[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

@ -20,7 +20,6 @@ Label that appears on top of the field.
Property in which the value is stored. Property in which the value is stored.
### help ### help
- **Type:** `String` - **Type:** `String`
@ -30,10 +29,11 @@ Help text that appears below the field.
### required ### required
- **Type:** `Boolean` - **Type:** `Boolean`|`String`
- **Required:** `No` - **Required:** `No`
Indicates and enforces that the field is required. Indicates and enforces that the field is required.
If the value is string it will be used as the custom error message.
### tooltip ### tooltip
@ -49,25 +49,130 @@ If provided, shows a tooltip next to the label with additional information.
Placeholder text that appears in the field when it's empty. 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 ## Usage
Here's a snippet that adds a field similar to the previous screenshot: Here's a snippet that adds a field similar to the previous screenshot:
```php ```php
$section->add_block( $section->add_block(
[ array(
'id' => 'example-text-meta', 'id' => 'example-text-meta',
'blockName' => 'woocommerce/product-text-field', 'blockName' => 'woocommerce/product-text-field',
'order' => 13, 'order' => 13,
'attributes' => [ 'attributes' => array(
'label' => 'Text', 'label' => 'Text',
'property' => 'meta_data.text', 'property' => 'meta_data.text',
'placeholder' => 'Placeholder', 'placeholder' => 'Placeholder',
'required' => true, 'required' => true,
'help' => 'Add additional information here', 'help' => 'Add additional information here',
'tooltip' => 'My tooltip' '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", "title": "Product text field",
"category": "woocommerce", "category": "woocommerce",
"description": "A text field for use in the product editor.", "description": "A text field for use in the product editor.",
"keywords": [ "keywords": [ "products", "text" ],
"products",
"text"
],
"textdomain": "default", "textdomain": "default",
"attributes": { "attributes": {
"label": { "label": {
@ -27,21 +24,29 @@
"tooltip": { "tooltip": {
"type": "string" "type": "string"
}, },
"suffix": {
"type": "object"
},
"type": {
"type": "object"
},
"required": { "required": {
"type": "boolean", "type": "object"
"default": false
}, },
"validationRegex": { "pattern": {
"type": "string" "type": "object"
},
"validationErrorMessage": {
"type": "string"
}, },
"minLength": { "minLength": {
"type": "number" "type": "object"
}, },
"maxLength": { "maxLength": {
"type": "number" "type": "object"
},
"min": {
"type": "object"
},
"max": {
"type": "object"
} }
}, },
"supports": { "supports": {

View File

@ -1,92 +1,167 @@
/** /**
* External dependencies * External dependencies
*/ */
import { createElement } from '@wordpress/element';
import { __, sprintf } from '@wordpress/i18n';
import { Product } from '@woocommerce/data';
import { useWooBlockProps } from '@woocommerce/block-templates'; 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 * 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 { TextControl } from '../../../components/text-control';
import { useValidation } from '../../../contexts/validation-context';
import { useProductEdits } from '../../../hooks/use-product-edits'; 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( { export function Edit( {
attributes, attributes,
context: { postType }, context: { postType },
}: ProductEditorBlockEditProps< TextBlockAttributes > ) { }: ProductEditorBlockEditProps< TextBlockAttributes > ) {
const blockProps = useWooBlockProps( attributes ); const blockProps = useWooBlockProps( attributes );
const { const {
property, property,
label, label,
placeholder, placeholder,
required, required,
validationRegex, pattern,
validationErrorMessage,
minLength, minLength,
maxLength, maxLength,
min,
max,
help, help,
tooltip, tooltip,
disabled, disabled,
type,
suffix,
} = attributes; } = attributes;
const [ value, setValue ] = useProductEntityProp< string >( property, { const [ value, setValue ] = useProductEntityProp< string >( property, {
postType, postType,
fallbackValue: '', fallbackValue: '',
} ); } );
const { hasEdit } = useProductEdits(); const { hasEdit } = useProductEdits();
const inputRef = useRef< HTMLInputElement >( null );
const { error, validate } = useValidation< Product >( const { error, validate } = useValidation< Product >(
property, property,
async function validator() { async function validator() {
if ( typeof value !== 'string' ) { if ( ! inputRef.current ) return;
return __(
'Unexpected property type assigned to field.', const input = inputRef.current;
'woocommerce'
); let customErrorMessage = '';
if ( input.validity.typeMismatch ) {
customErrorMessage =
type?.message ??
__( 'Invalid value for the field.', 'woocommerce' );
} }
if ( required && ! value ) { if ( input.validity.valueMissing ) {
return __( 'This field is required.', 'woocommerce' ); customErrorMessage =
typeof required === 'string'
? required
: __( 'This field is required.', 'woocommerce' );
} }
if ( validationRegex ) { if ( input.validity.patternMismatch ) {
const regExp = new RegExp( validationRegex ); customErrorMessage =
if ( ! regExp.test( value ) ) { pattern?.message ??
return ( __( 'Invalid value for the field.', 'woocommerce' );
validationErrorMessage ||
__( 'Invalid value for the field.', 'woocommerce' )
);
} }
} if ( input.validity.tooShort ) {
if ( typeof minLength === 'number' && value.length < minLength ) { // eslint-disable-next-line @wordpress/valid-sprintf
return sprintf( customErrorMessage = sprintf(
minLength?.message ??
/* translators: %d: minimum length */ /* translators: %d: minimum length */
__( __(
'The minimum length of the field is %d', 'The minimum length of the field is %d',
'woocommerce' 'woocommerce'
), ),
minLength minLength?.value
); );
} }
if ( typeof maxLength === 'number' && value.length > maxLength ) { if ( input.validity.tooLong ) {
return sprintf( // eslint-disable-next-line @wordpress/valid-sprintf
customErrorMessage = sprintf(
maxLength?.message ??
/* translators: %d: maximum length */ /* translators: %d: maximum length */
__( __(
'The maximum length of the field is %d', 'The maximum length of the field is %d',
'woocommerce' 'woocommerce'
), ),
maxLength maxLength?.value
); );
} }
}, if ( input.validity.rangeUnderflow ) {
[ value ] // 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;
}
},
[ 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 ( return (
<div { ...blockProps }> <div { ...blockProps }>
<TextControl <TextControl
ref={ inputRef }
type={ type?.value ?? 'text' }
value={ value } value={ value }
disabled={ disabled } disabled={ disabled }
label={ label } label={ label }
@ -99,8 +174,14 @@ export function Edit( {
error={ error } error={ error }
help={ help } help={ help }
placeholder={ placeholder } placeholder={ placeholder }
required={ required }
tooltip={ tooltip } tooltip={ tooltip }
suffix={ getSuffix() }
required={ Boolean( required ) }
pattern={ pattern?.value }
minLength={ minLength?.value }
maxLength={ maxLength?.value }
min={ min?.value }
max={ max?.value }
/> />
</div> </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 * External dependencies
*/ */
import type { HTMLInputTypeAttribute } from 'react';
import type { BlockAttributes } from '@wordpress/blocks'; import type { BlockAttributes } from '@wordpress/blocks';
export interface TextBlockAttributes extends BlockAttributes { export interface TextBlockAttributes extends BlockAttributes {
@ -9,9 +10,12 @@ export interface TextBlockAttributes extends BlockAttributes {
help?: string; help?: string;
tooltip?: string; tooltip?: string;
placeholder?: string; placeholder?: string;
required?: boolean; suffix?: boolean | string;
validationRegex?: string; required?: boolean | string;
validationErrorMessage?: string; type?: { value?: HTMLInputTypeAttribute; message?: string };
minLength?: number; pattern?: { value: string; message?: string };
maxLength?: number; 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; 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 { .has-error {
.components-base-control { .components-base-control {
margin-bottom: 0; margin-bottom: 0;

View File

@ -46,3 +46,8 @@ export { NumberControl as __experimentalNumberControl } from './number-control';
export * from './product-page-skeleton'; export * from './product-page-skeleton';
export * from './modal-editor-welcome-guide'; 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 './text-control';
export * from './types';

View File

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

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.status === 'private' ||
! variation.regular_price ) && ( ! variation.regular_price ) && (
<Tooltip <Tooltip
// @ts-expect-error className is missing in TS, should remove this when it is included.
className="woocommerce-attribute-list-item__actions-tooltip" className="woocommerce-attribute-list-item__actions-tooltip"
position="top center" position="top center"
text={ NOT_VISIBLE_TEXT } 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' ); 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 ); $this->redirection_controller = new RedirectionController( $this->supported_post_types );
if ( \Automattic\WooCommerce\Utilities\FeaturesUtil::feature_is_enabled( 'product_block_editor' ) ) { 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, '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.
$images_section = $general_group->add_section( $images_section = $general_group->add_section(
array( array(
'id' => 'product-images-section', 'id' => 'product-images-section',
'order' => 30, 'order' => 40,
'attributes' => array( 'attributes' => array(
'title' => __( 'Images', 'woocommerce' ), 'title' => __( 'Images', 'woocommerce' ),
'description' => sprintf( 'description' => sprintf(
@ -258,7 +331,7 @@ class SimpleProductTemplate extends AbstractProductFormTemplate implements Produ
$general_group->add_section( $general_group->add_section(
array( array(
'id' => 'product-downloads-section', 'id' => 'product-downloads-section',
'order' => 40, 'order' => 50,
'attributes' => array( 'attributes' => array(
'title' => __( 'Downloads', 'woocommerce' ), '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' ), 'description' => __( "Add any files you'd like to make available for the customer to download after purchasing, such as instructions or warranty info.", 'woocommerce' ),