Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

New component: FieldValidation #238

Closed
wants to merge 8 commits into from
Closed
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
189 changes: 189 additions & 0 deletions components/field-validation/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
import { __ } from '@wordpress/i18n';
import { useState, useEffect, useMemo, forwardRef, cloneElement } from '@wordpress/element';
import { dispatch } from '@wordpress/data';
import { useFloating, autoUpdate } from '@floating-ui/react-dom';
import { v4 as uuid } from 'uuid';
import PropTypes from 'prop-types';
import styled from '@emotion/styled';

/**
* Create Tests Array
*
* @description create a sanitized tests array.
*
* @param {boolean|string} required Field validation required prop.
* @param {boolean|Array} validate Field validation validate prop.
*
* @returns {Array}
*/
const createTestsArray = (required, validate) => {
const tests = [];

if (required) {
const fn = (str) => typeof str === 'string' && str !== '';
const res = typeof required === 'string' ? required : __('Required', '10up-block-library');
darrenjacoby marked this conversation as resolved.
Show resolved Hide resolved
tests.push([fn, res]);
}

if (validate && Array.isArray(validate)) {
const isNested = (arr) => Array.isArray(arr) && arr.some((item) => Array.isArray(item));
const sanitized = isNested(validate) ? validate : [validate];
sanitized.map((entry) => tests.push(entry));
}

return tests;
};

/**
* Validate Tests Array
*
* @description test a value against the tests array.
*
* @param {string} value Field value.
* @param {Array} tests Tests array created from required and validate.
*
* @returns {boolean} responses
*/
const validateTestsArray = (value, tests) => {
const responses = [];

tests.forEach((entry) => {
if (!Array.isArray(entry)) {
return;
}

const [test, res] = entry;
const passes = test(value);

if (passes === false) {
responses.push(res);
}
});

return responses;
};

/**
* Error Message
*
* @description style the validation error message.
*
* @returns <ErrorMessage />
*/
const ErrorResponse = styled('div')`
--color-warning: #f00;

color: var(--color-warning);
`;

/**
* Error
*
* @description display validation error.
*
* @returns <Error />
*/
const Error = forwardRef((props, ref) => {
const { responses } = props;

return (
<ErrorResponse className="tenup--block-components__validation-error" {...props} ref={ref}>
{responses.map((response) => (
<div key={uuid()} className="tenup--block-components__validation-error__rule">
{response}
</div>
))}
</ErrorResponse>
);
});

/**
* Field Validation
*
* @description create new component which adds field validation abilities.
*
* @param {object} props Component props.
* @param {string} props.value Field validation value.
* @param {boolean|string} props.required Field validation required prop.
* @param {boolean|Array} props.validate Field validation validate array prop.
* @param {*} props.children Child components.
*
* @returns {HTMLElement} <FieldValidation />
*/
const FieldValidation = (props) => {
const { value, required = false, validate = false, children } = props;

const tests = useMemo(() => createTestsArray(required, validate), [required, validate]);

const { x, y, reference, floating, strategy } = useFloating({
placement: 'bottom-start',
strategy: 'fixed',
whileElementsMounted: autoUpdate,
});

const [responses, setResponses] = useState(true);
const [lockId, setLockId] = useState(null);

if (lockId === null) {
setLockId(uuid());
}

const validateTests = useMemo(
() => (value) => {
const test = value === '' && required ? [tests[0]] : tests;
return validateTestsArray(value, test);
},
[required, tests],
);

const dispatchLock = useMemo(
() => (state) => {
if (state) {
dispatch('core/editor').lockPostSaving(lockId);
} else {
dispatch('core/editor').unlockPostSaving(lockId);
}
},
[lockId],
);

useEffect(() => {
setResponses(validateTests(value));
}, [value, validateTests]);

useEffect(() => {
dispatchLock(responses.length > 0);
}, [responses, dispatchLock]);

return (
<>
{cloneElement(children, { ref: reference, ...children.props })}

{responses.length > 0 && (
<Error
ref={floating}
responses={responses}
style={{
position: strategy,
top: y ?? 0,
left: x ?? 0,
}}
/>
)}
</>
);
};

export { FieldValidation };

FieldValidation.defaultProps = {
required: false,
validate: false,
};

FieldValidation.propTypes = {
value: PropTypes.string.isRequired,
required: PropTypes.oneOfType([PropTypes.bool, PropTypes.string]),
validate: PropTypes.oneOfType([PropTypes.bool, PropTypes.array]),
children: PropTypes.node.isRequired,
};
106 changes: 106 additions & 0 deletions components/field-validation/readme.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
# Field Validation

The Field Validation component enables the ability to add field validation.

## Usage

### Prop: Required

`<FieldValidation>` provides a `required` prop which accepts true or a string. Passing true will display the default response which is "Required", while passing a string enables the use of a custom response.

```js
import { FieldValidation } from '@10up/block-components';

function BlockEdit(props) {
const { attributes, setAttributes } = props;
const { title } = attributes;

return (
<>
<FieldValidation value={title} required={true}>
<RichText
tagName="p"
value={title}
onChange={(value) => setAttributes({ title: value })}
/>
</FieldValidation>

<FieldValidation value={title} required={__('Title is a required field', '10up')__}>
<RichText
tagName="p"
value={title}
onChange={(value) => setAttributes({ title: value })}
/>
</FieldValidation>
</>
)
}
```

### Prop: Validate

`<FieldValidation>` can provide more extensive validation tests, `validate` accepts an array of tests. Each test entry is an array containing a callback function and response.

```js
import { FieldValidation } from '@10up/block-components';

function BlockEdit(props) {
const { attributes, setAttributes } = props;
const { title } = attributes;

return (
<>
<FieldValidation
value={title}
validate={[ (value) => /^[a-zA-Z\s]+$/.test(value), __('Title requires a-z characters', '10up') ]}
>
<RichText
tagName="p"
value={title}
onChange={(value) => setAttributes({ title: value })}
/>
</FieldValidation>
</>
)
}
```

### Full example

Required and validate props can be combined. The required validation will display if the value is empty, and validate will display if there is a value available for custom tests.

```js
import { FieldValidation } from '@10up/block-components';

function BlockEdit(props) {
const { attributes, setAttributes } = props;
const { title } = attributes;

return (
<>
<FieldValidation
value={title}
required={__('Title is a required field', '10up')}
validate={[
[ (value) => /^[a-zA-Z\s]+$/.test(value), __('Title requires a-z characters', '10up') ],
[ (value) => /^.{10,}$/.test(value), __('Title requires 10 characters or more', '10up') ],
]}
>
<RichText
tagName="p"
value={title}
onChange={(value) => setAttributes({ title: value })}
/>
</FieldValidation>
</>
)
}
```

## Props

| Name | Type | Default | Description |
| ---------- | ----------------- | -------- | -------------------------------------------------------------- |
| `value` | `string` | `undefined` | Value to validate |
| `required` | `boolean/string` | `false/Required` | Required validation response |
| `validate` | `array` | `false` | Tests to validate value |