Skip to content

Commit

Permalink
Merge pull request #2178 from woocommerce/add/product-block-editor-cu…
Browse files Browse the repository at this point in the history
…stom-select-with-text-and-date-time

Product Block Editor: Map `SelectWithTextInput` and `DateTime` inputs to the custom product blocks
  • Loading branch information
eason9487 authored Jan 3, 2024
2 parents cb432d9 + 0fb9cf0 commit d50c151
Show file tree
Hide file tree
Showing 17 changed files with 474 additions and 24 deletions.
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

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

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

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

0 comments on commit d50c151

Please sign in to comment.