mirror of
https://github.com/scm-manager/scm-manager.git
synced 2025-11-15 09:46:16 +01:00
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:
committed by
SCM-Manager
parent
f2f2f29791
commit
72dfe80843
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
)
|
||||
@@ -22,4 +22,4 @@
|
||||
* SOFTWARE.
|
||||
*/
|
||||
|
||||
export { Button, LinkButton, ExternalLinkButton, ButtonVariants } from "./button";
|
||||
export { Button, LinkButton, ExternalLinkButton, ButtonVariants } from "./Button";
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -241,4 +241,4 @@ class LazyMarkdownView extends React.Component<Props, State> {
|
||||
}
|
||||
}
|
||||
|
||||
export default withRouter(withTranslation("repos")(LazyMarkdownView));
|
||||
export default withTranslation("repos")(withRouter(LazyMarkdownView));
|
||||
|
||||
57
scm-ui/ui-forms/.storybook/RemoveThemesPlugin.js
Normal file
57
scm-ui/ui-forms/.storybook/RemoveThemesPlugin.js
Normal 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
|
||||
92
scm-ui/ui-forms/.storybook/main.js
Normal file
92
scm-ui/ui-forms/.storybook/main.js
Normal 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;
|
||||
},
|
||||
};
|
||||
26
scm-ui/ui-forms/.storybook/preview-head.html
Normal file
26
scm-ui/ui-forms/.storybook/preview-head.html
Normal 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">
|
||||
|
||||
72
scm-ui/ui-forms/.storybook/preview.js
Normal file
72
scm-ui/ui-forms/.storybook/preview.js
Normal 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" },
|
||||
],
|
||||
},
|
||||
};
|
||||
49
scm-ui/ui-forms/package.json
Normal file
49
scm-ui/ui-forms/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
160
scm-ui/ui-forms/src/Form.stories.mdx
Normal file
160
scm-ui/ui-forms/src/Form.stories.mdx
Normal 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>
|
||||
161
scm-ui/ui-forms/src/Form.tsx
Normal file
161
scm-ui/ui-forms/src/Form.tsx
Normal 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,
|
||||
});
|
||||
37
scm-ui/ui-forms/src/FormRow.tsx
Normal file
37
scm-ui/ui-forms/src/FormRow.tsx
Normal 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;
|
||||
42
scm-ui/ui-forms/src/ScmFormContext.tsx
Normal file
42
scm-ui/ui-forms/src/ScmFormContext.tsx
Normal 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);
|
||||
}
|
||||
34
scm-ui/ui-forms/src/base/Control.tsx
Normal file
34
scm-ui/ui-forms/src/base/Control.tsx
Normal 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;
|
||||
33
scm-ui/ui-forms/src/base/Field.tsx
Normal file
33
scm-ui/ui-forms/src/base/Field.tsx
Normal 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;
|
||||
34
scm-ui/ui-forms/src/base/field-message/FieldMessage.tsx
Normal file
34
scm-ui/ui-forms/src/base/field-message/FieldMessage.tsx
Normal 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;
|
||||
36
scm-ui/ui-forms/src/base/help/Help.tsx
Normal file
36
scm-ui/ui-forms/src/base/help/Help.tsx
Normal 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;
|
||||
33
scm-ui/ui-forms/src/base/label/Label.tsx
Normal file
33
scm-ui/ui-forms/src/base/label/Label.tsx
Normal 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;
|
||||
26
scm-ui/ui-forms/src/checkbox/Checkbox.stories.mdx
Normal file
26
scm-ui/ui-forms/src/checkbox/Checkbox.stories.mdx
Normal 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>
|
||||
85
scm-ui/ui-forms/src/checkbox/Checkbox.tsx
Normal file
85
scm-ui/ui-forms/src/checkbox/Checkbox.tsx
Normal 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;
|
||||
39
scm-ui/ui-forms/src/checkbox/CheckboxField.tsx
Normal file
39
scm-ui/ui-forms/src/checkbox/CheckboxField.tsx
Normal 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;
|
||||
@@ -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>
|
||||
76
scm-ui/ui-forms/src/checkbox/ControlledCheckboxField.tsx
Normal file
76
scm-ui/ui-forms/src/checkbox/ControlledCheckboxField.tsx
Normal 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;
|
||||
26
scm-ui/ui-forms/src/index.ts
Normal file
26
scm-ui/ui-forms/src/index.ts
Normal 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";
|
||||
36
scm-ui/ui-forms/src/input/ControlledInputField.stories.mdx
Normal file
36
scm-ui/ui-forms/src/input/ControlledInputField.stories.mdx
Normal 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>
|
||||
77
scm-ui/ui-forms/src/input/ControlledInputField.tsx
Normal file
77
scm-ui/ui-forms/src/input/ControlledInputField.tsx
Normal 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;
|
||||
@@ -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>
|
||||
121
scm-ui/ui-forms/src/input/ControlledSecretConfirmationField.tsx
Normal file
121
scm-ui/ui-forms/src/input/ControlledSecretConfirmationField.tsx
Normal 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,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
22
scm-ui/ui-forms/src/input/Input.stories.mdx
Normal file
22
scm-ui/ui-forms/src/input/Input.stories.mdx
Normal 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>
|
||||
46
scm-ui/ui-forms/src/input/Input.tsx
Normal file
46
scm-ui/ui-forms/src/input/Input.tsx
Normal 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;
|
||||
22
scm-ui/ui-forms/src/input/InputField.stories.mdx
Normal file
22
scm-ui/ui-forms/src/input/InputField.stories.mdx
Normal 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>
|
||||
60
scm-ui/ui-forms/src/input/InputField.tsx
Normal file
60
scm-ui/ui-forms/src/input/InputField.tsx
Normal 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;
|
||||
152
scm-ui/ui-forms/src/resourceHooks.ts
Normal file
152
scm-ui/ui-forms/src/resourceHooks.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
27
scm-ui/ui-forms/src/variants.ts
Normal file
27
scm-ui/ui-forms/src/variants.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
|
||||
export const variants = ["danger"] as const;
|
||||
export type Variant = typeof variants[number];
|
||||
export const createVariantClass = (variant?: Variant) => (variant ? `is-${variant}` : undefined);
|
||||
3
scm-ui/ui-forms/tsconfig.json
Normal file
3
scm-ui/ui-forms/tsconfig.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "@scm-manager/tsconfig"
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "17",
|
||||
"react-i18next": "10"
|
||||
"react-i18next": "11"
|
||||
},
|
||||
"dependencies": {
|
||||
"mousetrap": "1.6.5"
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user