diff --git a/java/buildconf/manager-developer-build.properties.example b/java/buildconf/manager-developer-build.properties.example index a5782b96d86d..d915951ae667 100644 --- a/java/buildconf/manager-developer-build.properties.example +++ b/java/buildconf/manager-developer-build.properties.example @@ -10,3 +10,15 @@ deploy.host = d52.suse.de # Uncomment to get javascript sourcemaps in Reactjs pages #javascript.devel = true +# Define the way to deploy. Possible values: +# +# local : deploy to local instance of uyuni +# remote (default) : deploy to remote instance of uyuni defined in deploy.host through ssh connection. +# container : use mgrctl to deploy to a containerized server. The deploy.host will be ignored. +# remote-container : use SSH to connect to deploy.host, then run mgrctl on the remote system and deploy to +# a containerized server +#deploy.mode = remote + +# Backend to be used by mgrctl when deploying in container and remote-container mode. By default, mgrctl tries to +# autodetect the correct backend. Possible values can be looked up by checking mgrctl documentation. +#container.backend = podman diff --git a/java/manager-build.xml b/java/manager-build.xml index ab4e4f9184b3..81cca5e4ec9f 100644 --- a/java/manager-build.xml +++ b/java/manager-build.xml @@ -19,6 +19,7 @@ + @@ -44,19 +45,108 @@ - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -188,32 +278,31 @@ - - - - - + + + + + + + + + - - - - + + + - - - + + + - - - - - - - - - - + + + @@ -233,8 +322,8 @@ - + @@ -244,195 +333,90 @@ - - - - + + - + + - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + - - - + - + - - - + - + - - + + - - mgrctl is not in the PATH. Please install mgrctl first. - - - - - - - + + + - - - - - - - - - - - - - - - - + + + + + + + - - - + - - - - - - - - - - - - - - - - + + + - - - + + - - - - + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -442,11 +426,7 @@ - + @@ -467,7 +447,7 @@ - + @@ -497,25 +477,26 @@ - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + @@ -610,47 +591,17 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + diff --git a/testsuite/podman_runner/09_build_server_code.sh b/testsuite/podman_runner/09_build_server_code.sh index 2417388405f3..2c0c5e5464dc 100755 --- a/testsuite/podman_runner/09_build_server_code.sh +++ b/testsuite/podman_runner/09_build_server_code.sh @@ -30,7 +30,7 @@ sudo -i podman exec server bash -c "[ -d /usr/share/susemanager/www/tomcat/webap # to try again and hope it succeeds. sudo -i podman exec server bash -c "cd /java && ant -f manager-build.xml ivy || ant -f manager-build.xml ivy || ant -f manager-build.xml ivy" -sudo -i podman exec server bash -c "cd /java && ant -f manager-build.xml refresh-branding-jar deploy-local" +sudo -i podman exec server bash -c "cd /java && ant -f manager-build.xml -Ddeploy.mode=local refresh-branding-jar deploy" sudo -i podman exec server bash -c "set -xe;cd /web/html/src;[ -d dist ] || mkdir dist;yarn install --force --ignore-optional --production=true --frozen-lockfile;yarn autoclean --force;yarn build:novalidate; rsync -a dist/ /usr/share/susemanager/www/htdocs/" sudo -i podman exec server bash -c "rctomcat restart" sudo -i podman exec server bash -c "rctaskomatic restart" diff --git a/web/README.md b/web/README.md index 160d7ec4c041..d55c9daa7d5a 100644 --- a/web/README.md +++ b/web/README.md @@ -24,6 +24,7 @@ The following scripts cover most day-to-day uses, see `web/html/src/package.json - Run lint with autofixer: `yarn lint` - Run unit tests: `yarn test` + - Run a single set of tests: `yarn test path/to/file.test.ts` - Run the Typescript checker: `yarn tsc` - Build the web UI: `yarn build` - Run lint, tests, Typescript checker, and build the application: `yarn all` diff --git a/web/html/src/branding/css/susemanager/components/alerts.less b/web/html/src/branding/css/susemanager/components/alerts.less index c5d7234c2a78..104c6a932bce 100644 --- a/web/html/src/branding/css/susemanager/components/alerts.less +++ b/web/html/src/branding/css/susemanager/components/alerts.less @@ -75,7 +75,7 @@ .alert-danger, .Toastify__toast--error { background-color: @eos-bc-red-100; - border-left: 5px solid @eos-bc-red-900; + border-left: 5px solid @eos-bc-red-600; color: @eos-bc-gray-1000; &:before { diff --git a/web/html/src/branding/css/susemanager/components/alerts.scss b/web/html/src/branding/css/susemanager/components/alerts.scss index 5ac6f5952994..0a1ad3daee9a 100644 --- a/web/html/src/branding/css/susemanager/components/alerts.scss +++ b/web/html/src/branding/css/susemanager/components/alerts.scss @@ -75,7 +75,7 @@ .alert-danger, .Toastify__toast--error { background-color: $eos-bc-red-100; - border-left: 5px solid $eos-bc-red-900; + border-left: 5px solid $eos-bc-red-600; color: $eos-bc-gray-1000; &:before { diff --git a/web/html/src/branding/css/susemanager/components/buttons.less b/web/html/src/branding/css/susemanager/components/buttons.less index 99dfe97eef7e..cf9296f63948 100644 --- a/web/html/src/branding/css/susemanager/components/buttons.less +++ b/web/html/src/branding/css/susemanager/components/buttons.less @@ -5,6 +5,8 @@ .btn-success, .btn-warning, .btn-danger { + user-select: auto; + i.fa::before { // No text-decoration for the icon text, see https://stackoverflow.com/a/19529256/1470607 display: inline-block; diff --git a/web/html/src/branding/css/susemanager/components/buttons.scss b/web/html/src/branding/css/susemanager/components/buttons.scss index 5c69a93c9031..0ed2e30813a1 100644 --- a/web/html/src/branding/css/susemanager/components/buttons.scss +++ b/web/html/src/branding/css/susemanager/components/buttons.scss @@ -5,6 +5,8 @@ .btn-success, .btn-warning, .btn-danger { + user-select: auto; + i.fa::before { // No text-decoration for the icon text, see https://stackoverflow.com/a/19529256/1470607 display: inline-block; diff --git a/web/html/src/branding/css/susemanager/components/buttons.suma.less b/web/html/src/branding/css/susemanager/components/buttons.suma.less index 4f250bea181a..1b226df5195e 100644 --- a/web/html/src/branding/css/susemanager/components/buttons.suma.less +++ b/web/html/src/branding/css/susemanager/components/buttons.suma.less @@ -77,26 +77,15 @@ } } -.btn-link { - &, - &:active, - &:focus, - &:disabled, - &.disabled { - &, - &:hover { - color: @link-color; - font-weight: normal; - } +.btn { + background: transparent; - &:not(:disabled):not(.disabled):not(.text-muted):hover { - text-decoration: underline; - } + &.disabled, + &:disabled { + border-color: transparent; } } -// TODO: Add :active and :focus states for green etc -// NB! Here and below, these are not nested because they're not used consistently along with .btn .btn-default { &, &:active, @@ -116,6 +105,7 @@ } } +// TODO: Make this obsolete and move it into the search module instead .btn-default-inverse { &, &:active, @@ -173,6 +163,24 @@ } } +.btn-info { + &, + &:active, + &:focus, + &:disabled, + &.disabled { + &, + &:hover { + background: @eos-bc-cerulean-400; + border-color: @eos-bc-cerulean-400; + + &:not(:disabled):not(.disabled):not(.text-muted):hover::after { + background: @eos-bc-gray-900; + } + } + } +} + // Warning shouldn't currently be used anywhere, but cover it just in case .btn-warning, .btn-danger { @@ -187,11 +195,6 @@ border-color: @eos-bc-red-500; color: @eos-bc-white; - &:not(:disabled):not(.disabled):not(.text-muted):hover { - background: @eos-bc-red-900; - border-color: @eos-bc-red-900; - } - &:not(:disabled):not(.disabled):not(.text-muted):hover::after { background: @eos-bc-gray-900; } diff --git a/web/html/src/branding/css/susemanager/components/buttons.suma.scss b/web/html/src/branding/css/susemanager/components/buttons.suma.scss index 66c8dcda97a3..52fe8f9ece9f 100644 --- a/web/html/src/branding/css/susemanager/components/buttons.suma.scss +++ b/web/html/src/branding/css/susemanager/components/buttons.suma.scss @@ -8,8 +8,7 @@ font-weight: bold; padding: 8px 16px; position: relative; - border-style: solid; - border-width: 2px; + border: 2px solid transparent; filter: none; transition: none; text-decoration: none; @@ -71,8 +70,15 @@ } } -// TODO: Add :active and :focus states for green etc -// NB! Here and below, these are not nested because they're not used consistently along with .btn +.btn { + background: transparent; + + &.disabled, + &:disabled { + border-color: transparent; + } +} + .btn-default { &, &:active, @@ -92,6 +98,7 @@ } } +// TODO: Make this obsolete and move it into the search module instead .btn-default-inverse { &, &:active, @@ -157,10 +164,11 @@ &.disabled { &, &:hover { - border-color: $eos-bc-cerulean-500; + background: $eos-bc-cerulean-400; + border-color: $eos-bc-cerulean-400; - &:not(:disabled):not(.disabled):not(.text-muted):hover { - color: $white; + &:not(:disabled):not(.disabled):not(.text-muted):hover::after { + background: $eos-bc-gray-900; } } } @@ -180,11 +188,6 @@ border-color: $eos-bc-red-500; color: $eos-bc-white; - &:not(:disabled):not(.disabled):not(.text-muted):hover { - background: $eos-bc-red-900; - border-color: $eos-bc-red-900; - } - &:not(:disabled):not(.disabled):not(.text-muted):hover::after { background: $eos-bc-gray-900; } diff --git a/web/html/src/branding/css/susemanager/components/buttons.uyuni.less b/web/html/src/branding/css/susemanager/components/buttons.uyuni.less index 78ea3417bf93..7cf9477d5b6b 100644 --- a/web/html/src/branding/css/susemanager/components/buttons.uyuni.less +++ b/web/html/src/branding/css/susemanager/components/buttons.uyuni.less @@ -1,18 +1,92 @@ +.btn { + &, &:hover { + color: @body-color; + background: transparent; + + &.disabled, + &:disabled { + border-color: transparent; + } + } +} + +// These are grandfathered old styles so we're consistent between Bootstrap 3 and 5 .btn-default { - background-image: linear-gradient(to bottom, #fff 0%, #e6e6e6 100%); - border-color: #ccc;; - color: #5F5F5F; + &, &:hover { + background-image: linear-gradient(to bottom, #fff 0%, #e6e6e6 100%); + border-color: #e6e6e6; + color: #5F5F5F; + + &:hover { + border-color: #929292; + } + + &, &:disabled, &.disabled { + border-color: #ccc; + } + } } .btn-primary, .btn-success { - background-image: linear-gradient(to bottom, #00da92 0%, #00C081 100%); - border-color: #00C081; - color: #fff; + &, &:hover { + background-image: linear-gradient(to bottom, #00da92 0%, #00C081 100%); + color: #fff; + + &:hover { + border-color: #009060; + } + + &, &:disabled, &.disabled { + border-color: #00C081; + } + } } +.btn-warning { + &, &:hover { + background-image: linear-gradient(to bottom, #ffb638 0%, #e59409 100%); + color: #fff; + + &:hover { + border-color: #b46c00; + } + + &, &:disabled, &.disabled { + color: #fff; + border-color: #e59409; + } + } +} -.btn-warning, .btn-danger { - // Use the default Bootstrap styles + &, &:hover { + background-image: linear-gradient(to bottom, #f53933 0%, #c81b16 100%); + color: #fff; + + &:hover { + border-color: #840703; + } + + &, &:disabled, &.disabled { + color: #fff; + border-color: #c81b16; + } + } +} + +.btn-info { + &, &:hover { + background-image: linear-gradient(to bottom, #59d4f9 0%, #23bdea 100%); + color: #fff; + + &:hover { + border-color: #047fa4; + } + + &, &:disabled, &.disabled { + color: #fff; + border-color: #23bdea; + } + } } diff --git a/web/html/src/branding/css/susemanager/components/buttons.uyuni.scss b/web/html/src/branding/css/susemanager/components/buttons.uyuni.scss index ba76aecead4a..27aefca1b3c3 100644 --- a/web/html/src/branding/css/susemanager/components/buttons.uyuni.scss +++ b/web/html/src/branding/css/susemanager/components/buttons.uyuni.scss @@ -23,52 +23,90 @@ border: 1px solid; } +.btn { + &.disabled, + &:disabled { + border-color: transparent; + } +} + // These are grandfathered old styles so we're consistent between Bootstrap 3 and 5 .btn-default { - background-image: linear-gradient(to bottom, #fff 0%, #e6e6e6 100%); - color: #5F5F5F; + &, &:hover { + background-image: linear-gradient(to bottom, #fff 0%, #e6e6e6 100%); + border-color: #e6e6e6; + color: #5F5F5F; - &, &:disabled, &.disabled { - border-color: #ccc; + &:hover { + border-color: #929292; + } + + &, &:disabled, &.disabled { + border-color: #ccc; + } } } .btn-primary, .btn-success { - background-image: linear-gradient(to bottom, #00da92 0%, #00C081 100%); - color: #fff; + &, &:hover { + background-image: linear-gradient(to bottom, #00da92 0%, #00C081 100%); + color: #fff; + + &:hover { + border-color: #009060; + } - &, &:disabled, &.disabled { - border-color: #00C081; + &, &:disabled, &.disabled { + border-color: #00C081; + } } } .btn-warning { - color: #fff; - background: #f0ad4e; + &, &:hover { + background-image: linear-gradient(to bottom, #ffb638 0%, #e59409 100%); + color: #fff; + + &:hover { + border-color: #b46c00; + } - &, &:disabled, &.disabled { - border-color: #eea236; + &, &:disabled, &.disabled { + color: #fff; + border-color: #e59409; + } } } .btn-danger { - color: #fff; - background: #d9534f; + &, &:hover { + background-image: linear-gradient(to bottom, #f53933 0%, #c81b16 100%); + color: #fff; - &, &:disabled, &.disabled { - border-color: #d43f3a; + &:hover { + border-color: #840703; + } + + &, &:disabled, &.disabled { + color: #fff; + border-color: #c81b16; + } } } .btn-info { - color: #fff; - background-color: #5bc0de; - border-color: #46b8da; - - &:hover{ + &, &:hover { + background-image: linear-gradient(to bottom, #59d4f9 0%, #23bdea 100%); color: #fff; - background-color: #31b0d5; - border-color: #269abc; + + &:hover { + border-color: #047fa4; + } + + &, &:disabled, &.disabled { + color: #fff; + border-color: #23bdea; + } } } diff --git a/web/html/src/branding/css/susemanager/components/label.scss b/web/html/src/branding/css/susemanager/components/label.scss index 06a98cda7295..e228c12b913b 100644 --- a/web/html/src/branding/css/susemanager/components/label.scss +++ b/web/html/src/branding/css/susemanager/components/label.scss @@ -25,5 +25,5 @@ .label-danger { color: $white; - background-color: $eos-bc-red-900; + background-color: $eos-bc-red-600; } diff --git a/web/html/src/branding/css/susemanager/components/text.scss b/web/html/src/branding/css/susemanager/components/text.scss index fecf0a1e345d..1e4739f7f88f 100644 --- a/web/html/src/branding/css/susemanager/components/text.scss +++ b/web/html/src/branding/css/susemanager/components/text.scss @@ -31,7 +31,7 @@ .text-danger { &, &:hover, &:focus { - color: $eos-bc-red-900 !important; + color: $eos-bc-red-600 !important; } } diff --git a/web/html/src/branding/css/susemanager/variables.less b/web/html/src/branding/css/susemanager/variables.less index 81304eb32708..d30f360261f1 100644 --- a/web/html/src/branding/css/susemanager/variables.less +++ b/web/html/src/branding/css/susemanager/variables.less @@ -9,11 +9,12 @@ // TODO: This name should be -1000 or similar @eos-bc-pine-500: #0c322c; @eos-bc-cerulean-100: #b3e8f6; +@eos-bc-cerulean-400: #1acdff; @eos-bc-cerulean-500: #00b2e2; @eos-bc-cerulean-900: #008acf; @eos-bc-red-100: #f5c2c7; @eos-bc-red-500: #dc3545; -@eos-bc-red-900: #c5161f; +@eos-bc-red-600: #be1324; @eos-bc-yellow-100: #ffecb5; @eos-bc-yellow-500: #ffc107; @eos-bc-yellow-600: #cc7e00; diff --git a/web/html/src/branding/css/susemanager/variables.scss b/web/html/src/branding/css/susemanager/variables.scss index 2d7733ba3bc0..4827d980668f 100644 --- a/web/html/src/branding/css/susemanager/variables.scss +++ b/web/html/src/branding/css/susemanager/variables.scss @@ -9,11 +9,12 @@ $eos-bc-green-900: #0e7e3c; // TODO: This name should be -1000 or similar $eos-bc-pine-500: #0c322c; $eos-bc-cerulean-100: #b3e8f6; +$eos-bc-cerulean-400: #1acdff; $eos-bc-cerulean-500: #00b2e2; $eos-bc-cerulean-900: #008acf; $eos-bc-red-100: #f5c2c7; -$eos-bc-red-500: #dc3545; -$eos-bc-red-900: #c5161f; +$eos-bc-red-500: #e81b2e; +$eos-bc-red-600: #be1324; $eos-bc-yellow-100: #ffecb5; $eos-bc-yellow-500: #ffc107; $eos-bc-yellow-600: #cc7e00; diff --git a/web/html/src/components/input/InputBase.test.tsx b/web/html/src/components/input/InputBase.test.tsx index 0cfbf66d740f..4d9c48441ec7 100644 --- a/web/html/src/components/input/InputBase.test.tsx +++ b/web/html/src/components/input/InputBase.test.tsx @@ -4,6 +4,7 @@ import { render, screen } from "utils/test-utils"; import { Form } from "./form/Form"; import { InputBase } from "./InputBase"; +import { Validation } from "./validation/validation"; describe("InputBase", () => { // Use these to test model changes in tests @@ -15,7 +16,9 @@ describe("InputBase", () => { beforeEach(() => { model = {}; - onChange = () => {}; + onChange = (newModel) => { + model = newModel; + }; }); function renderWithForm(content) { @@ -40,7 +43,7 @@ describe("InputBase", () => { }; renderWithForm( - value.length > 2]}> + {({ setValue }) => { if (isFirstFire) { // Realistically this should be with a user interaction, but we manually fire it off to see if it propagates @@ -52,7 +55,7 @@ describe("InputBase", () => { ); expect(model).toStrictEqual({ foo: "bar" }); - expect(screen.queryByText(/Minimum 2 characters/)).toBeNull(); + expect(screen.queryByText(/Must be at least 2 characters long/)).toBeNull(); }); test("validation error", () => { @@ -63,12 +66,7 @@ describe("InputBase", () => { }; renderWithForm( - value.length > 2]} - > + {({ setValue }) => { if (isFirstFire) { setValue("username", "fo"); @@ -79,7 +77,7 @@ describe("InputBase", () => { ); expect(model).toStrictEqual({ username: "fo" }); - screen.findByText(/Minimum 2 characters/); + screen.findByText(/Must be at least 2 characters long/); }); test("multiple properties", () => { @@ -91,12 +89,7 @@ describe("InputBase", () => { }; renderWithForm( - Object.values(value).every((v) => v.length > 2)]} - > + {({ setValue }) => { if (isFirstFire) { setValue("firstname", "John"); diff --git a/web/html/src/components/input/InputBase.tsx b/web/html/src/components/input/InputBase.tsx index cf4f649cbca3..2e586fcc444c 100644 --- a/web/html/src/components/input/InputBase.tsx +++ b/web/html/src/components/input/InputBase.tsx @@ -1,12 +1,12 @@ import * as React from "react"; +import _debounce from "lodash/debounce"; import _isNil from "lodash/isNil"; import { FormContext } from "./form/Form"; import { FormGroup } from "./FormGroup"; import { Label } from "./Label"; - -type Validator = (...args: any[]) => boolean | Promise; +import { ValidationResult, Validator } from "./validation/validation"; export type InputBaseProps = { /** name of the field to map in the form model. @@ -54,16 +54,33 @@ export type InputBaseProps = { onBlur: () => void; }) => React.ReactNode; - /** Indicates whether the field is required in the form */ - required?: boolean; + /** + * Indicates whether the field is required in the form. + * You can optionally specify a value that's used as the error message if the value is not filled. + */ + required?: boolean | React.ReactNode; /** Indicates whether the field is disabled */ disabled?: boolean; - /** An array of validators to run against the input, either sync or async, resolve with `true` for valid & `false` for invalid */ - validators?: Validator | Validator[]; + /** + * Validate the input, either sync or async, return `undefined` when valid, a string or string array for an error message when invalid + */ + validate?: Validator | Validator[]; + + /** + * Debounce async validation for `debounceValidate` milliseconds + * + */ + debounceValidate?: number; - /** Hint to display on a validation error */ + /** + * + * Prefer returning an error message from your validator instead + * + * Fallback validation error + * + */ invalidHint?: React.ReactNode; /** Function to call when the data model needs to be changed. @@ -73,13 +90,19 @@ export type InputBaseProps = { }; type State = { - isValid: boolean; - showErrors: boolean; + isTouched: boolean; + + requiredError?: React.ReactNode; - /** Error messages received from FormContext - * (typically errors messages received from server response) + /** + * Error messages received from FormContext (typically errors messages received from a server response) + */ + formErrors?: string[]; + + /** + * Validation errors, a `Map` from a given validator to its result */ - errors?: Array | Object; + validationErrors: Map; }; export class InputBase extends React.Component, State> { @@ -99,9 +122,9 @@ export class InputBase extends React.Component) { super(props); this.state = { - isValid: true, - showErrors: false, - errors: undefined, + isTouched: false, + formErrors: undefined, + validationErrors: new Map(), }; } @@ -112,7 +135,8 @@ export class InputBase extends React.Component { + + const checkValueChange = (name: string, defaultValue?: ValueType) => { // If we don't have a value yet but do have a defaultValue, set it on the model if (typeof model[name] === "undefined" && typeof defaultValue !== "undefined") { this.setValue(name, defaultValue); @@ -145,51 +169,88 @@ export class InputBase extends React.Component { - if (name.includes(key)) { - filtered[key] = this.context.model[key]; - } - return filtered; - }, {}); - this.validate(values); - } else if (typeof name !== "undefined") { - this.validate(this.context.model[name]); - } + this.validate(this.getModelValue()); } } componentWillUnmount() { if (Object.keys(this.context).length > 0) { - this.context.unregisterInput(this); - if (this.props.name instanceof Array) { - this.props.name.forEach((name) => this.context.setModelValue(name, undefined)); - } else { - this.context.setModelValue(this.props.name, undefined); + this.context.unregisterInput?.(this); + if (Array.isArray(this.props.name)) { + this.props.name.forEach((name) => this.context.setModelValue?.(name, undefined)); + } else if (this.props.name) { + this.context.setModelValue?.(this.props.name, undefined); } } } onBlur = () => { this.setState({ - showErrors: true, + isTouched: true, }); }; - isValid() { - return this.state.isValid; + getModelValue() { + const name = this.props.name; + if (Array.isArray(name)) { + const values = Object.keys(this.context.model).reduce((filtered, key) => { + if (name.includes(key)) { + filtered[key] = this.context.model[key]; + } + return filtered; + }, {} as ValueType); + return values; + } else if (typeof name !== "undefined") { + return this.context.model[name]; + } } - isEmptyValue(input: any) { + isEmptyValue(input: unknown) { if (typeof input === "string") { return input.trim() === ""; } return _isNil(input); } + requiredHint = () => { + const value = this.getModelValue(); + const hasNoValue = + this.isEmptyValue(value) || + (Array.isArray(this.props.name) && Object.values(value).filter((v) => !this.isEmptyValue(v)).length === 0); + + if (hasNoValue) { + if (typeof this.props.required === "string") { + return this.props.required; + } + + return this.props.label ? t(`${this.props.label} is required.`) : t("Required"); + } + }; + + private validateRequired = (value: T) => { + let requiredError: React.ReactNode = undefined; + + if (this.props.required && !this.props.disabled) { + const hasNoValue = + this.isEmptyValue(value) || + (Array.isArray(this.props.name) && Object.values(value).filter((v) => !this.isEmptyValue(v)).length === 0); + + if (hasNoValue) { + if (this.props.required && this.props.required !== true) { + requiredError = this.props.required; + } else { + requiredError = this.props.label ? t(`${this.props.label} is required.`) : t("Required"); + } + } + } + + if (requiredError !== this.state.requiredError) { + this.setState({ requiredError }, () => this.context.validateForm?.()); + } + }; + /** * Validate the input, updating state and errors if necessary. * @@ -197,51 +258,68 @@ export class InputBase extends React.Component(value: InferredValueType, errors?: Array | Object): void { - const results: ReturnType[] = []; - let isValid = true; + private debouncedValidate = _debounce(async (value: T): Promise => { + const validators = Array.isArray(this.props.validate) ? this.props.validate : [this.props.validate] ?? []; + + /** + * Each validator sets its own result independently, this way we can mix and match different speed async + * validators without having to wait all of them to finish + */ + await Promise.all( + validators.map(async (validator, index) => { + // If the validator is debounced, it may be undefined + if (!validator) { + return; + } - if (Array.isArray(errors) && errors.length > 0) { - isValid = false; - } + // BUG: We don't handle race conditions here, it's a bug, but it will get fixed for free this once we swap this code out for Formik + const result = await validator(value); + this.setState((state) => { + const newValidationErrors = new Map(state.validationErrors); + if (result) { + newValidationErrors.set(index, result); + } else { + newValidationErrors.delete(index); + } - if (!this.props.disabled && (value || this.props.required)) { - const noValue = - this.isEmptyValue(value) || - (Array.isArray(this.props.name) && Object.values(value).filter((v) => !this.isEmptyValue(v)).length === 0); - if (this.props.required && noValue) { - isValid = false; - } else if (this.props.validators) { - const validators = Array.isArray(this.props.validators) ? this.props.validators : [this.props.validators]; - validators.forEach((v) => { - results.push(Promise.resolve(v(value instanceof Object ? value : `${value || ""}`))); + return { + ...state, + validationErrors: newValidationErrors, + }; }); - } + }) + ); + + this.context.validateForm?.(); + }, this.props.debounceValidate ?? 0); + + validate = (value: InferredValueType): void => { + this.validateRequired(value); + this.debouncedValidate(value); + }; + + isValid = () => { + if (this.state.requiredError) { + return false; + } + if (this.state.formErrors?.some((item) => typeof item !== "undefined")) { + return false; + } + if (this.state.validationErrors.size > 0) { + return false; } + return true; + }; - Promise.all(results).then((result) => { - result.forEach((r) => { - isValid = isValid && r; - }); - this.setState( - (state) => ({ - isValid: isValid, - errors: errors, - showErrors: state.showErrors || (Array.isArray(errors) && errors.length > 0), - }), - () => { - if (this.context.validateForm != null) { - this.context.validateForm(); - } - } - ); - }); - } + setFormErrors = (formErrors?: string[]) => { + this.setState({ formErrors }); + }; setValue = (name: string | undefined = undefined, value: ValueType) => { if (name && this.context.setModelValue != null) { this.context.setModelValue(name, value); } + const propsName = this.props.name; if (propsName instanceof Array) { const values = Object.keys(this.context.model).reduce((filtered, key) => { @@ -259,39 +337,64 @@ export class InputBase extends React.Component this.pushHint(hints, item)); + return; + } + if (hint) { if (hints.length > 0) { - hints.push(
); + hints.push(
); } hints.push(hint); } } render() { - const isError = this.state.showErrors && !this.state.isValid; - const requiredHint = this.props.label ? t(`${this.props.label} is required.`) : t("required"); - const invalidHint = isError && (this.props.invalidHint || (this.props.required && requiredHint)); const hints: React.ReactNode[] = []; this.pushHint(hints, this.props.hint); + this.state.formErrors?.forEach((error) => this.pushHint(hints, error)); + if (this.state.isTouched) { + if (this.state.validationErrors.size) { + if (this.props.invalidHint) { + this.pushHint(hints, this.props.invalidHint); + } else { + this.state.validationErrors.forEach((error) => this.pushHint(hints, error)); + } + } - const errors = Array.isArray(this.state.errors) ? this.state.errors : this.state.errors ? [this.state.errors] : []; - if (errors.length > 0) { - errors.forEach((error) => this.pushHint(hints, error)); - } else { - this.pushHint(hints, invalidHint); + this.pushHint(hints, this.state.requiredError); } + const hasError = this.state.isTouched && !this.isValid(); + + // if (!this.isValid()) { + // console.log("+++++++++++++++++++++"); + // console.log(this.props.name); + // console.log(this.state.requiredError); + // console.log(this.state.validationErrors); + // console.log(this.state.formErrors); + // console.log(this.getModelValue()); + // console.log("+++++++++++++++++++++"); + // } + return ( - + {this.props.label && (