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

Product Block Editor: Map SelectWithTextInput and DateTime inputs to the custom product blocks #2178

Merged
Show file tree
Hide file tree
Changes from all 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
18 changes: 18 additions & 0 deletions js/src/blocks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,24 @@ Since this extension requires a few custom blocks, and considering these custom
└── blocks.asset.php # The dependencies of blocks.js to be used when registering blocks.js via PHP
```

### Accessing product data

The `useProductEntityProp` hook imported from `@woocommerce/product-editor` offers a convenient way to get and set product data and metadata.

- In block.json, it needs to list `"postType"` in the `usesContext` array to ask for the current post type ('product' or 'product_variation') from the context provider.
- Forwarding the `context.postType` to `useProductEntityProp` hook is required in order to be compatible with both simple and variation product block templates.

#### Derived value for initialization
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❓ Can we elaborate or reword this explanation? Maybe my lack of knowledge of the concepts but not sure if I understand what those "derived values" are. I assume is just the data itself?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the React ecosystem, the derived value/state is commonly referred to the value calculated based on another value. I'm guessing that it probably comes from the wording in the React API getDerivedStateFromProps.

Added in 0965ef9.


The "derived value" refers to the computation of a value based on another state or props in a component. At the time of starting rendering a block, the product data has already been loaded to a data store of `@wordpress/data` in Woo's Product Block Editor, so the value returned from `useProductEntityProp` can be considered as an already fetched data for directly initializing derived values, because they all eventually use the same selector `getEntityRecord` from `@wordpress/core-data` to get product data.

References:

- At [ProductPage](https://github.com/woocommerce/woocommerce/blob/8.3.0/plugins/woocommerce-admin/client/products/product-page.tsx#L77-L79) and [ProductVariationPage](https://github.com/woocommerce/woocommerce/blob/8.3.0/plugins/woocommerce-admin/client/products/product-variation-page.tsx#L83-L85) layers, they won't render the actual blocks before the product data is fetched
- The above product data is obtained from [useProductEntityRecord](https://github.com/woocommerce/woocommerce/blob/8.3.0/plugins/woocommerce-admin/client/products/hooks/use-product-entity-record.ts#L19-L23) or [useProductVariationEntityRecord](https://github.com/woocommerce/woocommerce/blob/8.3.0/plugins/woocommerce-admin/client/products/hooks/use-product-variation-entity-record.ts#L16-L22) hook, and both hooks use the `getEntityRecord` selector.
- This extension obtains product data from [useProductEntityProp](https://github.com/woocommerce/woocommerce/blob/8.3.0/packages/js/product-editor/src/hooks/use-product-entity-prop.ts#L24-L33), which uses the `useEntityProp` hook internally.
- The [useEntityProp](https://github.com/WordPress/gutenberg/blob/wp/6.0/packages/core-data/src/entity-provider.js#L102-L133) hook also uses the `getEntityRecord` selector.

### Infrastructure adjustments

#### block.json and edit.js
Expand Down
9 changes: 9 additions & 0 deletions js/src/blocks/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,12 @@ import { registerProductEditorBlockType } from '@woocommerce/product-editor';
/**
* Internal dependencies
*/
import DateTimeFieldEdit from './product-date-time-field/edit';
import dateTimeFieldMetadata from './product-date-time-field/block.json';
import SelectFieldEdit from './product-select-field/edit';
import selectFieldMetadata from './product-select-field/block.json';
import SelectWithTextFieldEdit from './product-select-with-text-field/edit';
import selectWithTextFieldMetadata from './product-select-with-text-field/block.json';

function registerProductEditorBlock( { name, ...metadata }, Edit ) {
registerProductEditorBlockType( {
Expand All @@ -17,4 +21,9 @@ function registerProductEditorBlock( { name, ...metadata }, Edit ) {
} );
}

registerProductEditorBlock( dateTimeFieldMetadata, DateTimeFieldEdit );
registerProductEditorBlock( selectFieldMetadata, SelectFieldEdit );
registerProductEditorBlock(
selectWithTextFieldMetadata,
SelectWithTextFieldEdit
);
30 changes: 30 additions & 0 deletions js/src/blocks/product-date-time-field/block.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
{
"$schema": "https://schemas.wp.org/trunk/block.json",
"apiVersion": 2,
"name": "google-listings-and-ads/product-date-time-field",
"title": "Product date and time fields",
"textdomain": "google-listings-and-ads",
"attributes": {
"_templateBlockHideConditions": {
"type": "array"
},
"label": {
"type": "string"
},
"tooltip": {
"type": "string"
},
"property": {
"type": "string",
"__experimentalRole": "content"
}
},
"supports": {
"html": false,
"inserter": false,
"lock": false
},
"usesContext": [
"postType"
]
}
126 changes: 126 additions & 0 deletions js/src/blocks/product-date-time-field/edit.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
/**
* External dependencies
*/
import { date as formatDate, getDate } from '@wordpress/date';
import { useWooBlockProps } from '@woocommerce/block-templates';
import { useState, useRef } from '@wordpress/element';
import {
__experimentalUseProductEntityProp as useProductEntityProp,
__experimentalTextControl as TextControl,
useValidation,
} from '@woocommerce/product-editor';
import { Flex, FlexBlock } from '@wordpress/components';

/**
* Internal dependencies
*/
import styles from './editor.module.scss';

/**
* @typedef {import('../types.js').ProductEditorBlockContext} ProductEditorBlockContext
* @typedef {import('../types.js').ProductBasicAttributes} ProductBasicAttributes
*/

async function resolveValidationMessage( inputRef ) {
const input = inputRef.current;

if ( ! input.validity.valid ) {
return input.validationMessage;
}
}
Comment on lines +24 to +30
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need validation for the datetime? I wasn't able to set an invalid date time in the inputs.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't test all browsers, but Chrome and FireFox both allow entering date by keyboard, which may result in an invalid value.

Kapture.2024-01-03.at.11.23.23.mp4

"請輸入有效的日期" and "請輸入有效的時間" mean "Please enter a valid date" and "Please enter a valid time".


/**
* Custom block for editing a given product data with date and time fields.
*
* @param {Object} props React props.
* @param {ProductBasicAttributes} props.attributes
* @param {ProductEditorBlockContext} props.context
*/
export default function Edit( { attributes, context } ) {
const { property } = attributes;
const blockProps = useWooBlockProps( attributes );
const [ value, setValue ] = useProductEntityProp( property, {
postType: context.postType,
fallbackValue: '',
} );
const dateInputRef = useRef( null );
const timeInputRef = useRef( null );

// Refer to the "Derived value for initialization" in README for why these `useState`
// can initialize directly.
const [ date, setDate ] = useState( () =>
value ? formatDate( 'Y-m-d', value ) : ''
);
const [ time, setTime ] = useState( () =>
value ? formatDate( 'H:i', value ) : ''
);

const setNextValue = ( nextDate, nextTime ) => {
let nextValue = '';

// It allows `nextTime` to be an empty string and fall back to '00:00:00'.
if ( nextDate ) {
// The date and time values are strings presented in the site timezone.
// Normalize them to date string in UTC timezone in ISO 8601 format.
const isoDateString = `${ nextDate }T${ nextTime || '00:00:00' }`;
const dateInSiteTimezone = getDate( isoDateString );
nextValue = formatDate( 'c', dateInSiteTimezone, 'UTC' );
}

setDate( nextDate );
setTime( nextTime );

if ( value !== nextValue ) {
setValue( nextValue );
}
};

const dateValidation = useValidation( `${ property }-date`, () =>
resolveValidationMessage( dateInputRef )
);

const timeValidation = useValidation( `${ property }-time`, () =>
resolveValidationMessage( timeInputRef )
);

return (
<div { ...blockProps }>
<Flex align="flex-start">
<FlexBlock>
<TextControl
ref={ dateInputRef }
type="date"
pattern="\d{4}-\d{2}-\d{2}"
label={ attributes.label }
tooltip={ attributes.tooltip }
value={ date }
error={ dateValidation.error }
onChange={ ( nextDate ) =>
setNextValue( nextDate, time )
}
onBlur={ dateValidation.validate }
/>
</FlexBlock>
<FlexBlock>
<TextControl
// The invisible chars in the label and tooltip are to maintain the space between
// the <label> and <input> as the same in the sibling <TextControl>. Also, it uses
// CSS to keep its space but is invisible.
className={ styles.invisibleLabelAndTooltip }
label=" "
tooltip="‎ "
ref={ timeInputRef }
type="time"
pattern="[0-9]{2}:[0-9]{2}"
value={ time }
error={ timeValidation.error }
onChange={ ( nextTime ) =>
setNextValue( date, nextTime )
}
onBlur={ timeValidation.validate }
/>
</FlexBlock>
</Flex>
</div>
);
}
5 changes: 5 additions & 0 deletions js/src/blocks/product-date-time-field/editor.module.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
.invisibleLabelAndTooltip {
label {
visibility: hidden;
}
}
2 changes: 1 addition & 1 deletion js/src/blocks/product-select-field/block.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"$schema": "https://schemas.wp.org/trunk/block.json",
"apiVersion": 2,
"name": "google-listings-and-ads/product-select-field",
"title": "Product select control",
"title": "Product select field",
"textdomain": "google-listings-and-ads",
"attributes": {
"_templateBlockHideConditions": {
Expand Down
19 changes: 19 additions & 0 deletions js/src/blocks/product-select-field/edit.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,25 @@ import { __experimentalUseProductEntityProp as useProductEntityProp } from '@woo
*/
import { Label } from '../components';

/**
* @typedef {import('../types.js').ProductEditorBlockContext} ProductEditorBlockContext
* @typedef {import('../types.js').ProductBasicAttributes} ProductBasicAttributes
*/

/**
* @typedef {Object} SpecificAttributes
* @property {import('@wordpress/components').SelectControl.Option} [options=[]] The options to be shown in the select field.
*
* @typedef {ProductBasicAttributes & SpecificAttributes} ProductSelectFieldAttributes
*/

/**
* Custom block for editing a given product data with a select field.
*
* @param {Object} props React props.
* @param {ProductSelectFieldAttributes} props.attributes
* @param {ProductEditorBlockContext} props.context
*/
export default function Edit( { attributes, context } ) {
const blockProps = useWooBlockProps( attributes );
const [ value, setValue ] = useProductEntityProp( attributes.property, {
Expand Down
40 changes: 40 additions & 0 deletions js/src/blocks/product-select-with-text-field/block.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
{
"$schema": "https://schemas.wp.org/trunk/block.json",
"apiVersion": 2,
"name": "google-listings-and-ads/product-select-with-text-field",
"title": "Product select with text field",
"textdomain": "google-listings-and-ads",
"attributes": {
"_templateBlockHideConditions": {
"type": "array"
},
"label": {
"type": "string"
},
"tooltip": {
"type": "string"
},
"property": {
"type": "string",
"__experimentalRole": "content"
},
"options": {
"type": "array",
"items": {
"type": "object"
},
"default": []
},
"customInputValue": {
"type": "string"
}
},
"supports": {
"html": false,
"inserter": false,
"lock": false
},
"usesContext": [
"postType"
]
}
103 changes: 103 additions & 0 deletions js/src/blocks/product-select-with-text-field/edit.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
/**
* External dependencies
*/
import { useWooBlockProps } from '@woocommerce/block-templates';
import { SelectControl } from '@wordpress/components';
import { useState } from '@wordpress/element';
import {
__experimentalUseProductEntityProp as useProductEntityProp,
__experimentalTextControl as TextControl,
} from '@woocommerce/product-editor';

/**
* Internal dependencies
*/
import { Label } from '../components';

/**
* @typedef {import('../types.js').ProductEditorBlockContext} ProductEditorBlockContext
* @typedef {import('../types.js').ProductBasicAttributes} ProductBasicAttributes
*/

/**
* @typedef {Object} SpecificAttributes
* @property {string} customInputValue The value to be used in the selection option that shows the text field.
* @property {import('@wordpress/components').SelectControl.Option} [options=[]] The options to be shown in the select field.
*
* @typedef {ProductBasicAttributes & SpecificAttributes} ProductSelectWithTextFieldAttributes
*/

const FALLBACK_VALUE = '';

/**
* Custom block for editing a given product data with a select field or a text field.
* The text field is used to enter a custom value and is shown when selecting the option
* that has the same value as the given `customInputValue`.
*
* @param {Object} props React props.
* @param {ProductSelectWithTextFieldAttributes} props.attributes
* @param {ProductEditorBlockContext} props.context
*/
export default function Edit( { attributes, context } ) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💅 Can we add a bit of documentation of what customInputValue attribute is for?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added JSDoc for all custom blocks by d8252b8.

const { options, customInputValue } = attributes;

const blockProps = useWooBlockProps( attributes );
const [ value, setValue ] = useProductEntityProp( attributes.property, {
postType: context.postType,
fallbackValue: FALLBACK_VALUE,
} );

// Refer to the "Derived value for initialization" in README for why this `useState`
// can initialize directly.
const [ optionValue, setOptionValue ] = useState( () => {
// Empty string is a valid option value.
const initValue = value ?? FALLBACK_VALUE;
const selectedOption = options.find(
( option ) => option.value === initValue
);

return selectedOption?.value ?? customInputValue;
} );

const isSelectedCustomInput = optionValue === customInputValue;

const [ text, setText ] = useState( isSelectedCustomInput ? value : '' );

const handleSelectionChange = ( nextOptionValue ) => {
setOptionValue( nextOptionValue );

if ( nextOptionValue === customInputValue ) {
setValue( text );
} else {
setValue( nextOptionValue );
}
Comment on lines +69 to +73
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💅 This can be reduced to:

setValue( nextOptionValue === customInputValue ? text : nextOptionValue );

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I lead toward keeping it as it is.

};

const handleTextChange = ( nextText ) => {
setText( nextText );
setValue( nextText );
};

return (
<div { ...blockProps }>
<SelectControl
label={
<Label
label={ attributes.label }
tooltip={ attributes.tooltip }
/>
}
options={ options }
value={ optionValue }
onChange={ handleSelectionChange }
/>
{ isSelectedCustomInput && (
<TextControl
type="text"
value={ text }
onChange={ handleTextChange }
/>
) }
</div>
);
}
Loading