Move frontendcomponents into ui-core

The new architecture has been implemented.

Co-authored-by: Eduard Heimbuch<eduard.heimbuch@cloudogu.com>
Committed-by: Eduard Heimbuch<eduard.heimbuch@cloudogu.com>
Pushed-by: Tarik Gürsoy<tarik.guersoy@cloudogu.com>
Committed-by: Tarik Gürsoy<tarik.guersoy@cloudogu.com>
Pushed-by: Eduard Heimbuch<eduard.heimbuch@cloudogu.com>
Co-authored-by: Tarik Gürsoy<tarik.guersoy@cloudogu.com>


Reviewed-by: Konstantin Schaper <konstantin.schaper@cloudogu.com>
This commit is contained in:
Tarik Gürsoy
2024-01-24 10:38:17 +01:00
parent 0cc3d3f598
commit 3ffbdd8d17
184 changed files with 1356 additions and 1535 deletions

View File

@@ -1,57 +0,0 @@
/*
* 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.
*/
const HtmlWebpackPlugin = require('html-webpack-plugin');
class RemoveThemesPlugin {
apply (compiler) {
compiler.hooks.compilation.tap('RemoveThemesPlugin', (compilation) => {
HtmlWebpackPlugin.getHooks(compilation).beforeAssetTagGeneration.tapAsync(
'RemoveThemesPlugin',
(data, cb) => {
// remove generated style-loader bundles from the page
// there should be a better way, which does not generate the bundles at all
// but for now it works
if (data.assets.js) {
data.assets.js = data.assets.js.filter(bundle => !bundle.startsWith("ui-theme-"))
.filter(bundle => !bundle.startsWith("runtime~ui-theme-"))
}
// remove css links to avoid conflicts with the themes
// so we remove all and add our own via preview-head.html
if (data.assets.css) {
data.assets.css = data.assets.css.filter(css => !css.startsWith("ui-theme-"))
}
// Tell webpack to move on
cb(null, data)
}
)
})
}
}
module.exports = RemoveThemesPlugin

View File

@@ -1,92 +0,0 @@
/*
* 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.
*/
const path = require("path");
const fs = require("fs");
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const RemoveThemesPlugin = require("./RemoveThemesPlugin");
const ReactDOM = require("react-dom");
const root = path.resolve("..");
const themedir = path.join(root, "ui-styles", "src");
ReactDOM.createPortal = (node) => node;
const themes = fs
.readdirSync(themedir)
.map((filename) => path.parse(filename))
.filter((p) => p.ext === ".scss")
.reduce((entries, current) => ({ ...entries, [`ui-theme-${current.name}`]: path.join(themedir, current.base) }), {});
module.exports = {
typescript: { reactDocgen: false },
core: {
builder: "webpack5",
},
stories: ["../src/**/*.stories.mdx", "../src/**/*.stories.@(js|jsx|ts|tsx)"],
addons: [
"storybook-addon-i18next",
"storybook-addon-themes",
"@storybook/addon-links",
"@storybook/addon-essentials",
"@storybook/addon-interactions",
"@storybook/addon-a11y",
"storybook-addon-pseudo-states",
"storybook-addon-mock",
],
framework: "@storybook/react",
webpackFinal: async (config) => {
// add our themes to webpack entry points
config.entry = {
main: config.entry,
...themes,
};
// create separate css files for our themes
config.plugins.push(
new MiniCssExtractPlugin({
filename: "[name].css",
ignoreOrder: false,
})
);
config.module.rules.push({
test: /\.scss$/,
use: [MiniCssExtractPlugin.loader, "css-loader", "sass-loader"],
});
// the html-webpack-plugin adds the generated css and js files to the iframe,
// which overrides our manually loaded css files.
// So we use a custom plugin which uses a hook of html-webpack-plugin
// to filter our themes from the output.
config.plugins.push(new RemoveThemesPlugin());
// force cjs instead of esm
// https://github.com/tannerlinsley/react-query/issues/3513
config.resolve.alias["react-query/devtools"] = require.resolve("react-query/devtools");
return config;
},
};

View File

@@ -1,26 +0,0 @@
<!--
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.
-->
<link id="ui-theme" data-theme="light" rel="stylesheet" type="text/css" href="/ui-theme-light.css">

View File

@@ -1,72 +0,0 @@
/*
* 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, { useEffect } from "react";
import { I18nextProvider, initReactI18next } from "react-i18next";
import i18n from "i18next";
i18n.use(initReactI18next).init({
whitelist: ["en", "de"],
lng: "en",
fallbackLng: "en",
interpolation: {
escapeValue: false,
},
react: {
useSuspense: false,
},
});
const Decorator = ({ children, themeName }) => {
useEffect(() => {
const link = document.querySelector("#ui-theme");
if (link && link["data-theme"] !== themeName) {
link.href = `ui-theme-${themeName}.css`;
link["data-theme"] = themeName;
}
}, [themeName]);
return <>{children}</>;
};
export const parameters = {
actions: { argTypesRegex: "^on[A-Z].*" },
decorators: [
(Story) => (
<I18nextProvider i18n={i18n}>
<Story />
</I18nextProvider>
),
],
themes: {
Decorator,
clearable: false,
default: "light",
list: [
{ name: "light", color: "#fff" },
{ name: "highcontrast", color: "#050514" },
{ name: "dark", color: "#121212" },
],
},
};

View File

@@ -22,6 +22,22 @@
* SOFTWARE.
*/
export const variants = ["danger"] as const;
export type Variant = typeof variants[number];
export const createVariantClass = (variant?: Variant) => (variant ? `is-${variant}` : undefined);
export {
Field,
Checkbox,
Combobox,
ConfigurationForm,
SelectField,
ComboboxField,
Input,
Textarea,
Select,
useCreateResource,
useUpdateResource,
useDeleteResource,
Label,
RadioGroup,
RadioGroupField,
ChipInputField,
Form
} from "@scm-manager/ui-core";

View File

@@ -2,52 +2,15 @@
"name": "@scm-manager/ui-forms",
"private": false,
"version": "3.0.0-SNAPSHOT",
"main": "build/index.js",
"types": "build/index.d.ts",
"module": "build/index.mjs",
"main": "index.ts",
"license": "MIT",
"scripts": {
"build": "tsup ./src/index.ts -d build --format esm,cjs --dts",
"storybook": "start-storybook -p 6006",
"build-storybook": "build-storybook"
},
"devDependencies": {
"@babel/core": "^7.19.0",
"@scm-manager/eslint-config": "^2.16.0",
"@scm-manager/prettier-config": "^2.10.1",
"@scm-manager/tsconfig": "^2.13.0",
"@scm-manager/ui-styles": "3.0.0-SNAPSHOT",
"@storybook/addon-actions": "^6.5.10",
"@storybook/addon-docs": "^6.5.14",
"@storybook/addon-essentials": "^6.5.10",
"@storybook/addon-interactions": "^6.5.10",
"@storybook/addon-links": "^6.5.10",
"@storybook/builder-webpack5": "^6.5.10",
"@storybook/manager-webpack5": "^6.5.10",
"@storybook/react": "^6.5.10",
"@storybook/testing-library": "^0.0.13",
"babel-loader": "^8.2.5",
"storybook-addon-mock": "^3.2.0",
"storybook-addon-themes": "^6.1.0",
"tsup": "^6.2.3"
"@scm-manager/tsconfig": "^2.13.0"
},
"peerDependencies": {
"@scm-manager/ui-components": "3.0.0-SNAPSHOT",
"classnames": "^2.3.1",
"react": "17",
"react-hook-form": "7",
"react-i18next": "11",
"react-query": "3",
"styled-components": "5"
},
"dependencies": {
"@headlessui/react": "^1.7.15",
"@radix-ui/react-radio-group": "^1.1.3",
"@radix-ui/react-slot": "^1.0.1",
"@radix-ui/react-visually-hidden": "^1.0.3",
"@scm-manager/ui-api": "3.0.0-SNAPSHOT",
"@scm-manager/ui-buttons": "3.0.0-SNAPSHOT",
"@scm-manager/ui-overlays": "3.0.0-SNAPSHOT"
"@scm-manager/ui-core": "3.0.0-SNAPSHOT"
},
"prettier": "@scm-manager/prettier-config",
"eslintConfig": {
@@ -56,4 +19,4 @@
"publishConfig": {
"access": "public"
}
}
}

View File

@@ -1,127 +0,0 @@
/*
* 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") }),
});
const titleLabel = translateWithExtraPrefix("title", {
defaultValue: defaultTranslate("list.title.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="">
<h3 className="subtitle is-5">{titleLabel}</h3>
<form id={nameWithPrefix} onSubmit={form.handleSubmit(onSubmit)} noValidate></form>
{typeof children === "function" ? children(form) : children}
<div className="level-left">
<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,59 +0,0 @@
/*
* 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 { useConfigLink } from "@scm-manager/ui-api";
import { Loading } from "@scm-manager/ui-components";
import React, { ComponentProps } from "react";
import { HalRepresentation } from "@scm-manager/ui-types";
import Form from "./Form";
import { useTranslation } from "react-i18next";
type Props<T extends HalRepresentation> =
// eslint-disable-next-line prettier/prettier
Omit<ComponentProps<typeof Form<T>>, "onSubmit" | "defaultValues" | "readOnly" | "successMessageFallback">
& {
link: string;
};
/**
* @beta
* @since 2.41.0
*/
export function ConfigurationForm<T extends HalRepresentation>({ link, children, ...formProps }: Props<T>) {
const { initialConfiguration, isReadOnly, update, isLoading } = useConfigLink<T>(link);
const [t] = useTranslation("commons", { keyPrefix: "form" });
if (isLoading || !initialConfiguration) {
return <Loading />;
}
return (
<Form onSubmit={update} defaultValues={initialConfiguration} readOnly={isReadOnly}
successMessageFallback={t("submit-success-notification")} {...formProps}>
{children}
</Form>
);
}
export default ConfigurationForm;

View File

@@ -1,453 +0,0 @@
/*
* 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, { useRef, useState } 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";
import { HalRepresentation } from "@scm-manager/ui-types";
import ControlledChipInputField from "./chip-input/ControlledChipInputField";
import Combobox from "./combobox/Combobox";
import ControlledComboboxField from "./combobox/ControlledComboboxField";
import { defaultOptionFactory } from "./helpers";
import ChipInput from "./headless-chip-input/ChipInput";
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 rules={{ required: true }} 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",
passwordConfirmation: "",
active: true,
}}
>
<FormRow>
<ControlledInputField name="name" />
</FormRow>
<FormRow>
<ControlledSecretConfirmationField name="password" />
</FormRow>
<FormRow>
<ControlledCheckboxField name="active" />
</FormRow>
</Form>
))
.add("Reset", () => {
type FormType = {
name: string;
password: string;
passwordConfirmation: string;
active: boolean;
date: string;
};
const [defaultValues, setValues] = useState({
name: "trillian",
password: "secret",
passwordConfirmation: "secret",
active: true,
date: "",
});
const resetValues = useRef({
name: "",
password: "",
passwordConfirmation: "",
active: false,
date: "",
});
return (
<Form<FormType>
onSubmit={(vals) => {
console.log(vals);
setValues(vals);
}}
translationPath={["sample", "form"]}
defaultValues={defaultValues as HalRepresentation & FormType}
withResetTo={resetValues.current}
withDiscardChanges
>
<FormRow>
<ControlledInputField name="name" />
</FormRow>
<FormRow>
<ControlledSecretConfirmationField name="password" rules={{ required: true }} />
</FormRow>
<FormRow>
<ControlledCheckboxField name="active" />
</FormRow>
<FormRow>
<ControlledInputField name="date" type="date" />
</FormRow>
</Form>
);
})
.add("Layout", () => (
<Form
onSubmit={console.log}
translationPath={["sample", "form"]}
defaultValues={{
name: "",
password: "",
method: "",
}}
>
<FormRow>
<ControlledInputField name="name" />
<ControlledSelectField name="method">
{["", "post", "get", "put"].map((value) => (
<option value={value} key={value}>
{value}
</option>
))}
</ControlledSelectField>
<ControlledSecretConfirmationField name="password" />
</FormRow>
</Form>
))
.add("GlobalConfiguration", () => (
<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,
labels: ["test"],
people: [{ displayName: "Trillian", id: 2 }],
author: null,
}}
>
<ControlledInputField name="url" />
<ControlledInputField name="option" />
<ControlledInputField name="anotherOption" />
<ControlledCheckboxField name="disableA" />
<ControlledCheckboxField name="disableB" />
<ControlledCheckboxField name="disableC" />
<ControlledChipInputField name="labels" />
<ControlledChipInputField
name="people"
optionFactory={(person) => ({ label: person.displayName, value: person })}
>
<Combobox
options={[
{ label: "Zenod", value: { id: 1, displayName: "Zenod" } },
{ label: "Arthur", value: { id: 3, displayName: "Arthur" } },
{ label: "Cookie Monster", value: { id: 4, displayName: "Cookie Monster" } },
]}
/>
</ControlledChipInputField>
<ControlledComboboxField
options={["Zenod", "Arthur", "Trillian"].map(defaultOptionFactory)}
name="author"
nullable
/>
</Form>
))
.add("ReadOnly", () => (
<Form
onSubmit={console.log}
translationPath={["sample", "form"]}
defaultValues={{
name: "trillian",
password: "secret",
active: true,
labels: ["test", "hero"],
}}
readOnly
>
<FormRow>
<ControlledInputField name="name" />
</FormRow>
<FormRow>
<ControlledSecretConfirmationField name="password" />
</FormRow>
<FormRow>
<ControlledCheckboxField name="active" />
</FormRow>
<FormRow>
<ControlledChipInputField name="labels" aria-label="Test" placeholder="New label..." />
</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,
},
],
},
],
}}
withResetTo={{
webhooks: [
{
urlPattern: "https://other.com",
executeOnEveryCommit: true,
sendCommitData: true,
method: "get",
headers: [
{
key: "change",
value: "new",
concealed: true,
},
],
},
{
urlPattern: "http://things.com",
executeOnEveryCommit: false,
sendCommitData: true,
method: "put",
headers: [
{
key: "stuff",
value: "haha",
concealed: false,
},
],
},
],
}}
>
<ScmFormListContextProvider name="webhooks">
<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 }}>
<FormRow>
<ControlledInputField
name="key"
rules={{
validate: (newKey) =>
!(webhook as SimpleWebHookConfiguration).headers.some(({ key }) => newKey === key),
required: true,
}}
/>
</FormRow>
<FormRow>
<ControlledInputField name="value" />
</FormRow>
<FormRow>
<ControlledCheckboxField name="concealed" />
</FormRow>
</AddListEntryForm>
</ScmFormListContextProvider>
</div>
</details>
</>
)}
</ControlledList>
</ScmFormListContextProvider>
</Form>
))
.add("Controlled Chip Input with add", () => {
const ref = useRef<HTMLInputElement>(null);
return (
<Form
onSubmit={console.log}
translationPath={["sample", "form"]}
defaultValues={{
branches: ["main", "develop"],
}}
>
<FormRow>
<ControlledChipInputField name="branches" ref={ref} />
</FormRow>
<FormRow>
<ChipInput.AddButton inputRef={ref}>Add</ChipInput.AddButton>
</FormRow>
</Form>
);
});

View File

@@ -1,214 +0,0 @@
/*
* 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, useCallback, useEffect, useState } from "react";
import { DeepPartial, SubmitHandler, useForm, UseFormReturn } from "react-hook-form";
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 styled from "styled-components";
import { setValues } from "./helpers";
type RenderProps<T extends Record<string, unknown>> = Omit<
UseFormReturn<T>,
"register" | "unregister" | "handleSubmit" | "control"
>;
const ButtonsContainer = styled.div`
display: flex;
gap: 0.75rem;
`;
const SuccessNotification: FC<{ label?: string; hide: () => void }> = ({ label, hide }) => {
if (!label) {
return null;
}
return (
<div className="notification is-success">
<button className="delete" onClick={hide} />
{label}
</div>
);
};
type Props<FormType extends Record<string, unknown>, DefaultValues extends FormType> = {
children: ((renderProps: RenderProps<FormType>) => React.ReactNode | React.ReactNode[]) | React.ReactNode;
translationPath: [namespace: string, prefix: string];
onSubmit: SubmitHandler<FormType>;
defaultValues: DefaultValues;
readOnly?: boolean;
submitButtonTestId?: string;
/**
* Renders a button which resets the form to its default.
* This reflects the default browser behavior for a form *reset*.
*
* @since 2.43.0
*/
withDiscardChanges?: boolean;
/**
* Renders a button which acts as if a user manually updated all fields to supplied values.
* The default use-case for this is to clear forms and this is also how the button is labelled.
* You can also use it to reset the form to an original state, but it is then advised to change the button label
* to *Reset to Defaults* by defining the *reset* translation in the form's translation object's root.
*
* > *Important Note:* This mechanism cannot be used to change the number of items in lists,
* > neither on the root level nor nested.
* > It is therefore advised not to use this property when lists or nested forms are involved.
*
* @since 2.43.0
*/
withResetTo?: DefaultValues;
/**
* Message to display after a successful submit if no translation key is defined.
*
* If this is not supplied and the root level `submit-success-notification` translation key is not set,
* no message is displayed at all.
*
* @since 2.43.0
*/
successMessageFallback?: string;
};
/**
* @beta
* @since 2.41.0
*/
function Form<FormType extends Record<string, unknown>, DefaultValues extends FormType = FormType>({
children,
onSubmit,
defaultValues,
translationPath,
readOnly,
withResetTo,
withDiscardChanges,
successMessageFallback,
submitButtonTestId,
}: Props<FormType, DefaultValues>) {
const form = useForm<FormType>({
mode: "onChange",
defaultValues: defaultValues as DeepPartial<FormType>,
});
const { formState, handleSubmit, reset, setValue } = 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 resetButtonLabel = t("reset", { defaultValue: defaultTranslate("reset") });
const discardChangesButtonLabel = t("discardChanges", { defaultValue: defaultTranslate("discardChanges") });
const successNotification = translateWithFallback("submit-success-notification", {
defaultValue: successMessageFallback,
});
const overwriteValues = useCallback(() => {
if (withResetTo) {
setValues(withResetTo, setValue);
}
}, [setValue, withResetTo]);
// See https://react-hook-form.com/api/useform/reset/
useEffect(() => {
if (isSubmitSuccessful) {
setShowSuccessNotification(true);
}
}, [isSubmitSuccessful]);
useEffect(() => reset(defaultValues as never), [defaultValues, reset]);
useEffect(() => {
if (isDirty) {
setShowSuccessNotification(false);
}
}, [isDirty]);
const submit = useCallback(
async (data) => {
setError(null);
try {
return await onSubmit(data);
} catch (e) {
if (e instanceof Error) {
setError(e);
} else {
throw e;
}
}
},
[onSubmit]
);
return (
<ScmFormContextProvider {...form} readOnly={isSubmitting || readOnly} t={translateWithFallback} formId={prefix}>
<form onSubmit={handleSubmit(submit)} onReset={() => reset()} id={prefix} noValidate></form>
{showSuccessNotification ? (
<SuccessNotification label={successNotification} hide={() => setShowSuccessNotification(false)} />
) : null}
{typeof children === "function" ? children(form) : children}
{error ? <ErrorNotification error={error} /> : null}
{!readOnly ? (
<Level
right={
<ButtonsContainer>
<Button
type="submit"
variant="primary"
testId={submitButtonTestId ?? "submit-button"}
disabled={!isDirty || !isValid}
isLoading={isSubmitting}
form={prefix}
>
{submitButtonLabel}
</Button>
{withDiscardChanges ? (
<Button type="reset" form={prefix} testId={`${prefix}-discard-changes-button`}>
{discardChangesButtonLabel}
</Button>
) : null}
{withResetTo ? (
<Button form={prefix} onClick={overwriteValues} testId={`${prefix}-reset-button`}>
{resetButtonLabel}
</Button>
) : null}
</ButtonsContainer>
}
/>
) : null}
</ScmFormContextProvider>
);
}
export default Form;

View File

@@ -1,73 +0,0 @@
/*
* 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

@@ -1,37 +0,0 @@
/*
* 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, { HTMLProps } from "react";
import classNames from "classnames";
const FormRow = React.forwardRef<HTMLDivElement, HTMLProps<HTMLDivElement>>(
({ className, children, hidden, ...rest }, ref) =>
hidden ? null : (
<div ref={ref} className={classNames("columns", className)} {...rest}>
{children}
</div>
)
);
export default FormRow;

View File

@@ -1,43 +0,0 @@
/*
* 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, { PropsWithChildren, useContext } from "react";
import { UseFormReturn } from "react-hook-form";
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);
export function ScmFormContextProvider<T>({ children, ...props }: PropsWithChildren<ContextType<T>>) {
return <ScmFormContext.Provider value={props}>{children}</ScmFormContext.Provider>;
}
export function useScmFormContext() {
return useContext(ScmFormContext);
}

View File

@@ -1,65 +0,0 @@
/*
* 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

@@ -1,34 +0,0 @@
/*
* 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, { HTMLProps } from "react";
import classNames from "classnames";
const Control = React.forwardRef<HTMLDivElement, HTMLProps<HTMLDivElement>>(({ className, children, ...rest }, ref) => (
<div className={classNames("control", className)} {...rest} ref={ref}>
{children}
</div>
));
export default Control;

View File

@@ -1,35 +0,0 @@
/*
* 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, HTMLProps } from "react";
import classNames from "classnames";
const Field: FC<HTMLProps<HTMLDivElement> | ({ as: keyof JSX.IntrinsicElements } & HTMLProps<HTMLElement>)> = ({
as = "div",
className,
children,
...rest
}) => React.createElement(as, { className: classNames("field", className), ...rest }, children);
export default Field;

View File

@@ -1,34 +0,0 @@
/*
* 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, { ReactNode } from "react";
import classNames from "classnames";
import { createVariantClass, Variant } from "../../variants";
type Props = { variant?: Variant; className?: string; children?: ReactNode };
const FieldMessage = ({ variant, className, children }: Props) => (
<p className={classNames("help", createVariantClass(variant), className)}>{children}</p>
);
export default FieldMessage;

View File

@@ -1,34 +0,0 @@
/*
* 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 { Tooltip } from "@scm-manager/ui-overlays";
type Props = { text?: string; className?: string };
const Help = ({ text, className }: Props) => (
<Tooltip className={className} message={text}>
<i className="fas fa-fw fa-question-circle has-text-blue-light" aria-hidden />
</Tooltip>
);
export default Help;

View File

@@ -1,35 +0,0 @@
/*
* 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, HTMLProps } from "react";
import classNames from "classnames";
const Label: FC<HTMLProps<HTMLLabelElement> | ({ as: keyof JSX.IntrinsicElements } & HTMLProps<HTMLElement>)> = ({
as = "label",
className,
children,
...rest
}) => React.createElement(as, { className: classNames("label", className), ...rest }, children);
export default Label;

View File

@@ -1,26 +0,0 @@
import {Meta, Story} from "@storybook/addon-docs";
import Checkbox from "./Checkbox";
<Meta
title="Checkbox"
/>
<Story name="Default">
<Checkbox name="name"/>
</Story>
<Story name="WithHardcodedText">
<Checkbox name="name" label="Name" helpText="A help text"/>
</Story>
<Story name="WithStyling">
<Checkbox name="name" className="has-background-blue-light"/>
</Story>
<Story name="WithInitialFocus">
<Checkbox name="name" autoFocus/>
</Story>
<Story name="Readonly">
<Checkbox name="name" checked readOnly/>
</Story>

View File

@@ -1,118 +0,0 @@
/*
* 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, { InputHTMLAttributes } from "react";
import { createAttributesForTesting } from "@scm-manager/ui-components";
import Help from "../base/help/Help";
import styled from "styled-components";
import classNames from "classnames";
const StyledInput = styled.input`
height: 1rem;
width: 1rem;
`;
const StyledLabel = styled.label`
margin-left: -0.75rem;
display: inline-flex;
`;
type InputFieldProps = {
label: string;
helpText?: string;
testId?: string;
labelClassName?: string;
} & Omit<InputHTMLAttributes<HTMLInputElement>, "type">;
/**
* @see https://bulma.io/documentation/form/checkbox/
*/
const Checkbox = React.forwardRef<HTMLInputElement, InputFieldProps>(
(
{
readOnly,
label,
className,
labelClassName,
value,
name,
checked,
defaultChecked,
defaultValue,
testId,
helpText,
...props
},
ref
) => (
<StyledLabel
className={classNames("checkbox is-align-items-center", labelClassName)}
// @ts-ignore bulma uses the disabled attribute on labels, although it is not part of the html spec
disabled={readOnly || props.disabled}
>
{readOnly ? (
<>
<input
type="hidden"
name={name}
value={value}
defaultValue={defaultValue}
checked={checked}
defaultChecked={defaultChecked}
readOnly
/>
<StyledInput
type="checkbox"
className={classNames("m-3", className)}
ref={ref}
value={value}
defaultValue={defaultValue}
checked={checked}
defaultChecked={defaultChecked}
{...props}
{...createAttributesForTesting(testId)}
disabled
/>
</>
) : (
<StyledInput
type="checkbox"
className={classNames("m-3", className)}
ref={ref}
name={name}
value={value}
defaultValue={defaultValue}
checked={checked}
defaultChecked={defaultChecked}
{...props}
{...createAttributesForTesting(testId)}
/>
)}
{label}
{helpText ? <Help className="ml-1" text={helpText} /> : null}
</StyledLabel>
)
);
export default Checkbox;

View File

@@ -1,39 +0,0 @@
/*
* 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 Field from "../base/Field";
import Control from "../base/Control";
import Checkbox from "./Checkbox";
type Props = React.ComponentProps<typeof Checkbox>;
const CheckboxField = React.forwardRef<HTMLInputElement, Props>(({ className, ...props }, ref) => (
<Field className={className}>
<Control>
<Checkbox ref={ref} {...props} />
</Control>
</Field>
));
export default CheckboxField;

View File

@@ -1,36 +0,0 @@
import { Meta, Story } from "@storybook/addon-docs";
import Form from "../Form";
import ControlledCheckboxField from "./ControlledCheckboxField";
<Meta
title="ControlledCheckboxField"
decorators={[
(Story) => (
<Form onSubmit={console.log} defaultValues={{ checkOne: false, checkTwo: true, checkThree: false }} translationPath={["sample", "form"]}>
<Story />
</Form>
),
]}
/>
<Story name="Default">
<ControlledCheckboxField name="checkOne" />
</Story>
<Story name="WithHardcodedText">
<ControlledCheckboxField name="checkOne" label="Name" helpText="A help text" />
</Story>
<Story name="WithStyling">
<ControlledCheckboxField name="checkOne" className="has-background-blue-light" />
</Story>
<Story name="WithInitialFocus">
<ControlledCheckboxField name="checkOne" autoFocus={true} />
</Story>
<Story name="WithReadonly">
<ControlledCheckboxField name="checkOne" readOnly />
<ControlledCheckboxField name="checkTwo" disabled />
<ControlledCheckboxField name="checkThree" />
</Story>

View File

@@ -1,82 +0,0 @@
/*
* 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, { ComponentProps } from "react";
import { Controller, ControllerRenderProps, Path, RegisterOptions } from "react-hook-form";
import { useScmFormContext } from "../ScmFormContext";
import CheckboxField from "./CheckboxField";
import { useScmFormPathContext } from "../FormPathContext";
import { prefixWithoutIndices } from "../helpers";
import classNames from "classnames";
type Props<T extends Record<string, unknown>> = Omit<
ComponentProps<typeof CheckboxField>,
"label" | "defaultValue" | "required" | keyof ControllerRenderProps
> & {
name: Path<T>;
label?: string;
rules?: Pick<RegisterOptions, "deps">;
};
function ControlledInputField<T extends Record<string, unknown>>({
name,
label,
helpText,
rules,
testId,
defaultChecked,
readOnly,
className,
...props
}: Props<T>) {
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={nameWithPrefix}
rules={rules}
defaultValue={defaultChecked as never}
render={({ field }) => (
<CheckboxField
form={formId}
readOnly={readOnly ?? formReadonly}
checked={field.value}
className={classNames("column", className)}
{...props}
{...field}
label={labelTranslation}
helpText={helpTextTranslation}
testId={testId ?? `checkbox-${nameWithPrefix}`}
/>
)}
/>
);
}
export default ControlledInputField;

View File

@@ -1,75 +0,0 @@
/*
* 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 { storiesOf } from "@storybook/react";
import React, { useRef, useState } from "react";
import ChipInputField from "./ChipInputField";
import Combobox from "../combobox/Combobox";
import { Option } from "@scm-manager/ui-types";
import ChipInput from "../headless-chip-input/ChipInput";
storiesOf("Chip Input Field", module)
.add("Default", () => {
const [value, setValue] = useState<Option<string>[]>([]);
const ref = useRef<HTMLInputElement>(null);
return (
<>
<ChipInputField
value={value}
onChange={setValue}
label="Test Chips"
placeholder="Type a new chip name and press enter to add"
aria-label="My personal chip list"
ref={ref}
/>
<ChipInput.AddButton inputRef={ref}>Add</ChipInput.AddButton>
</>
);
})
.add("With Autocomplete", () => {
const people = ["Durward Reynolds", "Kenton Towne", "Therese Wunsch", "Benedict Kessler", "Katelyn Rohan"];
const [value, setValue] = useState<Option<string>[]>([]);
return (
<ChipInputField
value={value}
onChange={setValue}
label="Persons"
placeholder="Enter a new person"
aria-label="Enter a new person"
>
<Combobox
options={(query: string) =>
Promise.resolve(
people
.map<Option<string>>((p) => ({ label: p, value: p }))
.filter((t) => !value.some((val) => val.label === t.label) && t.label.startsWith(query))
.concat({ label: query, value: query, displayValue: `Use '${query}'` })
)
}
/>
</ChipInputField>
);
});

View File

@@ -1,169 +0,0 @@
/*
* 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, { KeyboardEventHandler, PropsWithRef, ReactElement, Ref, RefObject, useCallback } from "react";
import { createAttributesForTesting, useGeneratedId } from "@scm-manager/ui-components";
import Field from "../base/Field";
import Label from "../base/label/Label";
import Help from "../base/help/Help";
import FieldMessage from "../base/field-message/FieldMessage";
import styled from "styled-components";
import classNames from "classnames";
import { createVariantClass } from "../variants";
import ChipInput, { NewChipInput } from "../headless-chip-input/ChipInput";
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
import { useTranslation } from "react-i18next";
import { withForwardRef } from "../helpers";
import { Option } from "@scm-manager/ui-types";
const StyledChipInput: typeof ChipInput = styled(ChipInput)`
min-height: 40px;
height: min-content;
gap: 0.5rem;
&:focus-within {
border: 1px solid var(--scm-info-color);
box-shadow: rgba(51, 178, 232, 0.25) 0px 0px 0px 0.125em;
}
`;
const StyledInput = styled(NewChipInput)`
color: var(--scm-secondary-more-color);
font-size: 1rem;
height: initial;
padding: 0;
border-radius: 0;
&:focus {
outline: none;
}
` as unknown as typeof NewChipInput;
const StyledDelete = styled(ChipInput.Chip.Delete)`
&:focus {
outline-offset: 0;
}
`;
type InputFieldProps<T> = {
label: string;
createDeleteText?: (value: string) => string;
helpText?: string;
error?: string;
testId?: string;
id?: string;
children?: ReactElement;
placeholder?: string;
onChange?: (newValue: Option<T>[]) => void;
value?: Option<T>[] | null;
onKeyDown?: KeyboardEventHandler<HTMLInputElement>;
readOnly?: boolean;
disabled?: boolean;
className?: string;
isLoading?: boolean;
isNewItemDuplicate?: (existingItem: Option<T>, newItem: Option<T>) => boolean;
ref?: Ref<HTMLInputElement>;
};
/**
* @beta
* @since 2.44.0
*/
const ChipInputField = function ChipInputField<T>(
{
label,
helpText,
readOnly,
disabled,
error,
createDeleteText,
onChange,
placeholder,
value,
className,
testId,
id,
children,
isLoading,
isNewItemDuplicate,
...props
}: PropsWithRef<InputFieldProps<T>>,
ref: React.ForwardedRef<HTMLInputElement>
) {
const [t] = useTranslation("commons", { keyPrefix: "form.chipList" });
const deleteTextCallback = useCallback(
(item) => (createDeleteText ? createDeleteText(item) : t("delete", { item })),
[createDeleteText, t]
);
const inputId = useGeneratedId(id ?? testId);
const labelId = useGeneratedId();
const inputDescriptionId = useGeneratedId();
const variant = error ? "danger" : undefined;
return (
<Field className={className} aria-owns={inputId}>
<Label id={labelId}>
{label}
{helpText ? <Help className="ml-1" text={helpText} /> : null}
</Label>
<div className={classNames("control", { "is-loading": isLoading })}>
<StyledChipInput
value={value}
onChange={(e) => onChange && onChange(e ?? [])}
className="is-flex is-flex-wrap-wrap input"
aria-labelledby={labelId}
disabled={readOnly || disabled}
isNewItemDuplicate={isNewItemDuplicate}
>
{value?.map((option, index) => (
<ChipInput.Chip key={option.label} className="tag is-light is-overflow-hidden">
<span className="is-ellipsis-overflow">{option.label}</span>
<StyledDelete aria-label={deleteTextCallback(option.label)} index={index} className="delete is-small" />
</ChipInput.Chip>
))}
<StyledInput
{...props}
className={classNames(
"is-borderless",
"has-background-transparent",
"is-shadowless",
"input",
"is-ellipsis-overflow",
createVariantClass(variant)
)}
placeholder={!readOnly && !disabled ? placeholder : ""}
id={inputId}
ref={ref}
aria-describedby={inputDescriptionId}
{...createAttributesForTesting(testId)}
>
{children ? children : null}
</StyledInput>
</StyledChipInput>
</div>
<VisuallyHidden aria-hidden id={inputDescriptionId}>
{t("input.description")}
</VisuallyHidden>
{error ? <FieldMessage variant={variant}>{error}</FieldMessage> : null}
</Field>
);
};
export default withForwardRef(ChipInputField);

View File

@@ -1,111 +0,0 @@
/*
* 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, { ComponentProps } from "react";
import { Controller, ControllerRenderProps, Path } from "react-hook-form";
import { useScmFormContext } from "../ScmFormContext";
import { useScmFormPathContext } from "../FormPathContext";
import { defaultOptionFactory, prefixWithoutIndices, withForwardRef } from "../helpers";
import classNames from "classnames";
import ChipInputField from "./ChipInputField";
import { Option } from "@scm-manager/ui-types";
type Props<T extends Record<string, unknown>> = Omit<
Parameters<typeof ChipInputField>[0],
"error" | "createDeleteText" | "label" | "defaultChecked" | "required" | keyof ControllerRenderProps
> & {
rules?: ComponentProps<typeof Controller>["rules"];
name: Path<T>;
label?: string;
defaultValue?: string[];
createDeleteText?: (value: string) => string;
optionFactory?: (val: any) => Option<unknown>;
ref?: React.ForwardedRef<HTMLInputElement>;
};
/**
* @beta
* @since 2.44.0
*/
function ControlledChipInputField<T extends Record<string, unknown>>(
{
name,
label,
helpText,
rules,
testId,
defaultValue,
readOnly,
placeholder,
className,
createDeleteText,
children,
optionFactory = defaultOptionFactory,
...props
}: Props<T>,
ref: React.ForwardedRef<HTMLInputElement>
) {
const { control, t, readOnly: formReadonly } = useScmFormContext();
const formPathPrefix = useScmFormPathContext();
const nameWithPrefix = formPathPrefix ? `${formPathPrefix}.${name}` : name;
const prefixedNameWithoutIndices = prefixWithoutIndices(nameWithPrefix);
const labelTranslation = label || t(`${prefixedNameWithoutIndices}.label`) || "";
const placeholderTranslation = placeholder || t(`${prefixedNameWithoutIndices}.placeholder`) || "";
const ariaLabelTranslation = t(`${prefixedNameWithoutIndices}.ariaLabel`);
const helpTextTranslation = helpText || t(`${prefixedNameWithoutIndices}.helpText`);
return (
<Controller
control={control}
name={nameWithPrefix}
rules={rules}
defaultValue={defaultValue}
render={({ field: { value, onChange, ...field }, fieldState }) => (
<ChipInputField
label={labelTranslation}
helpText={helpTextTranslation}
placeholder={placeholderTranslation}
aria-label={ariaLabelTranslation}
value={value ? value.map(optionFactory) : []}
onChange={(selectedOptions) => onChange(selectedOptions.map((item) => item.value))}
{...props}
{...field}
readOnly={readOnly ?? formReadonly}
className={classNames("column", className)}
error={
fieldState.error
? fieldState.error.message || t(`${prefixedNameWithoutIndices}.error.${fieldState.error.type}`)
: undefined
}
testId={testId ?? `input-${nameWithPrefix}`}
ref={ref}
>
{children}
</ChipInputField>
)}
/>
);
}
export default withForwardRef(ControlledChipInputField);

View File

@@ -1,125 +0,0 @@
/*
* 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 { storiesOf } from "@storybook/react";
import React, { Fragment, useState } from "react";
import Combobox from "./Combobox";
import { Combobox as HeadlessCombobox } from "@headlessui/react";
import { Option } from "@scm-manager/ui-types";
import { Link, BrowserRouter } from "react-router-dom";
const waitFor = (ms: number) =>
function <T>(result: T) {
return new Promise<T>((resolve) => setTimeout(() => resolve(result), ms));
};
const data = [
{ label: "Trillian", value: "1" },
{ label: "Arthur", value: "2" },
{ label: "Zaphod", value: "3" },
];
const linkData = [{ label: "Link111111111111111111111111111111111111", value: "1" }];
storiesOf("Combobox", module)
.add("Options array", () => {
const [value, setValue] = useState<Option<string>>();
return <Combobox options={data} value={value} onChange={setValue} />;
})
.add("Options function", () => {
const [value, setValue] = useState<Option<string>>();
return <Combobox options={() => data} value={value} onChange={setValue} />;
})
.add("Options function as promise", () => {
const [value, setValue] = useState<Option<string>>();
return (
<Combobox
value={value}
onChange={setValue}
options={(query) => Promise.resolve(data.filter((t) => t.label.startsWith(query))).then(waitFor(1000))}
/>
);
})
.add("Children as Element", () => {
const [value, setValue] = useState<Option<string>>();
const [query, setQuery] = useState("");
return (
<Combobox value={value} onChange={setValue} onQueryChange={setQuery}>
{query ? (
<HeadlessCombobox.Option value={{ label: query, value: query }} key={query} as={Fragment}>
{({ active }) => <Combobox.Option isActive={active}>{`Create ${query}`}</Combobox.Option>}
</HeadlessCombobox.Option>
) : null}
<HeadlessCombobox.Option value={{ label: "All", value: "All" }} key="all" as={Fragment}>
{({ active }) => <Combobox.Option isActive={active}>All</Combobox.Option>}
</HeadlessCombobox.Option>
<>
{data.map((o) => (
<HeadlessCombobox.Option value={o} key={o.value} as={Fragment}>
{({ active }) => <Combobox.Option isActive={active}>{o.label}</Combobox.Option>}
</HeadlessCombobox.Option>
))}
</>
</Combobox>
);
})
.add("Children as render props", () => {
const [value, setValue] = useState<Option<string>>();
return (
<Combobox options={data} value={value} onChange={setValue}>
{(o) => (
<HeadlessCombobox.Option value={o} key={o.value} as={Fragment}>
{({ active }) => <Combobox.Option isActive={active}>{o.label}</Combobox.Option>}
</HeadlessCombobox.Option>
)}
</Combobox>
);
})
.add("Links as render props", () => {
const [value, setValue] = useState<Option<string>>();
const [query, setQuery] = useState("Hello");
return (
<BrowserRouter>
<Combobox
className="input is-small omni-search-bar"
placeholder={"Placeholder"}
value={value}
options={linkData}
onChange={setValue}
onQueryChange={setQuery}
>
{(o) => (
<HeadlessCombobox.Option value={{ label: o.label, value: query, displayValue: o.value }} key={o.value} as={Fragment}>
{({ active }) => (
<Combobox.Option isActive={active}>
<Link to={o.label}>{o.label}</Link>
</Combobox.Option>
)}
</HeadlessCombobox.Option>
)}
</Combobox>
</BrowserRouter>
);
});

View File

@@ -1,223 +0,0 @@
/*
* 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, {
ForwardedRef,
Fragment,
KeyboardEventHandler,
ReactElement,
Ref,
useEffect,
useRef,
useState,
} from "react";
import { Combobox as HeadlessCombobox } from "@headlessui/react";
import classNames from "classnames";
import styled from "styled-components";
import { withForwardRef } from "../helpers";
import { createAttributesForTesting } from "@scm-manager/ui-components";
import { Option } from "@scm-manager/ui-types";
const OptionsWrapper = styled(HeadlessCombobox.Options).attrs({
className: "is-flex is-flex-direction-column has-rounded-border has-box-shadow is-overflow-hidden",
})`
z-index: 400;
top: 2.5rem;
border: var(--scm-border);
background-color: var(--scm-secondary-background);
max-width: 35ch;
width: 35ch;
&:empty {
border: 0;
clip: rect(0 0 0 0);
height: 1px;
margin: -1px;
overflow: hidden;
padding: 0;
position: absolute;
white-space: nowrap;
width: 1px;
}
`;
const StyledComboboxOption = styled.li.attrs({
className: "px-3 py-2 has-text-inherit is-clickable is-size-6 is-borderless has-background-transparent",
})<{ isActive: boolean }>`
line-height: inherit;
background-color: ${({ isActive }) => (isActive ? "var(--scm-column-selection)" : "")};
word-break: break-all;
&[data-disabled] {
color: unset !important;
opacity: 40%;
cursor: unset !important;
}
> a {
color: inherit !important;
}
`;
type BaseProps<T> = {
className?: string;
onKeyDown?: KeyboardEventHandler<HTMLElement>;
value?: Option<T> | null;
onChange?: (value?: Option<T>) => void;
onBlur?: () => void;
disabled?: boolean;
defaultValue?: Option<T>;
nullable?: boolean;
readOnly?: boolean;
onQueryChange?: (value: string) => void;
id?: string;
placeholder?: string;
"aria-describedby"?: string;
"aria-labelledby"?: string;
"aria-label"?: string;
testId?: string;
ref?: Ref<HTMLInputElement>;
form?: string;
name?: string;
};
export type ComboboxProps<T> =
| (BaseProps<T> & {
options: Array<Option<T>> | ((query: string) => Array<Option<T>> | Promise<Array<Option<T>>>);
children?: (option: Option<T>, index: number) => ReactElement;
})
| (BaseProps<T> & { children: Array<ReactElement | null>; options?: never });
/**
* @beta
* @since 2.45.0
*/
function ComboboxComponent<T>(props: ComboboxProps<T>, ref: ForwardedRef<HTMLInputElement>) {
const [query, setQuery] = useState("");
const { onQueryChange } = props;
useEffect(() => onQueryChange && onQueryChange(query), [onQueryChange, query]);
let options;
if (Array.isArray(props.children)) {
options = props.children;
} else if (typeof props.options === "function") {
options = <ComboboxOptions<T> children={props.children} options={props.options} query={query} />;
} else {
options = props.options?.map((o, index) =>
typeof props.children === "function" ? (
props.children(o, index)
) : (
<HeadlessCombobox.Option value={o} key={o.label} as={Fragment}>
{({ active }) => <StyledComboboxOption isActive={active}>{o.displayValue ?? o.label}</StyledComboboxOption>}
</HeadlessCombobox.Option>
)
);
}
return (
<HeadlessCombobox
as="div"
value={props.value}
onChange={(e?: Option<T>) => props.onChange && props.onChange(e)}
disabled={props.disabled || props.readOnly}
name={props.name}
form={props.form}
defaultValue={props.defaultValue}
// @ts-ignore
nullable={props.nullable}
className="is-relative is-flex-grow-1 is-flex"
by="value"
>
<HeadlessCombobox.Input<Option<T>>
displayValue={(o) => o?.label}
ref={ref}
onChange={(e) => setQuery(e.target.value)}
className={classNames(props.className, "is-full-width", "input", "is-ellipsis-overflow")}
aria-describedby={props["aria-describedby"]}
aria-labelledby={props["aria-labelledby"]}
aria-label={props["aria-label"]}
id={props.id}
placeholder={props.placeholder}
onBlur={props.onBlur}
autoComplete="off"
onKeyDown={(e) => {
props.onKeyDown && props.onKeyDown(e);
}}
{...createAttributesForTesting(props.testId)}
/>
<OptionsWrapper className="is-absolute">{options}</OptionsWrapper>
</HeadlessCombobox>
);
}
type ComboboxOptionsProps<T> = {
options: (query: string) => Array<Option<T>> | Promise<Array<Option<T>>>;
children?: (option: Option<T>, index: number) => ReactElement;
query: string;
};
function ComboboxOptions<T>({ query, options, children }: ComboboxOptionsProps<T>) {
const [optionsData, setOptionsData] = useState<Array<Option<T>>>([]);
const activePromise = useRef<Promise<Array<Option<T>>>>();
useEffect(() => {
const optionsExec = options(query);
if (optionsExec instanceof Promise) {
activePromise.current = optionsExec;
optionsExec
.then((newOptions) => {
if (activePromise.current === optionsExec) {
setOptionsData(newOptions);
}
})
.catch(() => {
if (activePromise.current === optionsExec) {
setOptionsData([]);
}
});
} else {
setOptionsData(optionsExec);
}
}, [options, query]);
return (
<>
{optionsData?.map((o, index) =>
typeof children === "function" ? (
children(o, index)
) : (
<HeadlessCombobox.Option value={o} key={o.label} as={Fragment}>
{({ active }) => <StyledComboboxOption isActive={active}>{o.displayValue ?? o.label}</StyledComboboxOption>}
</HeadlessCombobox.Option>
)
)}
</>
);
}
const Combobox = Object.assign(withForwardRef(ComboboxComponent), {
Option: StyledComboboxOption,
});
export default Combobox;

View File

@@ -1,62 +0,0 @@
/*
* 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 Field from "../base/Field";
import Label from "../base/label/Label";
import Help from "../base/help/Help";
import React from "react";
import { useGeneratedId } from "@scm-manager/ui-components";
import { withForwardRef } from "../helpers";
import Combobox, { ComboboxProps } from "./Combobox";
import classNames from "classnames";
/**
* @beta
* @since 2.45.0
*/
const ComboboxField = function ComboboxField<T>(
{
label,
helpText,
error,
className,
isLoading,
...props
}: ComboboxProps<T> & { label: string; helpText?: string; error?: string; isLoading?: boolean },
ref: React.ForwardedRef<HTMLInputElement>
) {
const labelId = useGeneratedId();
return (
<Field className={className}>
<Label id={labelId}>
{label}
{helpText ? <Help className="ml-1" text={helpText} /> : null}
</Label>
<div className={classNames("control", { "is-loading": isLoading })}>
<Combobox {...props} ref={ref} aria-labelledby={labelId} />
</div>
</Field>
);
};
export default withForwardRef(ComboboxField);

View File

@@ -1,96 +0,0 @@
/*
* 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, { ComponentProps } from "react";
import { Controller, ControllerRenderProps, Path } from "react-hook-form";
import { useScmFormContext } from "../ScmFormContext";
import { useScmFormPathContext } from "../FormPathContext";
import { defaultOptionFactory, prefixWithoutIndices } from "../helpers";
import classNames from "classnames";
import ComboboxField from "./ComboboxField";
import { Option } from "@scm-manager/ui-types";
type Props<T extends Record<string, unknown>> = Omit<
Parameters<typeof ComboboxField>[0],
"error" | "label" | keyof ControllerRenderProps
> & {
rules?: ComponentProps<typeof Controller>["rules"];
name: Path<T>;
label?: string;
optionFactory?: (val: any) => Option<unknown>;
};
/**
* @beta
* @since 2.45.0
*/
function ControlledComboboxField<T extends Record<string, unknown>>({
name,
label,
helpText,
rules,
testId,
defaultValue,
readOnly,
className,
optionFactory = defaultOptionFactory,
...props
}: Props<T>) {
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={nameWithPrefix}
rules={rules}
defaultValue={defaultValue as never}
render={({ field: { onChange, value, ...field }, fieldState }) => (
// @ts-ignore
<ComboboxField
form={formId}
readOnly={readOnly ?? formReadonly}
className={classNames("column", className)}
{...props}
{...field}
label={labelTranslation}
helpText={helpTextTranslation}
onChange={(e) => onChange(e?.value)}
value={optionFactory(value)}
error={
fieldState.error
? fieldState.error.message || t(`${prefixedNameWithoutIndices}.error.${fieldState.error.type}`)
: undefined
}
testId={testId ?? `select-${nameWithPrefix}`}
/>
)}
/>
);
}
export default ControlledComboboxField;

View File

@@ -1,237 +0,0 @@
/*
* 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, {
ButtonHTMLAttributes,
ComponentProps,
ComponentType,
Context,
createContext,
HTMLAttributes,
KeyboardEventHandler,
LiHTMLAttributes,
ReactElement,
RefObject,
useCallback,
useContext,
useMemo,
useRef,
} from "react";
import { Slot } from "@radix-ui/react-slot";
import { Option } from "@scm-manager/ui-types";
import { mergeRefs, withForwardRef } from "../helpers";
import { Button } from "@scm-manager/ui-buttons";
type ChipInputContextType<T> = {
add(newValue: Option<T>): void;
remove(index: number): void;
inputRef: RefObject<HTMLInputElement>;
disabled?: boolean;
readOnly?: boolean;
};
const ChipInputContext = createContext<ChipInputContextType<never>>(null as unknown as ChipInputContextType<never>);
function getChipInputContext<T>() {
return ChipInputContext as unknown as Context<ChipInputContextType<T>>;
}
type CustomNewChipInputProps<T> = {
onChange: (newValue: Option<T>) => void;
value?: Option<T> | null;
readOnly?: boolean;
disabled?: boolean;
ref?: React.Ref<HTMLInputElement>;
className?: string;
placeholder?: string;
id?: string;
"aria-describedby"?: string;
};
const DefaultNewChipInput = React.forwardRef<HTMLInputElement, CustomNewChipInputProps<string>>(
({ onChange, value, ...props }, ref) => {
const handleKeyDown = useCallback<KeyboardEventHandler<HTMLInputElement>>(
(e) => {
if (e.key === "Enter") {
e.preventDefault();
if (e.currentTarget.value) {
onChange({ label: e.currentTarget.value, value: e.currentTarget.value });
}
return false;
}
},
[onChange]
);
return (
<div className="is-flex-grow-1">
<input onKeyDown={handleKeyDown} {...props} ref={ref} />
</div>
);
}
);
type ChipDeleteProps = {
asChild?: boolean;
index: number;
} & Omit<ButtonHTMLAttributes<HTMLButtonElement>, "type" | "onClick">;
/**
* @beta
* @since 2.44.0
*/
export const ChipDelete = React.forwardRef<HTMLButtonElement, ChipDeleteProps>(({ asChild, index, ...props }, ref) => {
const { remove, disabled } = useContext(ChipInputContext);
const Comp = asChild ? Slot : "button";
if (disabled) {
return null;
}
return <Comp {...props} ref={ref} type="button" onClick={() => remove(index)} />;
});
type NewChipInputProps = {
children?: ReactElement | null;
ref?: React.Ref<HTMLInputElement>;
className?: string;
placeholder?: string;
id?: string;
"aria-describedby"?: string;
};
/**
* @beta
* @since 2.44.0
*/
export const NewChipInput = withForwardRef(function NewChipInput<T>(
props: NewChipInputProps,
ref: React.ForwardedRef<HTMLInputElement>
) {
const { add, disabled, readOnly, inputRef } = useContext(getChipInputContext<T>());
const Comp = props.children ? Slot : DefaultNewChipInput;
return React.createElement(Comp as unknown as ComponentType<CustomNewChipInputProps<T>>, {
...props,
onChange: add,
readOnly,
disabled,
value: null,
ref: mergeRefs(ref, inputRef),
});
});
type ChipProps = { asChild?: boolean } & LiHTMLAttributes<HTMLLIElement>;
/**
* @beta
* @since 2.44.0
*/
export const Chip = React.forwardRef<HTMLLIElement, ChipProps>(({ asChild, ...props }, ref) => {
const Comp = asChild ? Slot : "li";
return <Comp {...props} ref={ref} />;
});
type Props<T> = {
value?: Option<T>[] | null;
onChange?: (newValue?: Option<T>[]) => void;
readOnly?: boolean;
disabled?: boolean;
isNewItemDuplicate?: (existingItem: Option<T>, newItem: Option<T>) => boolean;
} & Omit<HTMLAttributes<HTMLUListElement>, "onChange">;
/**
* @beta
* @since 2.44.0
*/
const ChipInput = withForwardRef(function ChipInput<T>(
{ children, value = [], disabled, readOnly, onChange, isNewItemDuplicate, ...props }: Props<T>,
ref: React.ForwardedRef<HTMLUListElement>
) {
const inputRef = useRef<HTMLInputElement>(null);
const isInactive = useMemo(() => disabled || readOnly, [disabled, readOnly]);
const add = useCallback<(newValue: Option<T>) => void>(
(newItem) => {
if (
!isInactive &&
!value?.some((item) =>
isNewItemDuplicate
? isNewItemDuplicate(item, newItem)
: item.label === newItem.label || item.value === newItem.value
)
) {
if (onChange) {
onChange([...(value ?? []), newItem]);
}
if (inputRef.current) {
inputRef.current.value = "";
}
}
},
[isInactive, isNewItemDuplicate, onChange, value]
);
const remove = useCallback(
(index: number) => !isInactive && onChange && onChange(value?.filter((_, itdx) => itdx !== index)),
[isInactive, onChange, value]
);
const Context = getChipInputContext<T>();
return (
<Context.Provider
value={useMemo(
() => ({
disabled,
readOnly,
add,
remove,
inputRef,
}),
[add, disabled, readOnly, remove]
)}
>
<ul {...props} ref={ref}>
{children}
</ul>
</Context.Provider>
);
});
const AddButton = React.forwardRef<
HTMLButtonElement,
Omit<ComponentProps<typeof Button>, "onClick"> & { inputRef: RefObject<HTMLInputElement | null> }
>(({ inputRef, children, ...props }, ref) => (
<Button
{...props}
onClick={() => inputRef.current?.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }))}
>
{children}
</Button>
));
export default Object.assign(ChipInput, {
Chip: Object.assign(Chip, {
Delete: ChipDelete,
}),
AddButton,
});

View File

@@ -1,74 +0,0 @@
/*
* 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 { UseFormReturn } from "react-hook-form";
import { ForwardedRef, forwardRef, MutableRefObject, Ref, RefCallback } from "react";
export function prefixWithoutIndices(path: string): string {
return path.replace(/(\.\d+)/g, "");
}
/**
* Works like {@link setValue} but recursively applies the whole {@link newValues} object.
*
* > *Important Note:* This deeply overwrites input values of fields in arrays,
* > but does **NOT** add or remove items to existing arrays.
* > This can therefore not be used to clear lists.
*/
export function setValues<T>(newValues: T, setValue: UseFormReturn<T>["setValue"], path = "") {
for (const [key, val] of Object.entries(newValues)) {
if (val !== null && typeof val === "object") {
if (Array.isArray(val)) {
val.forEach((subVal, idx) => setValues(subVal, setValue, path ? `${path}.${key}.${idx}` : `${key}.${idx}`));
} else {
setValues(val, setValue, path ? `${path}.${key}` : key);
}
} else {
const fullPath = path ? `${path}.${key}` : key;
setValue(fullPath as any, val, { shouldValidate: !fullPath.endsWith("Confirmation"), shouldDirty: true });
}
}
}
export function withForwardRef<T extends { name: string }>(component: T): T {
return forwardRef(component as unknown as any) as any;
}
export const defaultOptionFactory = (item: any) =>
typeof item === "object" && item !== null && "value" in item && typeof item["label"] === "string"
? item
: { label: item as string, value: item };
export function mergeRefs<T>(...refs: Array<RefCallback<T> | MutableRefObject<T> | ForwardedRef<T>>) {
return (el: T) =>
refs.forEach((ref) => {
if (ref) {
if (typeof ref === "function") {
ref(el);
} else {
ref.current = el;
}
}
});
}

View File

@@ -1,85 +0,0 @@
/*
* 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 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 ControlledChipInputField from "./chip-input/ControlledChipInputField";
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";
import ControlledComboboxField from "./combobox/ControlledComboboxField";
import ChipInputFieldComponent from "./chip-input/ChipInputField";
import ChipInput from "./headless-chip-input/ChipInput";
import ControlledRadioGroupField from "./radio-button/ControlledRadioGroupField";
import RadioGroupComponent from "./radio-button/RadioGroup";
import RadioButton from "./radio-button/RadioButton";
import RadioGroupFieldComponent from "./radio-button/RadioGroupField";
export { default as Field } from "./base/Field";
export { default as Checkbox } from "./checkbox/Checkbox";
export { default as Combobox } from "./combobox/Combobox";
export { default as ConfigurationForm } from "./ConfigurationForm";
export { default as SelectField } from "./select/SelectField";
export { default as ComboboxField } from "./combobox/ComboboxField";
export { default as Input } from "./input/Input";
export { default as Textarea } from "./input/Textarea";
export { default as Select } from "./select/Select";
export * from "./resourceHooks";
export { default as Label } from "./base/label/Label";
const RadioGroupExport = {
Option: RadioButton,
};
export const RadioGroup = Object.assign(RadioGroupComponent, RadioGroupExport);
export const RadioGroupField = Object.assign(RadioGroupFieldComponent, RadioGroupExport);
export const ChipInputField = Object.assign(ChipInputFieldComponent, {
AddButton: ChipInput.AddButton,
});
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,
}),
ChipInput: ControlledChipInputField,
Combobox: ControlledComboboxField,
RadioGroup: Object.assign(ControlledRadioGroupField, RadioGroupExport),
});

View File

@@ -1,36 +0,0 @@
import { Meta, Story } from "@storybook/addon-docs";
import Form from "../Form";
import ControlledInputField from "./ControlledInputField";
<Meta
title="ControlledInputField"
decorators={[
(Story) => (
<Form onSubmit={console.log} defaultValues={{name: "Initial value"}} translationPath={["sample", "form"]}>
<Story />
</Form>
),
]}
/>
<Story name="Default">
<ControlledInputField name="name" />
</Story>
<Story name="WithHardcodedText">
<ControlledInputField name="name" label="Name" helpText="A help text" />
</Story>
<Story name="WithStyling">
<ControlledInputField name="name" className="has-background-blue-light" />
</Story>
<Story name="WithCheck">
<ControlledInputField name="name" rules={{
required: true
}} />
</Story>
<Story name="WithInitialFocus">
<ControlledInputField name="name" autoFocus={true} />
</Story>

View File

@@ -1,87 +0,0 @@
/*
* 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, { ComponentProps } from "react";
import { Controller, ControllerRenderProps, Path } from "react-hook-form";
import { useScmFormContext } from "../ScmFormContext";
import InputField from "./InputField";
import { useScmFormPathContext } from "../FormPathContext";
import { prefixWithoutIndices } from "../helpers";
import classNames from "classnames";
type Props<T extends Record<string, unknown>> = Omit<
ComponentProps<typeof InputField>,
"error" | "label" | "defaultChecked" | "required" | keyof ControllerRenderProps
> & {
rules?: ComponentProps<typeof Controller>["rules"];
name: Path<T>;
label?: string;
};
function ControlledInputField<T extends Record<string, unknown>>({
name,
label,
helpText,
rules,
testId,
defaultValue,
readOnly,
className,
...props
}: Props<T>) {
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={nameWithPrefix}
rules={rules}
defaultValue={defaultValue as never}
render={({ field, fieldState }) => (
<InputField
readOnly={readOnly ?? formReadonly}
required={rules?.required as boolean}
className={classNames("column", className)}
{...props}
{...field}
form={formId}
label={labelTranslation}
helpText={helpTextTranslation}
error={
fieldState.error
? fieldState.error.message || t(`${prefixedNameWithoutIndices}.error.${fieldState.error.type}`)
: undefined
}
testId={testId ?? `input-${nameWithPrefix}`}
/>
)}
/>
);
}
export default ControlledInputField;

View File

@@ -1,39 +0,0 @@
import { Meta, Story } from "@storybook/addon-docs";
import Form from "../Form";
import ControlledSecretConfirmationField from "./ControlledSecretConfirmationField";
<Meta
title="ControlledSecretConfirmationField"
decorators={[
(Story) => (
<Form onSubmit={console.log} defaultValues={{ password: "", passwordConfirmation: "" }} translationPath={["sample", "form"]}>
<Story />
</Form>
),
]}
/>
<Story name="Default">
<ControlledSecretConfirmationField name="password" />
</Story>
<Story name="WithHardcodedText">
<ControlledSecretConfirmationField name="password" label="Password" confirmationLabel="Password Confirmation" helpText="A help text" confirmationHelpText="Another help text" />
</Story>
<Story name="WithStyling">
<ControlledSecretConfirmationField name="password" className="has-background-blue-light" />
</Story>
<Story name="WithCheck">
<ControlledSecretConfirmationField
name="password"
rules={{
required: true,
}}
/>
</Story>
<Story name="WithInitialFocus">
<ControlledSecretConfirmationField name="password" autoFocus={true} />
</Story>

View File

@@ -1,138 +0,0 @@
/*
* 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, { ComponentProps, useCallback, useMemo } from "react";
import { Controller, ControllerRenderProps, Path } from "react-hook-form";
import { useScmFormContext } from "../ScmFormContext";
import InputField from "./InputField";
import { useScmFormPathContext } from "../FormPathContext";
import { prefixWithoutIndices } from "../helpers";
import classNames from "classnames";
type Props<T extends Record<string, unknown>> = Omit<
ComponentProps<typeof InputField>,
"error" | "label" | "defaultChecked" | "required" | keyof ControllerRenderProps
> & {
rules?: ComponentProps<typeof Controller>["rules"];
name: Path<T>;
label?: string;
confirmationLabel?: string;
confirmationHelpText?: string;
confirmationErrorMessage?: string;
confirmationTestId?: string;
};
export default function ControlledSecretConfirmationField<T extends Record<string, unknown>>({
name,
label,
confirmationLabel,
helpText,
confirmationHelpText,
rules,
confirmationErrorMessage,
className,
testId,
confirmationTestId,
defaultValue,
readOnly,
...props
}: Props<T>) {
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);
const validateConfirmField = useCallback(
(value) => secretValue === value || confirmationErrorMessageTranslation,
[confirmationErrorMessageTranslation, secretValue]
);
const confirmFieldRules = useMemo(
() => ({
validate: validateConfirmField,
}),
[validateConfirmField]
);
return (
<>
<Controller
control={control}
name={nameWithPrefix}
defaultValue={defaultValue as never}
rules={{
...rules,
deps: [`${nameWithPrefix}Confirmation`],
}}
render={({ field, fieldState }) => (
<InputField
className={classNames("column", 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(`${prefixedNameWithoutIndices}.error.${fieldState.error.type}`)
: undefined
}
testId={testId ?? `input-${nameWithPrefix}`}
/>
)}
/>
<Controller
control={control}
name={`${nameWithPrefix}Confirmation`}
defaultValue={defaultValue as never}
render={({ field, fieldState: { error } }) => (
<InputField
className={classNames("column", className)}
type="password"
readOnly={readOnly ?? formReadonly}
disabled={props.disabled}
{...field}
form={formId}
label={confirmationLabelTranslation}
helpText={confirmationHelpTextTranslation}
error={
error ? error.message || t(`${prefixedNameWithoutIndices}.confirmation.error.${error.type}`) : undefined
}
testId={confirmationTestId ?? `input-${nameWithPrefix}-confirmation`}
/>
)}
rules={confirmFieldRules}
/>
</>
);
}

View File

@@ -1,22 +0,0 @@
import { Meta, Story } from "@storybook/addon-docs";
import Input from "./Input"
<Meta title="Input" />
This will be our latest input component
<Story name="Default">
<Input />
</Story>
<Story name="With Variant">
<Input variant="danger" />
</Story>
<Story name="With Custom Class">
<Input className="is-warning" />
</Story>
<Story name="With Ref">
<Input ref={r => {r.focus()}} />
</Story>

View File

@@ -1,46 +0,0 @@
/*
* 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, { InputHTMLAttributes } from "react";
import classNames from "classnames";
import { createVariantClass, Variant } from "../variants";
import { createAttributesForTesting } from "@scm-manager/ui-components";
type Props = {
variant?: Variant;
testId?: string;
} & InputHTMLAttributes<HTMLInputElement>;
const Input = React.forwardRef<HTMLInputElement, Props>(({ variant, className, testId, ...props }, ref) => {
return (
<input
ref={ref}
className={classNames("input", createVariantClass(variant), className)}
{...props}
{...createAttributesForTesting(testId)}
/>
);
});
export default Input;

View File

@@ -1,22 +0,0 @@
import { Meta, Story } from "@storybook/addon-docs";
import InputField from "./InputField";
<Meta title="InputField" />
This will be our first form field molecule
<Story name="Default">
<InputField label="MyInput" />
</Story>
<Story name="WithHelp">
<InputField label="MyInput" helpText="You can do all sorts of things with this input" />
</Story>
<Story name="WithError">
<InputField label="MyInput" error="This field is super required" />
</Story>
<Story name="WithWidth">
<InputField label="MyInput" className="column is-half" />
</Story>

View File

@@ -1,61 +0,0 @@
/*
* 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 Field from "../base/Field";
import Control from "../base/Control";
import Label from "../base/label/Label";
import FieldMessage from "../base/field-message/FieldMessage";
import Input from "./Input";
import Help from "../base/help/Help";
import { useGeneratedId } from "@scm-manager/ui-components";
type InputFieldProps = {
label: string;
helpText?: string;
error?: string;
} & React.ComponentProps<typeof Input>;
/**
* @see https://bulma.io/documentation/form/input/
*/
const InputField = React.forwardRef<HTMLInputElement, InputFieldProps>(
({ label, helpText, error, className, id, ...props }, ref) => {
const inputId = useGeneratedId(id ?? props.testId);
const variant = error ? "danger" : undefined;
return (
<Field className={className}>
<Label htmlFor={inputId}>
{label}
{helpText ? <Help className="ml-1" text={helpText} /> : null}
</Label>
<Control>
<Input variant={variant} ref={ref} id={inputId} {...props}></Input>
</Control>
{error ? <FieldMessage variant={variant}>{error}</FieldMessage> : null}
</Field>
);
}
);
export default InputField;

View File

@@ -1,28 +0,0 @@
import { Meta, Story } from "@storybook/addon-docs";
import Textarea from "./Textarea"
<Meta title="Textarea" />
This will be our latest Textarea component
<Story name="Default">
<Textarea />
</Story>
<Story name="With Variant">
<Textarea variant="danger" />
</Story>
<Story name="With Custom Class">
<Textarea className="is-warning" />
</Story>
<Story name="With Ref">
<Textarea ref={r => {r.focus()}} />
</Story>
<Story name="With Default Value">
<Textarea>
value
</Textarea>
</Story>

View File

@@ -1,46 +0,0 @@
/*
* 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, { InputHTMLAttributes } from "react";
import classNames from "classnames";
import { createVariantClass, Variant } from "../variants";
import { createAttributesForTesting } from "@scm-manager/ui-components";
type Props = {
variant?: Variant;
testId?: string;
} & InputHTMLAttributes<HTMLTextAreaElement>;
const Textarea = React.forwardRef<HTMLTextAreaElement, Props>(({ variant, className, testId, ...props }, ref) => {
return (
<textarea
ref={ref}
className={classNames("textarea", createVariantClass(variant), className)}
{...props}
{...createAttributesForTesting(testId)}
/>
);
});
export default Textarea;

View File

@@ -1,88 +0,0 @@
/*
* 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

@@ -1,94 +0,0 @@
/*
* 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, { ComponentProps } from "react";
import RadioGroupField from "./RadioGroupField";
import { Controller, ControllerRenderProps, Path } from "react-hook-form";
import { useScmFormContext } from "../ScmFormContext";
import { useScmFormPathContext } from "../FormPathContext";
import { prefixWithoutIndices } from "../helpers";
import classNames from "classnames";
import { RadioButtonContextProvider } from "./RadioButtonContext";
type Props<T extends Record<string, unknown>> = Omit<
ComponentProps<typeof RadioGroupField>,
"label" | keyof ControllerRenderProps
> & {
name: Path<T>;
rules?: ComponentProps<typeof Controller>["rules"];
label?: string;
readOnly?: boolean;
};
/**
* @beta
* @since 2.48.0
*/
function ControlledRadioGroupField<T extends Record<string, unknown>>({
name,
label,
helpText,
rules,
defaultValue,
children,
fieldClassName,
readOnly,
...props
}: Props<T>) {
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={nameWithPrefix}
rules={rules}
defaultValue={defaultValue}
render={({ field }) => (
<RadioButtonContextProvider t={t} prefix={prefixedNameWithoutIndices} formId={formId}>
<RadioGroupField
defaultValue={defaultValue}
disabled={readOnly ?? formReadOnly}
required={rules?.required as boolean}
label={labelTranslation}
helpText={helpTextTranslation}
fieldClassName={classNames("column", fieldClassName)}
onValueChange={field.onChange}
{...props}
{...field}
name={nameWithPrefix}
>
{children}
</RadioGroupField>
</RadioButtonContextProvider>
)}
/>
);
}
export default ControlledRadioGroupField;

View File

@@ -1,226 +0,0 @@
/*
* 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, { useState } from "react";
import { storiesOf } from "@storybook/react";
import RadioButton from "./RadioButton";
import RadioGroup from "./RadioGroup";
import RadioGroupField from "./RadioGroupField";
import Form from "../Form";
import ControlledRadioGroupField from "./ControlledRadioGroupField";
import ControlledInputField from "../input/ControlledInputField";
import FormRow from "../FormRow";
import { ScmFormListContextProvider } from "../ScmFormListContext";
import ControlledList from "../list/ControlledList";
import ControlledTable from "../table/ControlledTable";
import ControlledColumn from "../table/ControlledColumn";
import AddListEntryForm from "../AddListEntryForm";
storiesOf("Radio Group", module)
.add("Uncontrolled State", () => {
return (
<RadioGroup name="starter_pokemon">
<RadioButton value="CHARMANDER" label="Charmander" />
<RadioButton value="SQUIRTLE" label="Squirtle" />
<RadioButton value="BULBASAUR" label="Bulbasaur" helpText="Dont pick this one" />
</RadioGroup>
);
})
.add("Controlled State", () => {
const [value, setValue] = useState<string | undefined>(undefined);
return (
<RadioGroup name="starter_pokemon" value={value} onValueChange={setValue}>
<RadioButton value="CHARMANDER" label="Charmander" />
<RadioButton value="SQUIRTLE" label="Squirtle" />
<RadioButton value="BULBASAUR" label="Bulbasaur" helpText="Dont pick this one" />
</RadioGroup>
);
})
.add("Radio Group Field", () => {
const [value, setValue] = useState<string | undefined>(undefined);
return (
<RadioGroupField
label="Choose your starter"
helpText="Choose wisely"
name="starter_pokemon"
value={value}
onValueChange={setValue}
>
<RadioButton value="CHARMANDER" label="Charmander" />
<RadioButton value="SQUIRTLE" label="Squirtle" />
<RadioButton value="BULBASAUR" label="Bulbasaur" helpText="Dont pick this one" />
</RadioGroupField>
);
})
.add("Controlled Radio Group Field", () => (
<Form
// eslint-disable-next-line no-console
onSubmit={console.log}
translationPath={["sample", "form"]}
defaultValues={{ name: "", starter_pokemon: null }}
>
<FormRow>
<ControlledInputField name="name" />
</FormRow>
<FormRow>
<ControlledRadioGroupField
name="starter_pokemon"
label="Starter Pokemon"
helpText="Choose wisely"
rules={{ required: true }}
>
<RadioButton value="CHARMANDER" label="Charmander" labelClassName="is-block" />
<RadioButton value="SQUIRTLE" label="Squirtle" />
<RadioButton value="BULBASAUR" label="Bulbasaur" helpText="Dont pick this one" />
</ControlledRadioGroupField>
</FormRow>
</Form>
))
.add("Readonly Controlled Radio Group Field", () => (
<Form
// eslint-disable-next-line no-console
onSubmit={console.log}
translationPath={["sample", "form"]}
defaultValues={{ name: "Red", starter_pokemon: "SQUIRTLE" }}
>
<FormRow>
<ControlledInputField name="name" />
</FormRow>
<FormRow>
<ControlledRadioGroupField
name="starter_pokemon"
label="Starter Pokemon"
helpText="Choose wisely"
readOnly={true}
>
<RadioButton value="CHARMANDER" label="Charmander" labelClassName="is-block" />
<RadioButton value="SQUIRTLE" label="Squirtle" />
<RadioButton value="BULBASAUR" label="Bulbasaur" helpText="Dont pick this one" />
</ControlledRadioGroupField>
</FormRow>
</Form>
))
.add("With options", () => (
<Form
// eslint-disable-next-line no-console
onSubmit={console.log}
translationPath={["sample", "form"]}
defaultValues={{ starter_pokemon: "CHARMANDER" }}
>
<FormRow>
<ControlledRadioGroupField
name="starter_pokemon"
label="Starter Pokemon"
helpText="Choose wisely"
rules={{ required: true }}
options={[
{ value: "CHARMANDER", label: "Charmander" },
{ value: "SQUIRTLE", label: "Squirtle" },
{ value: "BULBASAUR", label: "Bulbasaur", helpText: "Dont pick this one" },
]}
/>
</FormRow>
</Form>
))
.add("Without label prop", () => (
<Form
// eslint-disable-next-line no-console
onSubmit={console.log}
translationPath={["sample", "form"]}
defaultValues={{ starter_pokemon: "CHARMANDER" }}
>
<FormRow>
<ControlledRadioGroupField
name="starter_pokemon"
label="Starter Pokemon"
helpText="Choose wisely"
rules={{ required: true }}
options={[
{ value: "CHARMANDER" },
{ value: "SQUIRTLE" },
{ value: "BULBASAUR", helpText: "Dont pick this one" },
]}
/>
</FormRow>
</Form>
))
.add("Nested", () => (
<Form
translationPath={["sample", "form"]}
// eslint-disable-next-line no-console
onSubmit={console.log}
defaultValues={{
trainers: [
{
name: "Red",
team: [{ nickname: "Charlie", pokemon: "CHARMANDER" }],
},
{
name: "Blue",
team: [{ nickname: "Squirtle", pokemon: "SQUIRTLE" }],
},
{
name: "Green",
team: [{ nickname: "Plant", pokemon: "SQUIRTLE" }],
},
],
}}
>
<ScmFormListContextProvider name={"trainers"}>
<ControlledList withDelete>
{({ value: trainers }) => (
<>
<FormRow>
<ControlledInputField name={"name"} />
</FormRow>
<details className="has-background-dark-25 mb-2 p-2">
<summary className="is-clickable">Team</summary>
<div>
<ScmFormListContextProvider name={"team"}>
<ControlledTable withDelete>
<ControlledColumn name="nickname" />
<ControlledColumn name="pokemon" />
</ControlledTable>
<AddListEntryForm defaultValues={{ nickname: "", pokemon: null }}>
<FormRow>
<ControlledInputField name="nickname" />
</FormRow>
<FormRow>
<ControlledRadioGroupField
name="pokemon"
label="Starter Pokemon"
rules={{ required: true }}
options={[{ value: "CHARMANDER" }, { value: "SQUIRTLE" }, { value: "BULBASAUR" }]}
/>
</FormRow>
</AddListEntryForm>
</ScmFormListContextProvider>
</div>
</details>
</>
)}
</ControlledList>
</ScmFormListContextProvider>
</Form>
));

View File

@@ -1,116 +0,0 @@
/*
* 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, { ComponentProps } from "react";
import classNames from "classnames";
import Help from "../base/help/Help";
import * as RadioGroup from "@radix-ui/react-radio-group";
import { createAttributesForTesting, useGeneratedId } from "@scm-manager/ui-components";
import styled from "styled-components";
import { useRadioButtonContext } from "./RadioButtonContext";
const StyledRadioButton = styled(RadioGroup.Item)`
all: unset;
width: 1rem;
height: 1rem;
border: var(--scm-border);
border-radius: 100%;
:hover {
border-color: var(--scm-hover-color);
}
:hover *::after {
background-color: var(--scm-info-hover-color);
}
:disabled {
background-color: var(--scm-dark-color-25);
border-color: var(--scm-hover-color);
}
:disabled *::after {
background-color: var(--scm-info-color);
}
`;
const StyledIndicator = styled(RadioGroup.Indicator)`
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
position: relative;
::after {
content: "";
display: block;
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
background-color: var(--scm-info-color);
}
`;
type Props = {
value: string;
id?: string;
testId?: string;
indicatorClassName?: string;
label?: string;
labelClassName?: string;
helpText?: string;
} & ComponentProps<typeof RadioGroup.Item>;
/**
* @beta
* @since 2.48.0
*/
const RadioButton = React.forwardRef<HTMLButtonElement, Props>(
({ id, testId, indicatorClassName, label, labelClassName, className, helpText, value, ...props }, ref) => {
const context = useRadioButtonContext();
const inputId = useGeneratedId(id);
const labelKey = `${context?.prefix}.radio.${value}`;
return (
<label className={classNames("radio is-flex is-align-items-center", labelClassName)} htmlFor={inputId}>
<StyledRadioButton
form={context?.formId}
id={inputId}
value={value}
ref={ref}
className={classNames("mr-3 mt-3 mb-3", className)}
{...props}
{...createAttributesForTesting(testId)}
>
<StyledIndicator className={indicatorClassName} />
</StyledRadioButton>
{label ?? context?.t(labelKey) ?? value}
{helpText ? <Help className="ml-3" text={helpText} /> : null}
</label>
);
}
);
export default RadioButton;

View File

@@ -1,42 +0,0 @@
/*
* 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, { createContext, FC, useContext } from "react";
import { TFunction } from "i18next";
type ContextType = {
t: TFunction;
prefix: string;
formId?: string;
};
const RadioButtonContext = createContext<ContextType | null>(null);
export function useRadioButtonContext() {
return useContext(RadioButtonContext);
}
export const RadioButtonContextProvider: FC<ContextType> = ({ children, ...props }) => (
<RadioButtonContext.Provider value={props}>{children}</RadioButtonContext.Provider>
);

View File

@@ -1,49 +0,0 @@
/*
* 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, { ComponentProps } from "react";
import Control from "../base/Control";
import * as RadixRadio from "@radix-ui/react-radio-group";
import RadioButton from "./RadioButton";
type Props = {
options?: { value: string; label?: string; helpText?: string }[];
} & ComponentProps<typeof RadixRadio.Root>;
/**
* @beta
* @since 2.48.0
*/
const RadioGroup = React.forwardRef<HTMLDivElement, Props>(({ options, children, className, ...props }, ref) => (
<RadixRadio.Root {...props} asChild>
<Control ref={ref} className={className}>
{children ??
options?.map((option) => (
<RadioButton key={option.value} value={option.value} label={option.label} helpText={option.helpText} />
))}
</Control>
</RadixRadio.Root>
));
export default RadioGroup;

View File

@@ -1,58 +0,0 @@
/*
* 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, { ComponentProps } from "react";
import Field from "../base/Field";
import Label from "../base/label/Label";
import Help from "../base/help/Help";
import RadioGroup from "./RadioGroup";
type Props = {
fieldClassName?: string;
labelClassName?: string;
label: string;
helpText?: string;
} & ComponentProps<typeof RadioGroup>;
/**
* @beta
* @since 2.48.0
*/
const RadioGroupField = React.forwardRef<HTMLDivElement, Props>(
({ fieldClassName, labelClassName, label, helpText, children, ...props }, ref) => {
return (
<Field className={fieldClassName} as="fieldset">
<Label className={labelClassName} as="legend">
{label}
{helpText ? <Help className="ml-1" text={helpText} /> : null}
</Label>
<RadioGroup ref={ref} {...props}>
{children}
</RadioGroup>
</Field>
);
}
);
export default RadioGroupField;

View File

@@ -1,164 +0,0 @@
/*
* 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 { apiClient, requiredLink } from "@scm-manager/ui-api";
import { useMutation, useQueryClient } from "react-query";
import { HalRepresentation, Link } from "@scm-manager/ui-types";
type QueryKeyPair = [singular: string, plural: string];
type LinkOrHalLink = string | [entity: HalRepresentation, link: string] | HalRepresentation;
const unwrapLink = (input: LinkOrHalLink, linkName: string) => {
if (Array.isArray(input)) {
return requiredLink(input[0], input[1]);
} else if (typeof input === "string") {
return input;
} else {
return (input._links[linkName] as Link).href;
}
};
type MutationResult<I, O = unknown> = {
submit: (resource: I) => Promise<O>;
isLoading: boolean;
error: Error | null;
submissionResult?: O;
};
type MutatingResourceOptions = {
contentType?: string;
};
const createResource = <I, O = never>(link: string, contentType: string) => {
return (payload: I): Promise<O> => {
return apiClient
.post(link, payload, contentType)
.then((response) => {
const location = response.headers.get("Location");
if (!location) {
throw new Error("Server does not return required Location header");
}
return apiClient.get(location);
})
.then((response) => response.json());
};
};
type CreateResourceOptions = MutatingResourceOptions;
/**
* @beta
* @since 2.41.0
*/
export const useCreateResource = <I, O>(
link: string,
[entityKey, collectionName]: QueryKeyPair,
idFactory: (createdResource: O) => string,
{ contentType = "application/json" }: CreateResourceOptions = {}
): MutationResult<I, O> => {
const queryClient = useQueryClient();
const { mutateAsync, data, isLoading, error } = useMutation<O, Error, I>(createResource<I, O>(link, contentType), {
onSuccess: (result) => {
queryClient.setQueryData([entityKey, idFactory(result)], result);
return queryClient.invalidateQueries(collectionName);
},
});
return {
submit: (payload: I) => mutateAsync(payload),
isLoading,
error,
submissionResult: data,
};
};
type UpdateResourceOptions = MutatingResourceOptions & {
collectionName?: QueryKeyPair;
};
/**
* @beta
* @since 2.41.0
*/
export const useUpdateResource = <T>(
link: LinkOrHalLink,
idFactory: (createdResource: T) => string,
{
contentType = "application/json",
collectionName: [entityQueryKey, collectionName] = ["", ""],
}: UpdateResourceOptions = {}
): MutationResult<T> => {
const queryClient = useQueryClient();
const { mutateAsync, isLoading, error, data } = useMutation<unknown, Error, T>(
(resource) => apiClient.put(unwrapLink(link, "update"), resource, contentType),
{
onSuccess: async (_, payload) => {
await queryClient.invalidateQueries(entityQueryKey ? [entityQueryKey, idFactory(payload)] : idFactory(payload));
if (collectionName) {
await queryClient.invalidateQueries(collectionName);
}
},
}
);
return {
submit: (resource: T) => mutateAsync(resource),
isLoading,
error,
submissionResult: data,
};
};
type DeleteResourceOptions = {
collectionName?: QueryKeyPair;
};
/**
* @beta
* @since 2.41.0
*/
export const useDeleteResource = <T extends HalRepresentation>(
idFactory: (createdResource: T) => string,
{ collectionName: [entityQueryKey, collectionName] = ["", ""] }: DeleteResourceOptions = {}
): MutationResult<T> => {
const queryClient = useQueryClient();
const { mutateAsync, isLoading, error, data } = useMutation<unknown, Error, T>(
(resource) => {
const deleteUrl = (resource._links.delete as Link).href;
return apiClient.delete(deleteUrl);
},
{
onSuccess: async (_, resource) => {
const id = idFactory(resource);
await queryClient.removeQueries(entityQueryKey ? [entityQueryKey, id] : id);
if (collectionName) {
await queryClient.invalidateQueries(collectionName);
}
},
}
);
return {
submit: (resource: T) => mutateAsync(resource),
isLoading,
error,
submissionResult: data,
};
};

View File

@@ -1,87 +0,0 @@
/*
* 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, { ComponentProps } from "react";
import { Controller, ControllerRenderProps, Path } from "react-hook-form";
import { useScmFormContext } from "../ScmFormContext";
import SelectField from "./SelectField";
import { useScmFormPathContext } from "../FormPathContext";
import { prefixWithoutIndices } from "../helpers";
import classNames from "classnames";
type Props<T extends Record<string, unknown>> = Omit<
ComponentProps<typeof SelectField>,
"error" | "label" | "required" | keyof ControllerRenderProps
> & {
rules?: ComponentProps<typeof Controller>["rules"];
name: Path<T>;
label?: string;
};
function ControlledSelectField<T extends Record<string, unknown>>({
name,
label,
helpText,
rules,
testId,
defaultValue,
readOnly,
className,
...props
}: Props<T>) {
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={nameWithPrefix}
rules={rules}
defaultValue={defaultValue as never}
render={({ field, fieldState }) => (
<SelectField
form={formId}
readOnly={readOnly ?? formReadonly}
required={rules?.required as boolean}
className={classNames("column", className)}
{...props}
{...field}
label={labelTranslation}
helpText={helpTextTranslation}
error={
fieldState.error
? fieldState.error.message || t(`${prefixedNameWithoutIndices}.error.${fieldState.error.type}`)
: undefined
}
testId={testId ?? `select-${nameWithPrefix}`}
/>
)}
/>
);
}
export default ControlledSelectField;

View File

@@ -1,57 +0,0 @@
/*
* 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, { 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>;
/**
* @beta
* @since 2.44.0
*/
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)} className={className}>
{options
? options.map((opt) => (
<option {...opt} key={opt.value as Key}>
{opt.label}
{opt.children}
</option>
))
: children}
</select>
</div>
)
);
export default Select;

View File

@@ -1,63 +0,0 @@
/*
* 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 Field from "../base/Field";
import Control from "../base/Control";
import Label from "../base/label/Label";
import FieldMessage from "../base/field-message/FieldMessage";
import Help from "../base/help/Help";
import Select from "./Select";
import { useGeneratedId } from "@scm-manager/ui-components";
type Props = {
label: string;
helpText?: string;
error?: string;
} & React.ComponentProps<typeof Select>;
/**
* @see https://bulma.io/documentation/form/select/
* @beta
* @since 2.44.0
*/
const SelectField = React.forwardRef<HTMLSelectElement, Props>(
({ label, helpText, error, className, id, ...props }, ref) => {
const selectId = useGeneratedId(id ?? props.testId);
const variant = error ? "danger" : undefined;
return (
<Field className={className}>
<Label htmlFor={selectId}>
{label}
{helpText ? <Help className="ml-1" text={helpText} /> : null}
</Label>
<Control>
<Select id={selectId} variant={variant} ref={ref} className="is-full-width" {...props}></Select>
</Control>
{error ? <FieldMessage variant={variant}>{error}</FieldMessage> : null}
</Field>
);
}
);
export default SelectField;

View File

@@ -1,49 +0,0 @@
/*
* 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

@@ -1,99 +0,0 @@
/*
* 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";
import { Notification } from "@scm-manager/ui-components";
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 emptyTableLabel = t(`${prefixedNameWithoutIndices}.empty`) || defaultTranslate("empty.label");
const actionHeaderLabel = t(`${prefixedNameWithoutIndices}.action.label`) || defaultTranslate("headers.action.label");
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.length === 0 ? <tr><td colSpan={1000}><Notification type="info">{emptyTableLabel}</Notification></td></tr> : null}
{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;