Skip to content

Commit

Permalink
Fix required validation
Browse files Browse the repository at this point in the history
  • Loading branch information
Etheryte committed Oct 2, 2024
1 parent ae76ce3 commit c94cc0c
Show file tree
Hide file tree
Showing 5 changed files with 757 additions and 686 deletions.
103 changes: 65 additions & 38 deletions web/html/src/components/input/InputBase.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,8 @@ export type InputBaseProps<ValueType = string> = {
type State = {
isTouched: boolean;

requiredError?: React.ReactNode;

/**
* Error messages received from FormContext (typically errors messages received from a server response)
*/
Expand Down Expand Up @@ -227,52 +229,79 @@ export class InputBase<ValueType = string> extends React.Component<InputBaseProp
}
};

private validateRequired = <T,>(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.
*
* The default case is for InputBase<ValueType = string>, but different inputs may use any type, for example an object when
* `this.props.name` is an array. This makes inferring validation types tricky, so we accept whatever inputs make sense
* for a given branch.
*/
validate = _debounce(
async <InferredValueType extends unknown = ValueType>(value: InferredValueType): Promise<void> => {
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;
private debouncedValidate = _debounce(async <T,>(value: T): Promise<void> => {
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;
}

// 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);
}

// 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);
}

return {
...state,
validationErrors: newValidationErrors,
};
});
})
);

this.context.validateForm?.();
},
this.props.debounceValidate ?? 0
);
return {
...state,
validationErrors: newValidationErrors,
};
});
})
);

this.context.validateForm?.();
}, this.props.debounceValidate ?? 0);

validate = <InferredValueType extends unknown = ValueType>(value: InferredValueType): void => {
this.validateRequired(value);
this.debouncedValidate(value);
};

isValid = () => {
if (this.state.requiredError) {
return false;
}
if (this.state.formErrors && this.state.formErrors.length > 0) {
return false;
}
Expand Down Expand Up @@ -342,9 +371,7 @@ export class InputBase<ValueType = string> extends React.Component<InputBaseProp
}
}

if (this.props.required && !this.props.disabled) {
this.pushHint(hints, this.requiredHint());
}
this.pushHint(hints, this.state.requiredError);
}

const hasError = this.state.isTouched && !this.isValid();
Expand Down
31 changes: 31 additions & 0 deletions web/html/src/components/input/validation/async-submit.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { useState } from "react";

import { SubmitButton } from "components/buttons";
import { Form, Text } from "components/input";

const timeout = (ms = 0) => new Promise<void>((resolve) => window.setTimeout(() => resolve(), ms));

export default () => {
const [model, setModel] = useState({ foo: "Foo" });
const [isValid, setIsValid] = useState(true);

const asyncValidator = async (value: string) => {
await timeout(300);

if (!value.toLowerCase().includes("o")) {
return "Must include the letter 'o'";
}
};

const onValidate = (newIsValid: boolean) => {
console.log(newIsValid);
setIsValid(newIsValid);
};

return (
<Form model={model} onChange={setModel} onValidate={onValidate}>
<Text name="foo" required validate={asyncValidator} debounceValidate={500} />
<SubmitButton id="submit-btn" className="btn-success" text="Submit" disabled={!isValid} />
</Form>
);
};
11 changes: 11 additions & 0 deletions web/html/src/manager/storybook/stories.generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,17 @@ export const components_input_text_Text_stories_tsx = {
raw: components_input_text_Text_stories_tsx_raw,
};

import components_input_validation_async_submit_stories_tsx_component from "components/input/validation/async-submit.stories.tsx";
import components_input_validation_async_submit_stories_tsx_raw from "components/input/validation/async-submit.stories.tsx?raw";

export const components_input_validation_async_submit_stories_tsx = {
path: "components/input/validation/async-submit.stories.tsx",
title: "async-submit.stories.tsx",
groupName: "validation",
component: components_input_validation_async_submit_stories_tsx_component,
raw: components_input_validation_async_submit_stories_tsx_raw,
};

import components_input_validation_async_stories_tsx_component from "components/input/validation/async.stories.tsx";
import components_input_validation_async_stories_tsx_raw from "components/input/validation/async.stories.tsx?raw";

Expand Down
Loading

0 comments on commit c94cc0c

Please sign in to comment.