Introduce ui-forms framework

Adding a new ui framework to make creating forms as easy and consistent as possible. It wraps a lot of boilerplate code and enforces good practices for make the forms in the "SCM-Manager way".


Co-authored-by: Florian Scholdei <florian.scholdei@cloudogu.com>
Co-authored-by: Konstantin Schaper <konstantin.schaper@cloudogu.com>

Reviewed-by: Rene Pfeuffer <rene.pfeuffer@cloudogu.com>
This commit is contained in:
Eduard Heimbuch
2023-01-02 08:59:07 +01:00
committed by SCM-Manager
parent f2f2f29791
commit 72dfe80843
52 changed files with 3711 additions and 548 deletions

View File

@@ -23,7 +23,8 @@
"react": "^17.0.1",
"react-dom": "^17.0.1",
"react-router-dom": "^5.3.1",
"classnames": "^2.2.6"
"classnames": "^2.2.6",
"@scm-manager/ui-components": "2.40.2-SNAPSHOT"
},
"devDependencies": {
"@scm-manager/prettier-config": "^2.11.1",
@@ -57,7 +58,7 @@
"html-webpack-plugin": "^5.5.0",
"react-query": "^3.25.1",
"i18next": "^19.9.2",
"react-i18next": "^10.13.2",
"react-i18next": "11",
"i18next-fetch-backend": "^2.3.1",
"depcheck": "^1.4.3"
},

View File

@@ -30,7 +30,7 @@ import {
ButtonVariants,
ExternalLinkButton as ExternalLinkButtonComponent,
LinkButton as LinkButtonComponent,
} from "./button";
} from "./Button";
import StoryRouter from "storybook-react-router";
import { StoryFn } from "@storybook/react";

View File

@@ -1,5 +1,5 @@
import { Meta, Story } from "@storybook/addon-docs";
import { Button, ButtonVariantList } from "./button";
import { Button, ButtonVariantList } from "./Button";
<Meta title="Tests"/>
@@ -18,9 +18,10 @@ import { Button, ButtonVariantList } from "./button";
<th>STATE</th>
{ButtonVariantList.map(variant => <th>{variant.toUpperCase()}</th>)}
</tr>
{["Normal", "Hover", "Active", "Focus", "Disabled"].map(state => <tr>
{["Normal", "Hover", "Active", "Focus", "Disabled", "Loading"].map(state => <tr>
<td>{state}</td>
{ButtonVariantList.map(variant => <td><Button id={`${variant}-${state}`} disabled={state === "Disabled"}
isLoading={state === "Loading"}
variant={variant}>Button</Button></td>)}
</tr>)}
</table>

View File

@@ -25,6 +25,7 @@
import React, { AnchorHTMLAttributes, ButtonHTMLAttributes } from "react";
import { Link as ReactRouterLink, LinkProps as ReactRouterLinkProps } from "react-router-dom";
import classNames from "classnames";
import { createAttributesForTesting } from "@scm-manager/ui-components";
export const ButtonVariants = {
PRIMARY: "primary",
@@ -37,16 +38,19 @@ export const ButtonVariantList = Object.values(ButtonVariants);
type ButtonVariant = typeof ButtonVariants[keyof typeof ButtonVariants];
const createButtonClasses = (variant?: ButtonVariant) =>
const createButtonClasses = (variant?: ButtonVariant, isLoading?: boolean) =>
classNames("button", {
"is-primary": variant === "primary",
"is-primary is-outlined": variant === "secondary",
"is-primary is-inverted": variant === "tertiary",
"is-warning": variant === "signal",
"is-loading": isLoading,
});
type BaseButtonProps = {
variant: ButtonVariant;
variant?: ButtonVariant;
isLoading?: boolean;
testId?: string;
};
type ButtonProps = BaseButtonProps & ButtonHTMLAttributes<HTMLButtonElement>;
@@ -55,8 +59,13 @@ type ButtonProps = BaseButtonProps & ButtonHTMLAttributes<HTMLButtonElement>;
* Styled html button
*/
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, children, ...props }, ref) => (
<button {...props} className={classNames(createButtonClasses(variant), className)} ref={ref}>
({ className, variant, isLoading, testId, children, ...props }, ref) => (
<button
{...props}
className={classNames(createButtonClasses(variant, isLoading), className)}
ref={ref}
{...createAttributesForTesting(testId)}
>
{children}
</button>
)
@@ -68,8 +77,13 @@ type LinkButtonProps = BaseButtonProps & ReactRouterLinkProps;
* Styled react router link
*/
export const LinkButton = React.forwardRef<HTMLAnchorElement, LinkButtonProps>(
({ className, variant, children, ...props }, ref) => (
<ReactRouterLink {...props} className={classNames(createButtonClasses(variant), className)} ref={ref}>
({ className, variant, isLoading, testId, children, ...props }, ref) => (
<ReactRouterLink
{...props}
className={classNames(createButtonClasses(variant, isLoading), className)}
ref={ref}
{...createAttributesForTesting(testId)}
>
{children}
</ReactRouterLink>
)
@@ -81,8 +95,13 @@ type ExternalLinkButtonProps = BaseButtonProps & AnchorHTMLAttributes<HTMLAnchor
* Styled html anchor
*/
export const ExternalLinkButton = React.forwardRef<HTMLAnchorElement, ExternalLinkButtonProps>(
({ className, variant, children, ...props }, ref) => (
<a {...props} className={classNames(createButtonClasses(variant), className)} ref={ref}>
({ className, variant, isLoading, testId, children, ...props }, ref) => (
<a
{...props}
className={classNames(createButtonClasses(variant, isLoading), className)}
ref={ref}
{...createAttributesForTesting(testId)}
>
{children}
</a>
)

View File

@@ -22,4 +22,4 @@
* SOFTWARE.
*/
export { Button, LinkButton, ExternalLinkButton, ButtonVariants } from "./button";
export { Button, LinkButton, ExternalLinkButton, ButtonVariants } from "./Button";

View File

@@ -79,7 +79,7 @@
"react-diff-view": "^2.4.10",
"react-dom": "^17.0.1",
"react-hook-form": "^7.5.1",
"react-i18next": "^10.13.1",
"react-i18next": "11",
"react-router": "^5.3.1",
"react-router-dom": "^5.3.1",
"react-select": "^2.1.2",

View File

@@ -20156,10 +20156,14 @@ exports[`Storyshots Repositories/Changesets Co-Authors with avatar 1`] = `
</h4>
<p
className="is-hidden-touch"
/>
>
repos:changeset.summary
</p>
<p
className="is-hidden-desktop"
/>
>
repos:changeset.shortSummary
</p>
<div
className="is-flex"
>
@@ -20324,10 +20328,14 @@ exports[`Storyshots Repositories/Changesets Commiter and Co-Authors with avatar
</h4>
<p
className="is-hidden-touch"
/>
>
repos:changeset.summary
</p>
<p
className="is-hidden-desktop"
/>
>
repos:changeset.shortSummary
</p>
<div
className="is-flex"
>
@@ -20468,10 +20476,14 @@ exports[`Storyshots Repositories/Changesets Default 1`] = `
</h4>
<p
className="is-hidden-touch"
/>
>
repos:changeset.summary
</p>
<p
className="is-hidden-desktop"
/>
>
repos:changeset.shortSummary
</p>
<div
className="is-flex"
>
@@ -20583,10 +20595,14 @@ exports[`Storyshots Repositories/Changesets List with navigation 1`] = `
</h4>
<p
className="is-hidden-touch"
/>
>
repos:changeset.summary
</p>
<p
className="is-hidden-desktop"
/>
>
repos:changeset.shortSummary
</p>
<div
className="is-flex"
>
@@ -20712,10 +20728,14 @@ exports[`Storyshots Repositories/Changesets List with navigation 1`] = `
</h4>
<p
className="is-hidden-touch"
/>
>
repos:changeset.summary
</p>
<p
className="is-hidden-desktop"
/>
>
repos:changeset.shortSummary
</p>
<div
className="is-flex"
>
@@ -20832,10 +20852,14 @@ exports[`Storyshots Repositories/Changesets List with navigation 1`] = `
</h4>
<p
className="is-hidden-touch"
/>
>
repos:changeset.summary
</p>
<p
className="is-hidden-desktop"
/>
>
repos:changeset.shortSummary
</p>
<div
className="is-flex"
>
@@ -20957,10 +20981,14 @@ exports[`Storyshots Repositories/Changesets Replacements 1`] = `
</h4>
<p
className="is-hidden-touch"
/>
>
repos:changeset.summary
</p>
<p
className="is-hidden-desktop"
/>
>
repos:changeset.shortSummary
</p>
<div
className="is-flex"
>
@@ -21072,10 +21100,14 @@ exports[`Storyshots Repositories/Changesets With Committer 1`] = `
</h4>
<p
className="is-hidden-touch"
/>
>
repos:changeset.summary
</p>
<p
className="is-hidden-desktop"
/>
>
repos:changeset.shortSummary
</p>
<div
className="is-flex"
>
@@ -21199,10 +21231,14 @@ exports[`Storyshots Repositories/Changesets With Committer and Co-Author 1`] = `
</h4>
<p
className="is-hidden-touch"
/>
>
repos:changeset.summary
</p>
<p
className="is-hidden-desktop"
/>
>
repos:changeset.shortSummary
</p>
<div
className="is-flex"
>
@@ -21348,10 +21384,14 @@ exports[`Storyshots Repositories/Changesets With avatar 1`] = `
</h4>
<p
className="is-hidden-touch"
/>
>
repos:changeset.summary
</p>
<p
className="is-hidden-desktop"
/>
>
repos:changeset.shortSummary
</p>
<div
className="is-flex"
>
@@ -21463,10 +21503,14 @@ exports[`Storyshots Repositories/Changesets With contactless signature 1`] = `
</h4>
<p
className="is-hidden-touch"
/>
>
repos:changeset.summary
</p>
<p
className="is-hidden-desktop"
/>
>
repos:changeset.shortSummary
</p>
<div
className="is-flex"
>
@@ -21587,10 +21631,14 @@ exports[`Storyshots Repositories/Changesets With invalid signature 1`] = `
</h4>
<p
className="is-hidden-touch"
/>
>
repos:changeset.summary
</p>
<p
className="is-hidden-desktop"
/>
>
repos:changeset.shortSummary
</p>
<div
className="is-flex"
>
@@ -21711,10 +21759,14 @@ exports[`Storyshots Repositories/Changesets With multiple Co-Authors 1`] = `
</h4>
<p
className="is-hidden-touch"
/>
>
repos:changeset.summary
</p>
<p
className="is-hidden-desktop"
/>
>
repos:changeset.shortSummary
</p>
<div
className="is-flex"
>
@@ -21839,10 +21891,14 @@ exports[`Storyshots Repositories/Changesets With multiple signatures and invalid
</h4>
<p
className="is-hidden-touch"
/>
>
repos:changeset.summary
</p>
<p
className="is-hidden-desktop"
/>
>
repos:changeset.shortSummary
</p>
<div
className="is-flex"
>
@@ -21963,10 +22019,14 @@ exports[`Storyshots Repositories/Changesets With multiple signatures and not fou
</h4>
<p
className="is-hidden-touch"
/>
>
repos:changeset.summary
</p>
<p
className="is-hidden-desktop"
/>
>
repos:changeset.shortSummary
</p>
<div
className="is-flex"
>
@@ -22087,10 +22147,14 @@ exports[`Storyshots Repositories/Changesets With multiple signatures and valid s
</h4>
<p
className="is-hidden-touch"
/>
>
repos:changeset.summary
</p>
<p
className="is-hidden-desktop"
/>
>
repos:changeset.shortSummary
</p>
<div
className="is-flex"
>
@@ -22211,10 +22275,14 @@ exports[`Storyshots Repositories/Changesets With unknown signature 1`] = `
</h4>
<p
className="is-hidden-touch"
/>
>
repos:changeset.summary
</p>
<p
className="is-hidden-desktop"
/>
>
repos:changeset.shortSummary
</p>
<div
className="is-flex"
>
@@ -22335,10 +22403,14 @@ exports[`Storyshots Repositories/Changesets With unowned signature 1`] = `
</h4>
<p
className="is-hidden-touch"
/>
>
repos:changeset.summary
</p>
<p
className="is-hidden-desktop"
/>
>
repos:changeset.shortSummary
</p>
<div
className="is-flex"
>
@@ -22459,10 +22531,14 @@ exports[`Storyshots Repositories/Changesets With valid signature 1`] = `
</h4>
<p
className="is-hidden-touch"
/>
>
repos:changeset.summary
</p>
<p
className="is-hidden-desktop"
/>
>
repos:changeset.shortSummary
</p>
<div
className="is-flex"
>

View File

@@ -241,4 +241,4 @@ class LazyMarkdownView extends React.Component<Props, State> {
}
}
export default withRouter(withTranslation("repos")(LazyMarkdownView));
export default withTranslation("repos")(withRouter(LazyMarkdownView));

View File

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

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

@@ -0,0 +1,26 @@
<!--
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

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

@@ -0,0 +1,49 @@
{
"name": "@scm-manager/ui-forms",
"private": true,
"version": "2.40.2-SNAPSHOT",
"main": "build/index.js",
"types": "build/index.d.ts",
"module": "build/index.mjs",
"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": "2.40.2-SNAPSHOT",
"@storybook/addon-actions": "^6.5.10",
"@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",
"@storybook/addon-docs": "^6.5.14",
"babel-loader": "^8.2.5",
"storybook-addon-mock": "^3.2.0",
"storybook-addon-themes": "^6.1.0",
"tsup": "^6.2.3"
},
"peerDependencies": {
"@scm-manager/ui-components": "^2.40.2-SNAPSHOT",
"classnames": "^2.3.1",
"react": "17",
"react-hook-form": "7",
"react-i18next": "11",
"react-query": "3"
},
"dependencies": {
"@scm-manager/ui-buttons": "^2.40.2-SNAPSHOT"
},
"prettier": "@scm-manager/prettier-config",
"eslintConfig": {
"extends": "@scm-manager/eslint-config"
}
}

View File

@@ -0,0 +1,160 @@
import { Meta, Story } from "@storybook/addon-docs";
import Form from "./Form";
import FormRow from "./FormRow";
import ControlledInputField from "./input/ControlledInputField";
import ControlledSecretConfirmationField from "./input/ControlledSecretConfirmationField";
import ControlledCheckboxField from "./checkbox/ControlledCheckboxField";
<Meta title="Form" />
<Story name="Creation">
<Form
onSubmit={console.log}
translationPath={["sample", "form"]}
defaultValues={{
name: "",
password: "",
active: true,
}}
>
<FormRow>
<ControlledInputField name="name" />
</FormRow>
<FormRow>
<ControlledSecretConfirmationField name="password" />
</FormRow>
<FormRow>
<ControlledCheckboxField name="active" />
</FormRow>
</Form>
</Story>
<Story name="Editing">
<Form
onSubmit={console.log}
translationPath={["sample", "form"]}
defaultValues={{
name: "trillian",
password: "secret",
active: true,
}}
>
<FormRow>
<ControlledInputField name="name" />
</FormRow>
<FormRow>
<ControlledSecretConfirmationField name="password" />
</FormRow>
<FormRow>
<ControlledCheckboxField name="active" />
</FormRow>
</Form>
</Story>
<Story name="GlobalConfiguration">
<Form
onSubmit={console.log}
translationPath={["sample", "form"]}
defaultValues={{
url: "",
filter: "",
username: "",
password: "",
roleLevel: "",
updateIssues: false,
disableRepoConfig: false,
}}
>
{({ watch }) => (
<>
<FormRow>
<ControlledInputField name="url" label="URL" helpText="URL of Jira installation (with context path)." />
</FormRow>
<FormRow>
<ControlledInputField name="filter" label="Project Filter" helpText="Filters for jira project key." />
</FormRow>
<FormRow>
<ControlledCheckboxField
name="updateIssues"
label="Update Jira Issues"
helpText="Enable the automatic update function."
/>
</FormRow>
<FormRow hide={watch("filter")}>
<ControlledInputField
name="username"
label="Username"
helpText="Jira username for connection."
className="is-half"
/>
<ControlledInputField
name="password"
label="Password"
helpText="Jira password for connection."
type="password"
className="is-half"
/>
</FormRow>
<FormRow hide={watch("filter")}>
<ControlledInputField
name="roleLevel"
label="Role Visibility"
helpText="Defines for which Project Role the comments are visible."
/>
</FormRow>
<FormRow>
<ControlledCheckboxField
name="disableRepoConfig"
label="Do not allow repository configuration"
helpText="Do not allow repository owners to configure jira instances."
/>
</FormRow>
</>
)}
</Form>
</Story>
<Story name="RepoConfiguration">
<Form
onSubmit={console.log}
translationPath={["sample", "form"]}
defaultValues={{
url: "",
option: "",
anotherOption: "",
disableA: false,
disableB: false,
disableC: true,
}}
>
<ControlledInputField name="url" />
<ControlledInputField name="option" />
<ControlledInputField name="anotherOption" />
<ControlledCheckboxField name="disableA" />
<ControlledCheckboxField name="disableB" />
<ControlledCheckboxField name="disableC" />
</Form>
</Story>
<Story name="ReadOnly">
<Form
onSubmit={console.log}
translationPath={["sample", "form"]}
defaultValues={{
name: "trillian",
password: "secret",
active: true,
}}
readOnly
>
<FormRow>
<ControlledInputField name="name" />
</FormRow>
<FormRow>
<ControlledSecretConfirmationField name="password" />
</FormRow>
<FormRow>
<ControlledCheckboxField name="active" />
</FormRow>
</Form>
</Story>

View File

@@ -0,0 +1,161 @@
/*
* 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 FormRow from "./FormRow";
import ControlledInputField from "./input/ControlledInputField";
import ControlledCheckboxField from "./checkbox/ControlledCheckboxField";
import ControlledSecretConfirmationField from "./input/ControlledSecretConfirmationField";
import { HalRepresentation } from "@scm-manager/ui-types";
type RenderProps<T extends Record<string, unknown>> = Omit<
UseFormReturn<T>,
"register" | "unregister" | "handleSubmit" | "control"
>;
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: Omit<DefaultValues, keyof HalRepresentation>;
readOnly?: boolean;
submitButtonTestId?: string;
};
function Form<FormType extends Record<string, unknown>, DefaultValues extends FormType>({
children,
onSubmit,
defaultValues,
translationPath,
readOnly,
submitButtonTestId,
}: Props<FormType, DefaultValues>) {
const form = useForm<FormType>({
mode: "onChange",
defaultValues: defaultValues as DeepPartial<FormType>,
});
const { formState, handleSubmit, reset } = form;
const [ns, prefix] = translationPath;
const { t } = useTranslation(ns, { keyPrefix: prefix });
const { isDirty, isValid, isSubmitting, isSubmitSuccessful } = formState;
const [error, setError] = useState<Error | null | undefined>();
const [showSuccessNotification, setShowSuccessNotification] = useState(false);
// See https://react-hook-form.com/api/useform/reset/
useEffect(() => {
if (isSubmitSuccessful) {
setShowSuccessNotification(true);
reset(defaultValues as never);
}
/* eslint-disable-next-line react-hooks/exhaustive-deps */
}, [isSubmitSuccessful]);
useEffect(() => {
if (isDirty) {
setShowSuccessNotification(false);
}
}, [isDirty]);
const translateWithFallback = useCallback<typeof t>(
(key, ...args) => {
const translation = t(key, ...(args as any));
if (translation === `${prefix}.${key}`) {
return "";
}
return translation;
},
[prefix, t]
);
const submit = useCallback(
async (data) => {
setError(null);
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}>
<form onSubmit={handleSubmit(submit)}>
{showSuccessNotification ? (
<SuccessNotification
label={translateWithFallback("submit-success-notification")}
hide={() => setShowSuccessNotification(false)}
/>
) : null}
{typeof children === "function" ? children(form) : children}
{error ? <ErrorNotification error={error} /> : null}
{!readOnly ? (
<Level
right={
<Button
type="submit"
variant="primary"
testId={submitButtonTestId ?? "submit-button"}
disabled={!isDirty || !isValid}
isLoading={isSubmitting}
>
{t("submit")}
</Button>
}
/>
) : null}
</form>
</ScmFormContextProvider>
);
}
export default Object.assign(Form, {
Row: FormRow,
Input: ControlledInputField,
Checkbox: ControlledCheckboxField,
SecretConfirmation: ControlledSecretConfirmationField,
});

View File

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

@@ -0,0 +1,42 @@
/*
* 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;
};
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

@@ -0,0 +1,34 @@
/*
* 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 Control: FC<HTMLProps<HTMLDivElement>> = ({ className, children, ...rest }) => (
<div className={classNames("control", className)} {...rest}>
{children}
</div>
);
export default Control;

View File

@@ -0,0 +1,33 @@
/*
* 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>> = ({ className, children, ...rest }) => (
<div className={classNames("field", className)} {...rest}>
{children}
</div>
);
export default Field;

View File

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

@@ -0,0 +1,36 @@
/*
* 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 classNames from "classnames";
type Props = { text?: string; className?: string };
/**
* TODO: Implement tooltip
*/
const Help = ({ text, className }: Props) => (
<span className={classNames("fas fa-fw fa-question-circle has-text-blue-light", className)} title={text} />
);
export default Help;

View File

@@ -0,0 +1,33 @@
/*
* 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>> = ({ className, children, ...rest }) => (
<label className={classNames("label", className)} {...rest}>
{children}
</label>
);
export default Label;

View File

@@ -0,0 +1,26 @@
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

@@ -0,0 +1,85 @@
/*
* 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";
type InputFieldProps = {
label: string;
helpText?: string;
testId?: string;
} & Omit<InputHTMLAttributes<HTMLInputElement>, "type">;
/**
* @see https://bulma.io/documentation/form/checkbox/
*/
const Checkbox = React.forwardRef<HTMLInputElement, InputFieldProps>(
({ readOnly, label, value, name, checked, defaultChecked, defaultValue, testId, helpText, ...props }, ref) => (
// @ts-ignore bulma uses the disabled attribute on labels, although it is not part of the html spec
<label className="checkbox" disabled={readOnly || props.disabled}>
{readOnly ? (
<>
<input
type="hidden"
name={name}
value={value}
defaultValue={defaultValue}
checked={checked}
defaultChecked={defaultChecked}
readOnly
/>
<input
type="checkbox"
className="mr-1"
ref={ref}
value={value}
defaultValue={defaultValue}
checked={checked}
defaultChecked={defaultChecked}
{...props}
{...createAttributesForTesting(testId)}
disabled
/>
</>
) : (
<input
type="checkbox"
className="mr-1"
ref={ref}
name={name}
value={value}
defaultValue={defaultValue}
checked={checked}
defaultChecked={defaultChecked}
{...props}
{...createAttributesForTesting(testId)}
/>
)}
{label}
{helpText ? <Help className="ml-1" text={helpText} /> : null}
</label>
)
);
export default Checkbox;

View File

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

@@ -0,0 +1,36 @@
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

@@ -0,0 +1,76 @@
/*
* 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 classNames from "classnames";
import { useScmFormContext } from "../ScmFormContext";
import CheckboxField from "./CheckboxField";
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,
className,
testId,
defaultChecked,
readOnly,
...props
}: Props<T>) {
const { control, t, readOnly: formReadonly } = useScmFormContext();
const labelTranslation = label || t(`${name}.label`) || "";
const helpTextTranslation = helpText || t(`${name}.helpText`);
return (
<Controller
control={control}
name={name}
rules={rules}
defaultValue={defaultChecked as never}
render={({ field }) => (
<CheckboxField
className={classNames("column", className)}
readOnly={readOnly ?? formReadonly}
defaultChecked={field.value}
{...props}
{...field}
label={labelTranslation}
helpText={helpTextTranslation}
testId={testId ?? `checkbox-${name}`}
/>
)}
/>
);
}
export default ControlledInputField;

View File

@@ -0,0 +1,26 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
export { default as Form } from "./Form";
export * from "./resourceHooks";

View File

@@ -0,0 +1,36 @@
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

@@ -0,0 +1,77 @@
/*
* 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 classNames from "classnames";
import { useScmFormContext } from "../ScmFormContext";
import InputField from "./InputField";
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,
className,
testId,
defaultValue,
readOnly,
...props
}: Props<T>) {
const { control, t, readOnly: formReadonly } = useScmFormContext();
const labelTranslation = label || t(`${name}.label`) || "";
const helpTextTranslation = helpText || t(`${name}.helpText`);
return (
<Controller
control={control}
name={name}
rules={rules}
defaultValue={defaultValue as never}
render={({ field, fieldState }) => (
<InputField
className={classNames("column", className)}
readOnly={readOnly ?? formReadonly}
required={rules?.required as boolean}
{...props}
{...field}
label={labelTranslation}
helpText={helpTextTranslation}
error={fieldState.error ? fieldState.error.message || t(`${name}.error.${fieldState.error.type}`) : undefined}
testId={testId ?? `input-${name}`}
/>
)}
/>
);
}
export default ControlledInputField;

View File

@@ -0,0 +1,39 @@
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

@@ -0,0 +1,121 @@
/*
* 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 classNames from "classnames";
import { useScmFormContext } from "../ScmFormContext";
import InputField from "./InputField";
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 } = useScmFormContext();
const labelTranslation = label || t(`${name}.label`) || "";
const helpTextTranslation = helpText || t(`${name}.helpText`);
const confirmationLabelTranslation = confirmationLabel || t(`${name}.confirmation.label`) || "";
const confirmationHelpTextTranslation = confirmationHelpText || t(`${name}.confirmation.helpText`);
const confirmationErrorMessageTranslation = confirmationErrorMessage || t(`${name}.confirmation.errorMessage`);
const secretValue = watch(name);
return (
<>
<Controller
control={control}
name={name}
defaultValue={defaultValue as never}
rules={{
...rules,
deps: [`${name}Confirmation`],
}}
render={({ field, fieldState }) => (
<InputField
className={classNames("column", className)}
readOnly={readOnly ?? formReadonly}
{...props}
{...field}
required={rules?.required as boolean}
type="password"
label={labelTranslation}
helpText={helpTextTranslation}
error={
fieldState.error ? fieldState.error.message || t(`${name}.error.${fieldState.error.type}`) : undefined
}
testId={testId ?? `input-${name}`}
/>
)}
/>
<Controller
control={control}
name={`${name}Confirmation`}
defaultValue={defaultValue as never}
render={({ field, fieldState }) => (
<InputField
className={classNames("column", className)}
type="password"
readOnly={readOnly ?? formReadonly}
disabled={props.disabled}
{...field}
label={confirmationLabelTranslation}
helpText={confirmationHelpTextTranslation}
error={
fieldState.error
? fieldState.error.message || t(`${name}.confirmation.error.${fieldState.error.type}`)
: undefined
}
testId={confirmationTestId ?? `input-${name}-confirmation`}
/>
)}
rules={{
validate: (value) => secretValue === value || confirmationErrorMessageTranslation,
}}
/>
</>
);
}

View File

@@ -0,0 +1,22 @@
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

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

@@ -0,0 +1,22 @@
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

@@ -0,0 +1,60 @@
/*
* 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";
type InputFieldProps = {
label: string;
helpText?: string;
error?: string;
type?: "text" | "password" | "email" | "tel";
} & Omit<React.ComponentProps<typeof Input>, "type">;
/**
* @see https://bulma.io/documentation/form/input/
*/
const InputField = React.forwardRef<HTMLInputElement, InputFieldProps>(
({ label, helpText, error, className, ...props }, ref) => {
const variant = error ? "danger" : undefined;
return (
<Field className={className}>
<Label>
{label}
{helpText ? <Help className="ml-1" text={helpText} /> : null}
</Label>
<Control>
<Input variant={variant} ref={ref} {...props}></Input>
</Control>
{error ? <FieldMessage variant={variant}>{error}</FieldMessage> : null}
</Field>
);
}
);
export default InputField;

View File

@@ -0,0 +1,152 @@
/*
* 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;
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;
};
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;
};
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

@@ -0,0 +1,27 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
export const variants = ["danger"] as const;
export type Variant = typeof variants[number];
export const createVariantClass = (variant?: Variant) => (variant ? `is-${variant}` : undefined);

View File

@@ -0,0 +1,3 @@
{
"extends": "@scm-manager/tsconfig"
}

View File

@@ -12,7 +12,7 @@
"query-string": "6.14.1",
"react": "^17.0.1",
"react-hook-form": "^7.5.1",
"react-i18next": "^10.13.1",
"react-i18next": "11",
"react-redux": "^5.0.7",
"react-router": "^5.3.1",
"react-router-dom": "^5.3.1",

View File

@@ -18,7 +18,7 @@
},
"peerDependencies": {
"react": "17",
"react-i18next": "10"
"react-i18next": "11"
},
"dependencies": {
"mousetrap": "1.6.5"

View File

@@ -39,7 +39,7 @@ export type UseShortcutOptions = {
*
* If no description is supplied, there will be no entry in the shortcut summary table.
*/
description?: string;
description?: string | null;
};
/**

View File

@@ -12,16 +12,17 @@
"@scm-manager/ui-text": "2.40.2-SNAPSHOT",
"@scm-manager/ui-shortcuts": "2.40.2-SNAPSHOT",
"@scm-manager/ui-legacy": "2.40.2-SNAPSHOT",
"@scm-manager/ui-forms": "2.40.2-SNAPSHOT",
"classnames": "^2.2.5",
"history": "^4.10.1",
"i18next": "^19.6.0",
"i18next-browser-languagedetector": "^4.0.0",
"i18next-fetch-backend": "^2.2.0",
"i18next": "21",
"i18next-browser-languagedetector": "6",
"i18next-fetch-backend": "4",
"query-string": "6.14.1",
"react": "^17.0.1",
"react-dom": "^17.0.1",
"react-hook-form": "^7.5.1",
"react-i18next": "^10.13.1",
"react-i18next": "11",
"react-router": "^5.3.1",
"react-router-dom": "^5.3.1",
"react-select": "^2.1.2",

View File

@@ -49,7 +49,52 @@
},
"createUser": {
"title": "Benutzer erstellen",
"subtitle": "Erstellen eines neuen Benutzers"
"subtitle": "Erstellen eines neuen Benutzers",
"form": {
"submit": "Speichern",
"submit-success-notification": "Der Benutzer wurde erfolgreich aktualisiert",
"name": {
"label": "Benutzername",
"helpText": "Einzigartiger Name des Benutzers.",
"error": {
"validate": "Dieser Name ist ungültig"
}
},
"displayName": {
"label": "Anzeigename",
"helpText": "Anzeigename des Benutzers.",
"error": {
"validate": "Dieser Anzeigename ist ungültig"
}
},
"mail": {
"label": "E-Mail",
"helpText": "E-Mail Adresse des Benutzers.",
"error": {
"validate": "Diese E-Mail ist ungültig"
}
},
"external": {
"label": "Extern",
"helpText": "Der Benutzer wird über ein Fremdsystem verwaltet."
},
"password": {
"label": "Neues Passwort",
"error": {
"validate": "Das Passwort muss zwischen 6 und 1024 Zeichen lang sein"
},
"confirmation": {
"label": "Passwort wiederholen",
"error": {
"validate": "Passwörter müssen identisch sein"
}
}
},
"active": {
"label": "Aktiv",
"helpText": "Aktivierung oder Deaktivierung eines Benutzers."
}
}
},
"deleteUser": {
"button": "Benutzer löschen",

View File

@@ -49,7 +49,52 @@
},
"createUser": {
"title": "Create User",
"subtitle": "Create a new user"
"subtitle": "Create a new user",
"form": {
"submit": "Submit",
"submit-success-notification": "The user was updated successfully",
"name": {
"label": "User Name",
"helpText": "Unique name of the user.",
"error": {
"validate": "This name is invalid"
}
},
"displayName": {
"label": "Display Name",
"helpText": "Display name of the user.",
"error": {
"validate": "This display name is invalid"
}
},
"mail": {
"label": "Email",
"helpText": "Email address of the user.",
"error": {
"validate": "This email address is invalid"
}
},
"external": {
"label": "External",
"helpText": "This user is managed by an external system."
},
"password": {
"label": "New Password",
"error": {
"validate": "Password has to be between 6 and 1024 characters"
},
"confirmation": {
"label": "Confirm New Password",
"error": {
"validate": "Passwords have to be identical"
}
}
},
"active": {
"label": "Active",
"helpText": "Activate or deactivate the user."
}
}
},
"deleteUser": {
"button": "Delete User",

View File

@@ -42,7 +42,50 @@
},
"createUser": {
"title": "Crear usuario",
"subtitle": "Crear un nuevo usuario"
"subtitle": "Crear un nuevo usuario",
"form": {
"name": {
"label": "Nombre de usuario",
"helpText": "Nombre único del usuario.",
"error": {
"validate": "El nombre es incorrecto"
}
},
"displayName": {
"label": "Nombre a mostrar",
"helpText": "Nombre de usuario a mostrar.",
"error": {
"validate": "El nombre a mostrar es incorrecto"
}
},
"mail": {
"label": "Correo electrónico",
"helpText": "Dirección de correo electrónico del usuario.",
"error": {
"validate": "El correo electrónico es incorrecto"
}
},
"external": {
"label": "Externo",
"helpText": "This user is managed by an external system."
},
"password": {
"label": "Nueva contraseña",
"error": {
"validate": "La contraseña debe tener entre 6 y 1024 caracteres"
},
"confirmation": {
"label": "Confirme la contraseña",
"error": {
"validate": "Las contraseñas deben ser identicas"
}
}
},
"active": {
"label": "Activo",
"helpText": "Activar o desactivar el usuario."
}
}
},
"deleteUser": {
"button": "Borrar usuario",

View File

@@ -23,7 +23,6 @@
*/
import i18n from "i18next";
// @ts-ignore
import Backend from "i18next-fetch-backend";
import LanguageDetector from "i18next-browser-languagedetector";
import { initReactI18next } from "react-i18next";
@@ -48,19 +47,18 @@ i18n
debug: false,
interpolation: {
escapeValue: false // not needed for react!!
escapeValue: false, // not needed for react!!
},
react: {
wait: true,
useSuspense: false
useSuspense: false,
},
backend: {
loadPath: loadPath,
init: {
credentials: "same-origin"
}
credentials: "same-origin",
},
},
// configure LanguageDetector
@@ -69,8 +67,8 @@ i18n
// we only use browser configuration
order: ["navigator"],
// we do not cache the detected language
caches: []
}
caches: [],
},
});
export default i18n;

View File

@@ -1,210 +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 * as React from "react";
import { fireEvent, render, screen } from "@testing-library/react";
import UserForm from "./UserForm";
import { User } from "@scm-manager/ui-types";
import "@scm-manager/ui-tests";
describe("for user creation", () => {
const fillForm = (userId: string, displayName: string, password: string, confirmation: string) => {
fireEvent.change(screen.getByTestId("input-username"), {
target: { value: userId },
});
fireEvent.change(screen.getByTestId("input-displayname"), {
target: { value: displayName },
});
fireEvent.change(screen.getByTestId("input-password"), {
target: { value: password },
});
fireEvent.change(screen.getByTestId("input-password-confirmation"), {
target: { value: confirmation },
});
};
it("should allow to create user", () => {
const mockSubmitForm = jest.fn();
render(<UserForm submitForm={mockSubmitForm} />);
fillForm("trillian", "Tricia McMillan", "password", "password");
fireEvent.click(screen.getByTestId("submit-button"));
expect(mockSubmitForm).toBeCalled();
});
it("should prevent to submit empty form", () => {
const mockSubmitForm = jest.fn();
render(<UserForm submitForm={mockSubmitForm} />);
fireEvent.click(screen.getByTestId("submit-button"));
expect(mockSubmitForm).not.toBeCalled();
});
it("should prevent to submit form without user id", () => {
const mockSubmitForm = jest.fn();
render(<UserForm submitForm={mockSubmitForm} />);
fillForm("", "Arthur Dent", "password", "password");
fireEvent.click(screen.getByTestId("submit-button"));
expect(mockSubmitForm).not.toBeCalled();
});
it("should prevent to submit form without display name", () => {
const mockSubmitForm = jest.fn();
render(<UserForm submitForm={mockSubmitForm} />);
fillForm("trillian", "", "password", "password");
fireEvent.click(screen.getByTestId("submit-button"));
expect(mockSubmitForm).not.toBeCalled();
});
it("should prevent to submit form without password", () => {
const mockSubmitForm = jest.fn();
render(<UserForm submitForm={mockSubmitForm} />);
fillForm("trillian", "Tricia McMillan", "", "");
fireEvent.click(screen.getByTestId("submit-button"));
expect(mockSubmitForm).not.toBeCalled();
});
it("should prevent to submit form with wrong password confirmation", () => {
const mockSubmitForm = jest.fn();
render(<UserForm submitForm={mockSubmitForm} />);
fillForm("trillian", "Tricia McMillan", "password", "different");
fireEvent.click(screen.getByTestId("submit-button"));
expect(mockSubmitForm).not.toBeCalled();
});
});
describe("for user edit", () => {
const user: User = {
name: "trillian",
mail: "tricia@hog.space",
displayName: "Tricia McMillan",
password: undefined,
active: true,
external: false,
_links: {},
};
it("should allow to edit user with changed display name", () => {
const mockSubmitForm = jest.fn();
render(<UserForm user={user} submitForm={mockSubmitForm} />);
fireEvent.change(screen.getByTestId("input-displayname"), {
target: { value: "Just Tricia" },
});
fireEvent.click(screen.getByTestId("submit-button"));
expect(mockSubmitForm).toBeCalled();
});
it("should allow to edit user with changed email", () => {
const mockSubmitForm = jest.fn();
render(<UserForm user={user} submitForm={mockSubmitForm} />);
fireEvent.change(screen.getByTestId("input-mail"), {
target: { value: "tricia@hg2g.com" },
});
fireEvent.click(screen.getByTestId("submit-button"));
expect(mockSubmitForm).toBeCalled();
});
it("should allow to edit user with changed active flag", () => {
const mockSubmitForm = jest.fn();
render(<UserForm user={user} submitForm={mockSubmitForm} />);
fireEvent.click(screen.getByTestId("checkbox-active"));
fireEvent.click(screen.getByTestId("submit-button"));
expect(mockSubmitForm).toBeCalled();
});
it("should prevent to submit unchanged user", () => {
const mockSubmitForm = jest.fn();
render(<UserForm user={user} submitForm={mockSubmitForm} />);
fireEvent.click(screen.getByTestId("submit-button"));
expect(mockSubmitForm).not.toBeCalled();
});
it("should prevent to edit user with incorrect email", () => {
const mockSubmitForm = jest.fn();
render(<UserForm user={user} submitForm={mockSubmitForm} />);
fireEvent.change(screen.getByTestId("input-mail"), {
target: { value: "do_not_reply" },
});
fireEvent.click(screen.getByTestId("submit-button"));
expect(mockSubmitForm).not.toBeCalled();
});
it("should prevent to edit user with empty display name", () => {
const mockSubmitForm = jest.fn();
render(<UserForm user={user} submitForm={mockSubmitForm} />);
fireEvent.change(screen.getByTestId("input-displayname"), {
target: { value: "" },
});
fireEvent.click(screen.getByTestId("submit-button"));
expect(mockSubmitForm).not.toBeCalled();
});
});

View File

@@ -1,210 +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, FormEvent, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { User } from "@scm-manager/ui-types";
import {
Checkbox,
InputField,
Level,
PasswordConfirmation,
SubmitButton,
Subtitle,
validation as validator,
} from "@scm-manager/ui-components";
import * as userValidator from "./userValidation";
type Props = {
submitForm: (p: User) => void;
user?: User;
loading?: boolean;
};
const UserForm: FC<Props> = ({ submitForm, user, loading }) => {
const [t] = useTranslation("users");
const [userState, setUserState] = useState<User>({
name: "",
displayName: "",
mail: "",
password: "",
active: true,
external: false,
_links: {},
});
const [mailValidationError, setMailValidationError] = useState(false);
const [displayNameValidationError, setDisplayNameValidationError] = useState(false);
const [nameValidationError, setNameValidationError] = useState(false);
const [passwordValid, setPasswordValid] = useState(false);
useEffect(() => {
if (user) {
setUserState(user);
}
}, [user]);
const createUserComponentsAreInvalid = () => {
if (!user) {
return nameValidationError || !userState.name || (!userState.external && !passwordValid);
} else {
return false;
}
};
const editUserComponentsAreUnchanged = () => {
if (user) {
return (
user.displayName === userState.displayName &&
user.mail === userState.mail &&
user.active === userState.active &&
user.external === userState.external
);
} else {
return false;
}
};
const isInvalid = () => {
return (
createUserComponentsAreInvalid() ||
editUserComponentsAreUnchanged() ||
mailValidationError ||
displayNameValidationError ||
nameValidationError ||
(!user && userState && !userState.external && !userState.password) ||
!userState.displayName
);
};
const submit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!isInvalid()) {
submitForm(userState);
}
};
const passwordChangeField = (
<PasswordConfirmation
passwordChanged={(password, isPasswordValid) => {
setPasswordValid(isPasswordValid);
setUserState({ ...userState, password });
}}
/>
);
let nameField = null;
let subtitle = null;
if (!user) {
// create new user
nameField = (
<div className="column is-half">
<InputField
label={t("user.name")}
onChange={(name) => {
setNameValidationError(!!name && !validator.isNameValid(name));
setUserState({ ...userState, name });
}}
value={userState ? userState.name : ""}
validationError={nameValidationError}
errorMessage={t("validation.name-invalid")}
helpText={t("help.usernameHelpText")}
testId="input-username"
/>
</div>
);
} else {
// edit existing user
subtitle = <Subtitle subtitle={t("userForm.subtitle")} />;
}
return (
<>
{subtitle}
<form onSubmit={submit}>
<div className="columns is-multiline">
{nameField}
<div className="column is-half">
<InputField
label={t("user.displayName")}
onChange={(displayName) => {
setDisplayNameValidationError(!userValidator.isDisplayNameValid(displayName));
setUserState({ ...userState, displayName });
}}
value={userState ? userState.displayName : ""}
validationError={displayNameValidationError}
errorMessage={t("validation.displayname-invalid")}
helpText={t("help.displayNameHelpText")}
testId="input-displayname"
/>
</div>
<div className="column is-half">
<InputField
label={t("user.mail")}
onChange={(mail) => {
setMailValidationError(!!mail && !validator.isMailValid(mail));
setUserState({ ...userState, mail });
}}
value={userState ? userState.mail : ""}
validationError={mailValidationError}
errorMessage={t("validation.mail-invalid")}
helpText={t("help.mailHelpText")}
testId="input-mail"
/>
</div>
</div>
{!user && (
<>
<div className="columns">
<div className="column">
<Checkbox
label={t("user.externalFlag")}
onChange={(external) => setUserState({ ...userState, external })}
checked={userState.external}
helpText={t("help.externalFlagHelpText")}
/>
</div>
</div>
</>
)}
{!userState.external && (
<>
{!user && passwordChangeField}
<div className="columns">
<div className="column">
<Checkbox
label={t("user.active")}
onChange={(active) => setUserState({ ...userState, active })}
checked={userState ? userState.active : false}
helpText={t("help.activeHelpText")}
testId="checkbox-active"
/>
</div>
</div>
</>
)}
<Level right={<SubmitButton disabled={isInvalid()} loading={loading} label={t("userForm.button.submit")} />} />
</form>
</>
);
};
export default UserForm;

View File

@@ -22,28 +22,97 @@
* SOFTWARE.
*/
import React, { FC } from "react";
import { useTranslation } from "react-i18next";
import { Page } from "@scm-manager/ui-components";
import UserForm from "../components/UserForm";
import { useCreateUser } from "@scm-manager/ui-api";
import { Redirect } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useRequiredIndexLink } from "@scm-manager/ui-api";
import { Page } from "@scm-manager/ui-components";
import { Form, useCreateResource } from "@scm-manager/ui-forms";
import * as userValidator from "../components/userValidation";
import { User, UserCreation } from "@scm-manager/ui-types";
type UserCreationForm = Pick<UserCreation, "password" | "name" | "displayName" | "active" | "external" | "mail"> & {
passwordConfirmation: string;
};
const CreateUser: FC = () => {
const [t] = useTranslation("users");
const { error, isLoading, user, create } = useCreateUser();
const indexLink = useRequiredIndexLink("users");
const { submit, submissionResult: createdUser } = useCreateResource<UserCreationForm, User>(
indexLink,
["user", "users"],
(user) => user.name,
{
contentType: "application/vnd.scmm-user+json;v=2",
}
);
if (!!user) {
return <Redirect to={`/user/${user.name}`} />;
if (!!createdUser) {
return <Redirect to={`/user/${createdUser.name}`} />;
}
return (
<Page
title={t("createUser.title")}
subtitle={t("createUser.subtitle")}
error={error || undefined}
showContentOnError={true}
<Page title={t("createUser.title")} subtitle={t("createUser.subtitle")} showContentOnError={true}>
<Form
onSubmit={submit}
translationPath={["users", "createUser.form"]}
defaultValues={{
name: "",
password: "",
passwordConfirmation: "",
active: true,
external: false,
displayName: "",
mail: "",
}}
>
<UserForm submitForm={create} loading={isLoading} />
{({ watch }) => (
<>
<Form.Row>
<Form.Input
name="name"
rules={{
validate: userValidator.isNameValid,
}}
testId="input-username"
/>
<Form.Input
name="displayName"
rules={{
validate: userValidator.isDisplayNameValid,
}}
/>
</Form.Row>
<Form.Row>
<Form.Input
name="mail"
className="is-half"
rules={{
validate: (email) => !email || userValidator.isMailValid(email),
}}
/>
</Form.Row>
<Form.Row>
<Form.Checkbox
name="external"
rules={{
deps: ["password"],
}}
/>
</Form.Row>
<Form.Row hidden={watch("external")}>
<Form.SecretConfirmation
name="password"
rules={{
validate: userValidator.isPasswordValid,
}}
/>
</Form.Row>
<Form.Row hidden={watch("external")}>
<Form.Checkbox name="active" />
</Form.Row>
</>
)}
</Form>
</Page>
);
};

View File

@@ -22,30 +22,52 @@
* SOFTWARE.
*/
import React, { FC } from "react";
import UserForm from "../components/UserForm";
import DeleteUser from "./DeleteUser";
import { User } from "@scm-manager/ui-types";
import { ErrorNotification } from "@scm-manager/ui-components";
import UserConverter from "../components/UserConverter";
import { useUpdateUser } from "@scm-manager/ui-api";
import UpdateNotification from "../../components/UpdateNotification";
import { Form, useUpdateResource } from "@scm-manager/ui-forms";
import * as userValidator from "../components/userValidation";
import { Subtitle } from "@scm-manager/ui-components";
import { useTranslation } from "react-i18next";
type Props = {
user: User;
};
const EditUser: FC<Props> = ({ user }) => {
const { error, isLoading, update, isUpdated } = useUpdateUser();
const [t] = useTranslation("users");
const { submit } = useUpdateResource<User>(user, (user) => user.name, {
contentType: "application/vnd.scmm-user+json;v=2",
collectionName: ["user", "users"],
});
return (
<div>
<UpdateNotification isUpdated={isUpdated} />
<ErrorNotification error={error || undefined} />
<UserForm submitForm={update} user={user} loading={isLoading} />
<>
<Subtitle subtitle={t("userForm.subtitle")} />
<Form translationPath={["users", "createUser.form"]} defaultValues={user} onSubmit={submit}>
<Form.Row>
<Form.Input
name="displayName"
rules={{
validate: userValidator.isDisplayNameValid,
}}
/>
<Form.Input
name="mail"
className="is-half"
rules={{
validate: (email) => !email || userValidator.isMailValid(email),
}}
/>
</Form.Row>
<Form.Row hidden={user.external}>
<Form.Checkbox name="active" />
</Form.Row>
</Form>
<hr />
<UserConverter user={user} />
<DeleteUser user={user} />
</div>
</>
);
};

1558
yarn.lock

File diff suppressed because it is too large Load Diff