[External products] Product details (#41442)

* Add buy button section

* Enable external product support into the product block editor

* Hide buy button section when product type is not external

* Remove BaseControl from TextControl since it's not required anymore, InputControl takes care of that now

* Add type and suffix support to the product-text-field block

* Add the placeholder to the external url input and remove required constraint to the buy button text

* Set the url icon link type to external

* Fix input border to be red when invalida now that base control is not present twice

* Set the min height to 36px to match others non InputControls components height

* Extends required constrain to also support a custom error message

* Extends the product-text-field validation system

* Add product-text-field documentation

* Add changelog files

* Fix php linter error

* Fix compilation error

* Fix linter errors
This commit is contained in:
Maikel David Pérez Gómez 2023-11-16 16:11:44 -03:00 committed by GitHub
parent 89514921f9
commit ed0d38c44b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 452 additions and 139 deletions

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Add support for custom validation message to the generic text block

View File

@ -8,66 +8,171 @@ A reusable text field for the product editor.
### label
- **Type:** `String`
- **Required:** `Yes`
- **Type:** `String`
- **Required:** `Yes`
Label that appears on top of the field.
### property
- **Type:** `String`
- **Required:** `Yes`
- **Type:** `String`
- **Required:** `Yes`
Property in which the value is stored.
### help
- **Type:** `String`
- **Required:** `No`
- **Type:** `String`
- **Required:** `No`
Help text that appears below the field.
### required
- **Type:** `Boolean`
- **Required:** `No`
- **Type:** `Boolean`|`String`
- **Required:** `No`
Indicates and enforces that the field is required.
If the value is string it will be used as the custom error message.
### tooltip
- **Type:** `String`
- **Required:** `No`
- **Type:** `String`
- **Required:** `No`
If provided, shows a tooltip next to the label with additional information.
### placeholder
- **Type:** `String`
- **Required:** `No`
- **Type:** `String`
- **Required:** `No`
Placeholder text that appears in the field when it's empty.
### type
- **Type:** `Object`
- `value`
- **Type:** `String`
- **Required:** `No`
- **Default:** `'text'`
- `message`
- **Type:** `String`
- **Required:** `No`
- **Required:** `No`
Reffers to the type of the input. The `value` can be [any valid input type](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#input_types). The message is used as a custom error `message` for the [typeMismatch](https://developer.mozilla.org/en-US/docs/Web/API/ValidityState/typeMismatch) validity.
### pattern
- **Type:** `Object`
- `value`
- **Type:** `String`
- **Required:** `Yes`
- `message`
- **Type:** `String`
- **Required:** `No`
- **Required:** `No`
Reffers to the validation pattern of the input. The `value` can be [any valid regular expression](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/pattern). The `message` is used as a custom error message for the [patternMismatch](https://developer.mozilla.org/en-US/docs/Web/API/ValidityState/patternMismatch) validity.
### minLength
- **Type:** `Object`
- `value`
- **Type:** `Number`
- **Required:** `Yes`
- `message`
- **Type:** `String`
- **Required:** `No`
- **Required:** `No`
Reffers to the minimum string length constraint of the input. The `value` can be [any positive integer](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/minLength). The `message` is used as a custom error message for the [tooShort](https://developer.mozilla.org/en-US/docs/Web/API/ValidityState/tooShort) validity.
### maxLength
- **Type:** `Object`
- `value`
- **Type:** `Number`
- **Required:** `Yes`
- `message`
- **Type:** `String`
- **Required:** `No`
- **Required:** `No`
Reffers to the maximum string length constraint of the input. The `value` can be [any positive integer](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/maxLength). The `message` is used as a custom error message for the [tooLong](https://developer.mozilla.org/en-US/docs/Web/API/ValidityState/tooLong) validity.
### min
- **Type:** `Object`
- `value`
- **Type:** `Number`
- **Required:** `Yes`
- `message`
- **Type:** `String`
- **Required:** `No`
- **Required:** `No`
Reffers to the [minimum](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/min) value that is acceptable and valid for the input containing the attribute. The `value` must be less than or equal to the value of the `max` attribute. The `message` is used as a custom error message for the [rangeUnderflow](https://developer.mozilla.org/en-US/docs/Web/API/ValidityState/rangeUnderflow) validity.
### max
- **Type:** `Object`
- `value`
- **Type:** `Number`
- **Required:** `Yes`
- `message`
- **Type:** `String`
- **Required:** `No`
- **Required:** `No`
Reffers to the [maximum](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/max) value that is acceptable and valid for the input containing the attribute. The `value` must be greater than or equal to the value of the `min` attribute. The `message` is used as a custom error message for the [rangeOverflow](https://developer.mozilla.org/en-US/docs/Web/API/ValidityState/rangeOverflow) validity.
## Usage
Here's a snippet that adds a field similar to the previous screenshot:
```php
$section->add_block(
[
'id' => 'example-text-meta',
'blockName' => 'woocommerce/product-text-field',
'order' => 13,
'attributes' => [
'label' => 'Text',
'property' => 'meta_data.text',
'placeholder' => 'Placeholder',
'required' => true,
'help' => 'Add additional information here',
'tooltip' => 'My tooltip'
],
]
array(
'id' => 'example-text-meta',
'blockName' => 'woocommerce/product-text-field',
'order' => 13,
'attributes' => array(
'label' => 'Text',
'property' => 'meta_data.text',
'placeholder' => 'Placeholder',
'required' => true,
'help' => 'Add additional information here',
'tooltip' => 'My tooltip',
),
)
);
```
Here's a snippet that adds fields validations:
```php
$section->add_block(
array(
'id' => 'product-external-url',
'blockName' => 'woocommerce/product-text-field',
'order' => 10,
'attributes' => array(
'property' => 'external_url',
'label' => __( 'Link to the external product', 'woocommerce' ),
'placeholder' => __( 'Enter the external URL to the product', 'woocommerce' ),
'suffix' => true,
'type' => array(
'value' => 'url',
'message' => __( 'Link to the external product is an invalid URL.', 'woocommerce' ),
),
'minLength' => array(
'value' => 8,
'message' => __( 'The link must be longer than %d.', 'woocommerce' ),
),
'required' => __( 'Link to the external product is required.', 'woocommerce' ),
),
)
);
```

View File

@ -5,10 +5,7 @@
"title": "Product text field",
"category": "woocommerce",
"description": "A text field for use in the product editor.",
"keywords": [
"products",
"text"
],
"keywords": [ "products", "text" ],
"textdomain": "default",
"attributes": {
"label": {
@ -27,21 +24,29 @@
"tooltip": {
"type": "string"
},
"suffix": {
"type": "object"
},
"type": {
"type": "object"
},
"required": {
"type": "boolean",
"default": false
"type": "object"
},
"validationRegex": {
"type": "string"
},
"validationErrorMessage": {
"type": "string"
"pattern": {
"type": "object"
},
"minLength": {
"type": "number"
"type": "object"
},
"maxLength": {
"type": "number"
"type": "object"
},
"min": {
"type": "object"
},
"max": {
"type": "object"
}
},
"supports": {

View File

@ -1,92 +1,167 @@
/**
* External dependencies
*/
import { createElement } from '@wordpress/element';
import { __, sprintf } from '@wordpress/i18n';
import { Product } from '@woocommerce/data';
import { useWooBlockProps } from '@woocommerce/block-templates';
import { Link } from '@woocommerce/components';
import { Product } from '@woocommerce/data';
import { createElement, useRef } from '@wordpress/element';
import { __, sprintf } from '@wordpress/i18n';
import { Icon, external } from '@wordpress/icons';
/**
* Internal dependencies
*/
import { useValidation } from '../../../contexts/validation-context';
import useProductEntityProp from '../../../hooks/use-product-entity-prop';
import { TextBlockAttributes } from './types';
import { ProductEditorBlockEditProps } from '../../../types';
import { TextControl } from '../../../components/text-control';
import { useValidation } from '../../../contexts/validation-context';
import { useProductEdits } from '../../../hooks/use-product-edits';
import useProductEntityProp from '../../../hooks/use-product-entity-prop';
import { ProductEditorBlockEditProps } from '../../../types';
import { TextBlockAttributes } from './types';
export function Edit( {
attributes,
context: { postType },
}: ProductEditorBlockEditProps< TextBlockAttributes > ) {
const blockProps = useWooBlockProps( attributes );
const {
property,
label,
placeholder,
required,
validationRegex,
validationErrorMessage,
pattern,
minLength,
maxLength,
min,
max,
help,
tooltip,
disabled,
type,
suffix,
} = attributes;
const [ value, setValue ] = useProductEntityProp< string >( property, {
postType,
fallbackValue: '',
} );
const { hasEdit } = useProductEdits();
const inputRef = useRef< HTMLInputElement >( null );
const { error, validate } = useValidation< Product >(
property,
async function validator() {
if ( typeof value !== 'string' ) {
return __(
'Unexpected property type assigned to field.',
'woocommerce'
if ( ! inputRef.current ) return;
const input = inputRef.current;
let customErrorMessage = '';
if ( input.validity.typeMismatch ) {
customErrorMessage =
type?.message ??
__( 'Invalid value for the field.', 'woocommerce' );
}
if ( input.validity.valueMissing ) {
customErrorMessage =
typeof required === 'string'
? required
: __( 'This field is required.', 'woocommerce' );
}
if ( input.validity.patternMismatch ) {
customErrorMessage =
pattern?.message ??
__( 'Invalid value for the field.', 'woocommerce' );
}
if ( input.validity.tooShort ) {
// eslint-disable-next-line @wordpress/valid-sprintf
customErrorMessage = sprintf(
minLength?.message ??
/* translators: %d: minimum length */
__(
'The minimum length of the field is %d',
'woocommerce'
),
minLength?.value
);
}
if ( required && ! value ) {
return __( 'This field is required.', 'woocommerce' );
}
if ( validationRegex ) {
const regExp = new RegExp( validationRegex );
if ( ! regExp.test( value ) ) {
return (
validationErrorMessage ||
__( 'Invalid value for the field.', 'woocommerce' )
);
}
}
if ( typeof minLength === 'number' && value.length < minLength ) {
return sprintf(
/* translators: %d: minimum length */
__(
'The minimum length of the field is %d',
'woocommerce'
),
minLength
if ( input.validity.tooLong ) {
// eslint-disable-next-line @wordpress/valid-sprintf
customErrorMessage = sprintf(
maxLength?.message ??
/* translators: %d: maximum length */
__(
'The maximum length of the field is %d',
'woocommerce'
),
maxLength?.value
);
}
if ( typeof maxLength === 'number' && value.length > maxLength ) {
return sprintf(
/* translators: %d: maximum length */
__(
'The maximum length of the field is %d',
'woocommerce'
),
maxLength
if ( input.validity.rangeUnderflow ) {
// eslint-disable-next-line @wordpress/valid-sprintf
customErrorMessage = sprintf(
min?.message ??
/* translators: %d: minimum length */
__(
'The minimum value of the field is %d',
'woocommerce'
),
min?.value
);
}
if ( input.validity.rangeOverflow ) {
// eslint-disable-next-line @wordpress/valid-sprintf
customErrorMessage = sprintf(
max?.message ??
/* translators: %d: maximum length */
__(
'The maximum value of the field is %d',
'woocommerce'
),
max?.value
);
}
input.setCustomValidity( customErrorMessage );
if ( ! input.validity.valid ) {
return input.validationMessage;
}
},
[ value ]
[ type, required, pattern, minLength, maxLength, min, max ]
);
function getSuffix() {
if ( ! suffix || ! value || ! inputRef.current ) return;
const isValidUrl =
inputRef.current.type === 'url' &&
! inputRef.current.validity.typeMismatch;
if ( suffix === true && isValidUrl ) {
return (
<Link
type="external"
href={ value }
target="_blank"
rel="noreferrer"
className="wp-block-woocommerce-product-text-field__suffix-link"
>
<Icon icon={ external } size={ 20 } />
</Link>
);
}
return typeof suffix === 'string' ? suffix : undefined;
}
return (
<div { ...blockProps }>
<TextControl
ref={ inputRef }
type={ type?.value ?? 'text' }
value={ value }
disabled={ disabled }
label={ label }
@ -99,8 +174,14 @@ export function Edit( {
error={ error }
help={ help }
placeholder={ placeholder }
required={ required }
tooltip={ tooltip }
suffix={ getSuffix() }
required={ Boolean( required ) }
pattern={ pattern?.value }
minLength={ minLength?.value }
maxLength={ maxLength?.value }
min={ min?.value }
max={ max?.value }
/>
</div>
);

View File

@ -0,0 +1,10 @@
.wp-block-woocommerce-product-text-field {
&__suffix-link {
display: flex;
align-items: center;
justify-content: center;
width: $grid-unit-30;
height: $grid-unit-30;
fill: $gray-700;
}
}

View File

@ -1,6 +1,7 @@
/**
* External dependencies
*/
import type { HTMLInputTypeAttribute } from 'react';
import type { BlockAttributes } from '@wordpress/blocks';
export interface TextBlockAttributes extends BlockAttributes {
@ -9,9 +10,12 @@ export interface TextBlockAttributes extends BlockAttributes {
help?: string;
tooltip?: string;
placeholder?: string;
required?: boolean;
validationRegex?: string;
validationErrorMessage?: string;
minLength?: number;
maxLength?: number;
suffix?: boolean | string;
required?: boolean | string;
type?: { value?: HTMLInputTypeAttribute; message?: string };
pattern?: { value: string; message?: string };
minLength?: { value: number; message?: string };
maxLength?: { value: number; message?: string };
min?: { value: number; message?: string };
max?: { value: number; message?: string };
}

View File

@ -42,6 +42,25 @@
max-width: unset;
}
.components-base-control {
&.has-error {
.components-input-control__backdrop {
border-color: $studio-red-50;
}
.components-base-control__help {
color: $studio-red-50;
}
}
.components-input-control__container .components-input-control__input {
min-height: $grid-unit-40 + $grid-unit-05;
}
}
// This is wrong for @wordpress/components/InputControl since it is
// wrapped within the BaseControl by default. So it does not need
// to be wrapped again.
.has-error {
.components-base-control {
margin-bottom: 0;

View File

@ -46,3 +46,8 @@ export { NumberControl as __experimentalNumberControl } from './number-control';
export * from './product-page-skeleton';
export * from './modal-editor-welcome-guide';
export {
TextControl as __experimentalTextControl,
TextControlProps,
} from './text-control';

View File

@ -1 +1,2 @@
export * from './text-control';
export * from './types';

View File

@ -1,52 +1,41 @@
/**
* External dependencies
*/
import { createElement } from '@wordpress/element';
import { useInstanceId } from '@wordpress/compose';
import { Ref } from 'react';
import { createElement, forwardRef } from '@wordpress/element';
import classNames from 'classnames';
import {
BaseControl,
// @ts-expect-error `__experimentalInputControl` does exist.
__experimentalInputControl as InputControl,
} from '@wordpress/components';
/**
* Internal dependencies
*/
import { Label } from '../label/label';
import { TextControlProps } from './types';
export type TextProps = {
value?: string;
onChange: ( selected: string ) => void;
label: string;
suffix?: string;
help?: string;
error?: string;
placeholder?: string;
required?: boolean;
onBlur?: () => void;
tooltip?: string;
disabled?: boolean;
};
export const TextControl: React.FC< TextProps > = ( {
value,
onChange,
label,
help,
error,
onBlur,
placeholder,
required,
tooltip,
disabled,
}: TextProps ) => {
const textControlId = useInstanceId(
BaseControl,
'text-control'
) as string;
export const TextControl = forwardRef( function ForwardedTextControl(
{
label,
help,
error,
tooltip,
className,
required,
onChange,
onBlur,
...props
}: TextControlProps,
ref: Ref< HTMLInputElement >
) {
return (
<BaseControl
id={ textControlId }
<InputControl
{ ...props }
ref={ ref }
className={ classNames( className, {
'has-error': error,
} ) }
label={
<Label
label={ label }
@ -54,19 +43,10 @@ export const TextControl: React.FC< TextProps > = ( {
tooltip={ tooltip }
/>
}
className={ classNames( {
'has-error': error,
} ) }
required={ required }
help={ error || help }
>
<InputControl
id={ textControlId }
disabled={ disabled }
placeholder={ placeholder }
value={ value }
onChange={ onChange }
onBlur={ onBlur }
></InputControl>
</BaseControl>
onChange={ onChange }
onBlur={ onBlur }
/>
);
};
} );

View File

@ -0,0 +1,19 @@
/**
* This must inherit the InputControlProp from @wordpress/components
* but it has not been exported yet
*/
export type TextControlProps = Omit<
React.DetailedHTMLProps<
React.InputHTMLAttributes< HTMLInputElement >,
HTMLInputElement
>,
'onChange'
> & {
label: string;
help?: string;
error?: string;
tooltip?: string;
prefix?: React.ReactNode;
suffix?: React.ReactNode;
onChange?( value: string ): void;
};

View File

@ -205,7 +205,6 @@ export function VariationsTableRow( {
{ ( variation.status === 'private' ||
! variation.regular_price ) && (
<Tooltip
// @ts-expect-error className is missing in TS, should remove this when it is included.
className="woocommerce-attribute-list-item__actions-tooltip"
position="top center"
text={ NOT_VISIBLE_TEXT }

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Add support for external/affiliate products

View File

@ -45,6 +45,10 @@ class Init {
array_push( $this->supported_post_types, 'variable' );
}
if ( Features::is_enabled( 'product-external-affiliate' ) ) {
array_push( $this->supported_post_types, 'external' );
}
$this->redirection_controller = new RedirectionController( $this->supported_post_types );
if ( \Automattic\WooCommerce\Utilities\FeaturesUtil::feature_is_enabled( 'product_block_editor' ) ) {

View File

@ -226,11 +226,84 @@ class SimpleProductTemplate extends AbstractProductFormTemplate implements Produ
'order' => 10,
)
);
// External/Affiliate section.
if ( Features::is_enabled( 'product-external-affiliate' ) ) {
$buy_button_section = $general_group->add_section(
array(
'id' => 'product-buy-button-section',
'order' => 30,
'attributes' => array(
'title' => __( 'Buy button', 'woocommerce' ),
'description' => __( 'Add a link and choose a label for the button linked to a product sold elsewhere.', 'woocommerce' ),
),
'hideConditions' => array(
array(
'expression' => 'editedProduct.type !== "external"',
),
),
)
);
$buy_button_section->add_block(
array(
'id' => 'product-external-url',
'blockName' => 'woocommerce/product-text-field',
'order' => 10,
'attributes' => array(
'property' => 'external_url',
'label' => __( 'Link to the external product', 'woocommerce' ),
'placeholder' => __( 'Enter the external URL to the product', 'woocommerce' ),
'suffix' => true,
'type' => array(
'value' => 'url',
'message' => __( 'Link to the external product is an invalid URL.', 'woocommerce' ),
),
'required' => __( 'Link to the external product is required.', 'woocommerce' ),
),
)
);
$button_text_columns = $buy_button_section->add_block(
array(
'id' => 'product-button-text-columns',
'blockName' => 'core/columns',
'order' => 20,
)
);
$button_text_columns->add_block(
array(
'id' => 'product-button-text-column1',
'blockName' => 'core/column',
'order' => 10,
)
)->add_block(
array(
'id' => 'product-button-text',
'blockName' => 'woocommerce/product-text-field',
'order' => 10,
'attributes' => array(
'property' => 'button_text',
'label' => __( 'Buy button text', 'woocommerce' ),
),
)
);
$button_text_columns->add_block(
array(
'id' => 'product-button-text-column2',
'blockName' => 'core/column',
'order' => 20,
)
);
}
// Images section.
$images_section = $general_group->add_section(
array(
'id' => 'product-images-section',
'order' => 30,
'order' => 40,
'attributes' => array(
'title' => __( 'Images', 'woocommerce' ),
'description' => sprintf(
@ -258,7 +331,7 @@ class SimpleProductTemplate extends AbstractProductFormTemplate implements Produ
$general_group->add_section(
array(
'id' => 'product-downloads-section',
'order' => 40,
'order' => 50,
'attributes' => array(
'title' => __( 'Downloads', 'woocommerce' ),
'description' => __( "Add any files you'd like to make available for the customer to download after purchasing, such as instructions or warranty info.", 'woocommerce' ),