Introduce ui-forms framework

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


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

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

View File

@@ -0,0 +1,57 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
const HtmlWebpackPlugin = require('html-webpack-plugin');
class RemoveThemesPlugin {
apply (compiler) {
compiler.hooks.compilation.tap('RemoveThemesPlugin', (compilation) => {
HtmlWebpackPlugin.getHooks(compilation).beforeAssetTagGeneration.tapAsync(
'RemoveThemesPlugin',
(data, cb) => {
// remove generated style-loader bundles from the page
// there should be a better way, which does not generate the bundles at all
// but for now it works
if (data.assets.js) {
data.assets.js = data.assets.js.filter(bundle => !bundle.startsWith("ui-theme-"))
.filter(bundle => !bundle.startsWith("runtime~ui-theme-"))
}
// remove css links to avoid conflicts with the themes
// so we remove all and add our own via preview-head.html
if (data.assets.css) {
data.assets.css = data.assets.css.filter(css => !css.startsWith("ui-theme-"))
}
// Tell webpack to move on
cb(null, data)
}
)
})
}
}
module.exports = RemoveThemesPlugin

View File

@@ -0,0 +1,92 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
const path = require("path");
const fs = require("fs");
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const RemoveThemesPlugin = require("./RemoveThemesPlugin");
const ReactDOM = require("react-dom");
const root = path.resolve("..");
const themedir = path.join(root, "ui-styles", "src");
ReactDOM.createPortal = (node) => node;
const themes = fs
.readdirSync(themedir)
.map((filename) => path.parse(filename))
.filter((p) => p.ext === ".scss")
.reduce((entries, current) => ({ ...entries, [`ui-theme-${current.name}`]: path.join(themedir, current.base) }), {});
module.exports = {
typescript: { reactDocgen: false },
core: {
builder: "webpack5",
},
stories: ["../src/**/*.stories.mdx", "../src/**/*.stories.@(js|jsx|ts|tsx)"],
addons: [
"storybook-addon-i18next",
"storybook-addon-themes",
"@storybook/addon-links",
"@storybook/addon-essentials",
"@storybook/addon-interactions",
"@storybook/addon-a11y",
"storybook-addon-pseudo-states",
"storybook-addon-mock",
],
framework: "@storybook/react",
webpackFinal: async (config) => {
// add our themes to webpack entry points
config.entry = {
main: config.entry,
...themes,
};
// create separate css files for our themes
config.plugins.push(
new MiniCssExtractPlugin({
filename: "[name].css",
ignoreOrder: false,
})
);
config.module.rules.push({
test: /\.scss$/,
use: [MiniCssExtractPlugin.loader, "css-loader", "sass-loader"],
});
// the html-webpack-plugin adds the generated css and js files to the iframe,
// which overrides our manually loaded css files.
// So we use a custom plugin which uses a hook of html-webpack-plugin
// to filter our themes from the output.
config.plugins.push(new RemoveThemesPlugin());
// force cjs instead of esm
// https://github.com/tannerlinsley/react-query/issues/3513
config.resolve.alias["react-query/devtools"] = require.resolve("react-query/devtools");
return config;
},
};

View File

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

View File

@@ -0,0 +1,72 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React, { useEffect } from "react";
import { I18nextProvider, initReactI18next } from "react-i18next";
import i18n from "i18next";
i18n.use(initReactI18next).init({
whitelist: ["en", "de"],
lng: "en",
fallbackLng: "en",
interpolation: {
escapeValue: false,
},
react: {
useSuspense: false,
},
});
const Decorator = ({ children, themeName }) => {
useEffect(() => {
const link = document.querySelector("#ui-theme");
if (link && link["data-theme"] !== themeName) {
link.href = `ui-theme-${themeName}.css`;
link["data-theme"] = themeName;
}
}, [themeName]);
return <>{children}</>;
};
export const parameters = {
actions: { argTypesRegex: "^on[A-Z].*" },
decorators: [
(Story) => (
<I18nextProvider i18n={i18n}>
<Story />
</I18nextProvider>
),
],
themes: {
Decorator,
clearable: false,
default: "light",
list: [
{ name: "light", color: "#fff" },
{ name: "highcontrast", color: "#050514" },
{ name: "dark", color: "#121212" },
],
},
};

View File

@@ -0,0 +1,49 @@
{
"name": "@scm-manager/ui-forms",
"private": true,
"version": "2.40.2-SNAPSHOT",
"main": "build/index.js",
"types": "build/index.d.ts",
"module": "build/index.mjs",
"license": "MIT",
"scripts": {
"build": "tsup ./src/index.ts -d build --format esm,cjs --dts",
"storybook": "start-storybook -p 6006",
"build-storybook": "build-storybook"
},
"devDependencies": {
"@babel/core": "^7.19.0",
"@scm-manager/eslint-config": "^2.16.0",
"@scm-manager/prettier-config": "^2.10.1",
"@scm-manager/tsconfig": "^2.13.0",
"@scm-manager/ui-styles": "2.40.2-SNAPSHOT",
"@storybook/addon-actions": "^6.5.10",
"@storybook/addon-essentials": "^6.5.10",
"@storybook/addon-interactions": "^6.5.10",
"@storybook/addon-links": "^6.5.10",
"@storybook/builder-webpack5": "^6.5.10",
"@storybook/manager-webpack5": "^6.5.10",
"@storybook/react": "^6.5.10",
"@storybook/testing-library": "^0.0.13",
"@storybook/addon-docs": "^6.5.14",
"babel-loader": "^8.2.5",
"storybook-addon-mock": "^3.2.0",
"storybook-addon-themes": "^6.1.0",
"tsup": "^6.2.3"
},
"peerDependencies": {
"@scm-manager/ui-components": "^2.40.2-SNAPSHOT",
"classnames": "^2.3.1",
"react": "17",
"react-hook-form": "7",
"react-i18next": "11",
"react-query": "3"
},
"dependencies": {
"@scm-manager/ui-buttons": "^2.40.2-SNAPSHOT"
},
"prettier": "@scm-manager/prettier-config",
"eslintConfig": {
"extends": "@scm-manager/eslint-config"
}
}

View File

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

View File

@@ -0,0 +1,161 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React, { FC, useCallback, useEffect, useState } from "react";
import { DeepPartial, SubmitHandler, useForm, UseFormReturn } from "react-hook-form";
import { ErrorNotification, Level } from "@scm-manager/ui-components";
import { ScmFormContextProvider } from "./ScmFormContext";
import { useTranslation } from "react-i18next";
import { Button } from "@scm-manager/ui-buttons";
import FormRow from "./FormRow";
import ControlledInputField from "./input/ControlledInputField";
import ControlledCheckboxField from "./checkbox/ControlledCheckboxField";
import ControlledSecretConfirmationField from "./input/ControlledSecretConfirmationField";
import { HalRepresentation } from "@scm-manager/ui-types";
type RenderProps<T extends Record<string, unknown>> = Omit<
UseFormReturn<T>,
"register" | "unregister" | "handleSubmit" | "control"
>;
const SuccessNotification: FC<{ label?: string; hide: () => void }> = ({ label, hide }) => {
if (!label) {
return null;
}
return (
<div className="notification is-success">
<button className="delete" onClick={hide} />
{label}
</div>
);
};
type Props<FormType extends Record<string, unknown>, DefaultValues extends FormType> = {
children: ((renderProps: RenderProps<FormType>) => React.ReactNode | React.ReactNode[]) | React.ReactNode;
translationPath: [namespace: string, prefix: string];
onSubmit: SubmitHandler<FormType>;
defaultValues: Omit<DefaultValues, keyof HalRepresentation>;
readOnly?: boolean;
submitButtonTestId?: string;
};
function Form<FormType extends Record<string, unknown>, DefaultValues extends FormType>({
children,
onSubmit,
defaultValues,
translationPath,
readOnly,
submitButtonTestId,
}: Props<FormType, DefaultValues>) {
const form = useForm<FormType>({
mode: "onChange",
defaultValues: defaultValues as DeepPartial<FormType>,
});
const { formState, handleSubmit, reset } = form;
const [ns, prefix] = translationPath;
const { t } = useTranslation(ns, { keyPrefix: prefix });
const { isDirty, isValid, isSubmitting, isSubmitSuccessful } = formState;
const [error, setError] = useState<Error | null | undefined>();
const [showSuccessNotification, setShowSuccessNotification] = useState(false);
// See https://react-hook-form.com/api/useform/reset/
useEffect(() => {
if (isSubmitSuccessful) {
setShowSuccessNotification(true);
reset(defaultValues as never);
}
/* eslint-disable-next-line react-hooks/exhaustive-deps */
}, [isSubmitSuccessful]);
useEffect(() => {
if (isDirty) {
setShowSuccessNotification(false);
}
}, [isDirty]);
const translateWithFallback = useCallback<typeof t>(
(key, ...args) => {
const translation = t(key, ...(args as any));
if (translation === `${prefix}.${key}`) {
return "";
}
return translation;
},
[prefix, t]
);
const submit = useCallback(
async (data) => {
setError(null);
try {
return await onSubmit(data);
} catch (e) {
if (e instanceof Error) {
setError(e);
} else {
throw e;
}
}
},
[onSubmit]
);
return (
<ScmFormContextProvider {...form} readOnly={isSubmitting || readOnly} t={translateWithFallback}>
<form onSubmit={handleSubmit(submit)}>
{showSuccessNotification ? (
<SuccessNotification
label={translateWithFallback("submit-success-notification")}
hide={() => setShowSuccessNotification(false)}
/>
) : null}
{typeof children === "function" ? children(form) : children}
{error ? <ErrorNotification error={error} /> : null}
{!readOnly ? (
<Level
right={
<Button
type="submit"
variant="primary"
testId={submitButtonTestId ?? "submit-button"}
disabled={!isDirty || !isValid}
isLoading={isSubmitting}
>
{t("submit")}
</Button>
}
/>
) : null}
</form>
</ScmFormContextProvider>
);
}
export default Object.assign(Form, {
Row: FormRow,
Input: ControlledInputField,
Checkbox: ControlledCheckboxField,
SecretConfirmation: ControlledSecretConfirmationField,
});

View File

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

View File

@@ -0,0 +1,42 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React, { PropsWithChildren, useContext } from "react";
import { UseFormReturn } from "react-hook-form";
import type { TFunction } from "i18next";
type ContextType<T = any> = UseFormReturn<T> & {
t: TFunction;
readOnly?: boolean;
};
const ScmFormContext = React.createContext<ContextType>(null as unknown as ContextType);
export function ScmFormContextProvider<T>({ children, ...props }: PropsWithChildren<ContextType<T>>) {
return <ScmFormContext.Provider value={props}>{children}</ScmFormContext.Provider>;
}
export function useScmFormContext() {
return useContext(ScmFormContext);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,85 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React, { InputHTMLAttributes } from "react";
import { createAttributesForTesting } from "@scm-manager/ui-components";
import Help from "../base/help/Help";
type InputFieldProps = {
label: string;
helpText?: string;
testId?: string;
} & Omit<InputHTMLAttributes<HTMLInputElement>, "type">;
/**
* @see https://bulma.io/documentation/form/checkbox/
*/
const Checkbox = React.forwardRef<HTMLInputElement, InputFieldProps>(
({ readOnly, label, value, name, checked, defaultChecked, defaultValue, testId, helpText, ...props }, ref) => (
// @ts-ignore bulma uses the disabled attribute on labels, although it is not part of the html spec
<label className="checkbox" disabled={readOnly || props.disabled}>
{readOnly ? (
<>
<input
type="hidden"
name={name}
value={value}
defaultValue={defaultValue}
checked={checked}
defaultChecked={defaultChecked}
readOnly
/>
<input
type="checkbox"
className="mr-1"
ref={ref}
value={value}
defaultValue={defaultValue}
checked={checked}
defaultChecked={defaultChecked}
{...props}
{...createAttributesForTesting(testId)}
disabled
/>
</>
) : (
<input
type="checkbox"
className="mr-1"
ref={ref}
name={name}
value={value}
defaultValue={defaultValue}
checked={checked}
defaultChecked={defaultChecked}
{...props}
{...createAttributesForTesting(testId)}
/>
)}
{label}
{helpText ? <Help className="ml-1" text={helpText} /> : null}
</label>
)
);
export default Checkbox;

View File

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

View File

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

View File

@@ -0,0 +1,76 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React, { ComponentProps } from "react";
import { Controller, ControllerRenderProps, Path, RegisterOptions } from "react-hook-form";
import classNames from "classnames";
import { useScmFormContext } from "../ScmFormContext";
import CheckboxField from "./CheckboxField";
type Props<T extends Record<string, unknown>> = Omit<
ComponentProps<typeof CheckboxField>,
"label" | "defaultValue" | "required" | keyof ControllerRenderProps
> & {
name: Path<T>;
label?: string;
rules?: Pick<RegisterOptions, "deps">;
};
function ControlledInputField<T extends Record<string, unknown>>({
name,
label,
helpText,
rules,
className,
testId,
defaultChecked,
readOnly,
...props
}: Props<T>) {
const { control, t, readOnly: formReadonly } = useScmFormContext();
const labelTranslation = label || t(`${name}.label`) || "";
const helpTextTranslation = helpText || t(`${name}.helpText`);
return (
<Controller
control={control}
name={name}
rules={rules}
defaultValue={defaultChecked as never}
render={({ field }) => (
<CheckboxField
className={classNames("column", className)}
readOnly={readOnly ?? formReadonly}
defaultChecked={field.value}
{...props}
{...field}
label={labelTranslation}
helpText={helpTextTranslation}
testId={testId ?? `checkbox-${name}`}
/>
)}
/>
);
}
export default ControlledInputField;

View File

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

View File

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

View File

@@ -0,0 +1,77 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React, { ComponentProps } from "react";
import { Controller, ControllerRenderProps, Path } from "react-hook-form";
import classNames from "classnames";
import { useScmFormContext } from "../ScmFormContext";
import InputField from "./InputField";
type Props<T extends Record<string, unknown>> = Omit<
ComponentProps<typeof InputField>,
"error" | "label" | "defaultChecked" | "required" | keyof ControllerRenderProps
> & {
rules?: ComponentProps<typeof Controller>["rules"];
name: Path<T>;
label?: string;
};
function ControlledInputField<T extends Record<string, unknown>>({
name,
label,
helpText,
rules,
className,
testId,
defaultValue,
readOnly,
...props
}: Props<T>) {
const { control, t, readOnly: formReadonly } = useScmFormContext();
const labelTranslation = label || t(`${name}.label`) || "";
const helpTextTranslation = helpText || t(`${name}.helpText`);
return (
<Controller
control={control}
name={name}
rules={rules}
defaultValue={defaultValue as never}
render={({ field, fieldState }) => (
<InputField
className={classNames("column", className)}
readOnly={readOnly ?? formReadonly}
required={rules?.required as boolean}
{...props}
{...field}
label={labelTranslation}
helpText={helpTextTranslation}
error={fieldState.error ? fieldState.error.message || t(`${name}.error.${fieldState.error.type}`) : undefined}
testId={testId ?? `input-${name}`}
/>
)}
/>
);
}
export default ControlledInputField;

View File

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

View File

@@ -0,0 +1,121 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React, { ComponentProps } from "react";
import { Controller, ControllerRenderProps, Path } from "react-hook-form";
import classNames from "classnames";
import { useScmFormContext } from "../ScmFormContext";
import InputField from "./InputField";
type Props<T extends Record<string, unknown>> = Omit<
ComponentProps<typeof InputField>,
"error" | "label" | "defaultChecked" | "required" | keyof ControllerRenderProps
> & {
rules?: ComponentProps<typeof Controller>["rules"];
name: Path<T>;
label?: string;
confirmationLabel?: string;
confirmationHelpText?: string;
confirmationErrorMessage?: string;
confirmationTestId?: string;
};
export default function ControlledSecretConfirmationField<T extends Record<string, unknown>>({
name,
label,
confirmationLabel,
helpText,
confirmationHelpText,
rules,
confirmationErrorMessage,
className,
testId,
confirmationTestId,
defaultValue,
readOnly,
...props
}: Props<T>) {
const { control, watch, t, readOnly: formReadonly } = useScmFormContext();
const labelTranslation = label || t(`${name}.label`) || "";
const helpTextTranslation = helpText || t(`${name}.helpText`);
const confirmationLabelTranslation = confirmationLabel || t(`${name}.confirmation.label`) || "";
const confirmationHelpTextTranslation = confirmationHelpText || t(`${name}.confirmation.helpText`);
const confirmationErrorMessageTranslation = confirmationErrorMessage || t(`${name}.confirmation.errorMessage`);
const secretValue = watch(name);
return (
<>
<Controller
control={control}
name={name}
defaultValue={defaultValue as never}
rules={{
...rules,
deps: [`${name}Confirmation`],
}}
render={({ field, fieldState }) => (
<InputField
className={classNames("column", className)}
readOnly={readOnly ?? formReadonly}
{...props}
{...field}
required={rules?.required as boolean}
type="password"
label={labelTranslation}
helpText={helpTextTranslation}
error={
fieldState.error ? fieldState.error.message || t(`${name}.error.${fieldState.error.type}`) : undefined
}
testId={testId ?? `input-${name}`}
/>
)}
/>
<Controller
control={control}
name={`${name}Confirmation`}
defaultValue={defaultValue as never}
render={({ field, fieldState }) => (
<InputField
className={classNames("column", className)}
type="password"
readOnly={readOnly ?? formReadonly}
disabled={props.disabled}
{...field}
label={confirmationLabelTranslation}
helpText={confirmationHelpTextTranslation}
error={
fieldState.error
? fieldState.error.message || t(`${name}.confirmation.error.${fieldState.error.type}`)
: undefined
}
testId={confirmationTestId ?? `input-${name}-confirmation`}
/>
)}
rules={{
validate: (value) => secretValue === value || confirmationErrorMessageTranslation,
}}
/>
</>
);
}

View File

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

View File

@@ -0,0 +1,46 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React, { InputHTMLAttributes } from "react";
import classNames from "classnames";
import { createVariantClass, Variant } from "../variants";
import { createAttributesForTesting } from "@scm-manager/ui-components";
type Props = {
variant?: Variant;
testId?: string;
} & InputHTMLAttributes<HTMLInputElement>;
const Input = React.forwardRef<HTMLInputElement, Props>(({ variant, className, testId, ...props }, ref) => {
return (
<input
ref={ref}
className={classNames("input", createVariantClass(variant), className)}
{...props}
{...createAttributesForTesting(testId)}
/>
);
});
export default Input;

View File

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

View File

@@ -0,0 +1,60 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React from "react";
import Field from "../base/Field";
import Control from "../base/Control";
import Label from "../base/label/Label";
import FieldMessage from "../base/field-message/FieldMessage";
import Input from "./Input";
import Help from "../base/help/Help";
type InputFieldProps = {
label: string;
helpText?: string;
error?: string;
type?: "text" | "password" | "email" | "tel";
} & Omit<React.ComponentProps<typeof Input>, "type">;
/**
* @see https://bulma.io/documentation/form/input/
*/
const InputField = React.forwardRef<HTMLInputElement, InputFieldProps>(
({ label, helpText, error, className, ...props }, ref) => {
const variant = error ? "danger" : undefined;
return (
<Field className={className}>
<Label>
{label}
{helpText ? <Help className="ml-1" text={helpText} /> : null}
</Label>
<Control>
<Input variant={variant} ref={ref} {...props}></Input>
</Control>
{error ? <FieldMessage variant={variant}>{error}</FieldMessage> : null}
</Field>
);
}
);
export default InputField;

View File

@@ -0,0 +1,152 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import { apiClient, requiredLink } from "@scm-manager/ui-api";
import { useMutation, useQueryClient } from "react-query";
import { HalRepresentation, Link } from "@scm-manager/ui-types";
type QueryKeyPair = [singular: string, plural: string];
type LinkOrHalLink = string | [entity: HalRepresentation, link: string] | HalRepresentation;
const unwrapLink = (input: LinkOrHalLink, linkName: string) => {
if (Array.isArray(input)) {
return requiredLink(input[0], input[1]);
} else if (typeof input === "string") {
return input;
} else {
return (input._links[linkName] as Link).href;
}
};
type MutationResult<I, O = unknown> = {
submit: (resource: I) => Promise<O>;
isLoading: boolean;
error: Error | null;
submissionResult?: O;
};
type MutatingResourceOptions = {
contentType?: string;
};
const createResource = <I, O = never>(link: string, contentType: string) => {
return (payload: I): Promise<O> => {
return apiClient
.post(link, payload, contentType)
.then((response) => {
const location = response.headers.get("Location");
if (!location) {
throw new Error("Server does not return required Location header");
}
return apiClient.get(location);
})
.then((response) => response.json());
};
};
type CreateResourceOptions = MutatingResourceOptions;
export const useCreateResource = <I, O>(
link: string,
[entityKey, collectionName]: QueryKeyPair,
idFactory: (createdResource: O) => string,
{ contentType = "application/json" }: CreateResourceOptions = {}
): MutationResult<I, O> => {
const queryClient = useQueryClient();
const { mutateAsync, data, isLoading, error } = useMutation<O, Error, I>(createResource<I, O>(link, contentType), {
onSuccess: (result) => {
queryClient.setQueryData([entityKey, idFactory(result)], result);
return queryClient.invalidateQueries(collectionName);
},
});
return {
submit: (payload: I) => mutateAsync(payload),
isLoading,
error,
submissionResult: data,
};
};
type UpdateResourceOptions = MutatingResourceOptions & {
collectionName?: QueryKeyPair;
};
export const useUpdateResource = <T>(
link: LinkOrHalLink,
idFactory: (createdResource: T) => string,
{
contentType = "application/json",
collectionName: [entityQueryKey, collectionName] = ["", ""],
}: UpdateResourceOptions = {}
): MutationResult<T> => {
const queryClient = useQueryClient();
const { mutateAsync, isLoading, error, data } = useMutation<unknown, Error, T>(
(resource) => apiClient.put(unwrapLink(link, "update"), resource, contentType),
{
onSuccess: async (_, payload) => {
await queryClient.invalidateQueries(entityQueryKey ? [entityQueryKey, idFactory(payload)] : idFactory(payload));
if (collectionName) {
await queryClient.invalidateQueries(collectionName);
}
},
}
);
return {
submit: (resource: T) => mutateAsync(resource),
isLoading,
error,
submissionResult: data,
};
};
type DeleteResourceOptions = {
collectionName?: QueryKeyPair;
};
export const useDeleteResource = <T extends HalRepresentation>(
idFactory: (createdResource: T) => string,
{ collectionName: [entityQueryKey, collectionName] = ["", ""] }: DeleteResourceOptions = {}
): MutationResult<T> => {
const queryClient = useQueryClient();
const { mutateAsync, isLoading, error, data } = useMutation<unknown, Error, T>(
(resource) => {
const deleteUrl = (resource._links.delete as Link).href;
return apiClient.delete(deleteUrl);
},
{
onSuccess: async (_, resource) => {
const id = idFactory(resource);
await queryClient.removeQueries(entityQueryKey ? [entityQueryKey, id] : id);
if (collectionName) {
await queryClient.invalidateQueries(collectionName);
}
},
}
);
return {
submit: (resource: T) => mutateAsync(resource),
isLoading,
error,
submissionResult: data,
};
};

View File

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

View File

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