Merged in feature/from-pantheon (pull request #16)

code from pantheon

* code from pantheon
This commit is contained in:
Tony Volpe
2024-01-10 17:03:02 +00:00
parent 054b4fffc9
commit 4eb982d7a8
16492 changed files with 3475854 additions and 0 deletions

View File

@@ -0,0 +1,11 @@
export { default as withAttributes } from './with-attributes';
export { default as withCategories } from './with-categories';
export { default as withCategory } from './with-category';
export { default as withProduct } from './with-product';
export { default as withProductVariations } from './with-product-variations';
export { default as withSearchedProducts } from './with-searched-products';
export { default as withTransformSingleSelectToMultipleSelect } from './with-transform-single-select-to-multiple-select';
export {
SelectedOption,
WithMaybeSelectedOption,
} from './with-transform-single-select-to-multiple-select';

View File

@@ -0,0 +1,107 @@
// We need to disable the following eslint check as it's only applicable
// to testing-library/react not `react-test-renderer` used here
/* eslint-disable testing-library/await-async-query */
/**
* External dependencies
*/
import TestRenderer from 'react-test-renderer';
import * as mockUtils from '@woocommerce/editor-components/utils';
/**
* Internal dependencies
*/
import withCategories from '../with-categories';
import * as mockBaseUtils from '../../base/utils/errors';
jest.mock( '@woocommerce/editor-components/utils', () => ( {
getCategories: jest.fn(),
} ) );
jest.mock( '../../base/utils/errors', () => ( {
formatError: jest.fn(),
} ) );
const mockCategories = [
{ id: 1, name: 'Clothing' },
{ id: 2, name: 'Food' },
];
const TestComponent = withCategories( ( props ) => {
return (
<div
data-error={ props.error }
data-isLoading={ props.isLoading }
data-categories={ props.categories }
/>
);
} );
const render = () => {
return TestRenderer.create( <TestComponent /> );
};
describe( 'withCategories Component', () => {
let renderer;
afterEach( () => {
mockUtils.getCategories.mockReset();
} );
describe( 'lifecycle events', () => {
beforeEach( () => {
mockUtils.getCategories.mockImplementation( () =>
Promise.resolve()
);
renderer = render();
} );
it( 'getCategories is called on mount', () => {
const { getCategories } = mockUtils;
expect( getCategories ).toHaveBeenCalledTimes( 1 );
} );
} );
describe( 'when the API returns categories data', () => {
beforeEach( () => {
mockUtils.getCategories.mockImplementation( () =>
Promise.resolve( mockCategories )
);
renderer = render();
} );
it( 'sets the categories props', () => {
const props = renderer.root.findByType( 'div' ).props;
expect( props[ 'data-error' ] ).toBeNull();
expect( props[ 'data-isLoading' ] ).toBe( false );
expect( props[ 'data-categories' ] ).toEqual( mockCategories );
} );
} );
describe( 'when the API returns an error', () => {
const error = { message: 'There was an error.' };
const getCategoriesPromise = Promise.reject( error );
const formattedError = { message: 'There was an error.', type: 'api' };
beforeEach( () => {
mockUtils.getCategories.mockImplementation(
() => getCategoriesPromise
);
mockBaseUtils.formatError.mockImplementation(
() => formattedError
);
renderer = render();
} );
test( 'sets the error prop', async () => {
await expect( () => getCategoriesPromise() ).toThrow();
const { formatError } = mockBaseUtils;
const props = renderer.root.findByType( 'div' ).props;
expect( formatError ).toHaveBeenCalledWith( error );
expect( formatError ).toHaveBeenCalledTimes( 1 );
expect( props[ 'data-error' ] ).toEqual( formattedError );
expect( props[ 'data-isLoading' ] ).toBe( false );
expect( props[ 'data-categories' ] ).toEqual( [] );
} );
} );
} );

View File

@@ -0,0 +1,131 @@
// We need to disable the following eslint check as it's only applicable
// to testing-library/react not `react-test-renderer` used here
/* eslint-disable testing-library/await-async-query */
/**
* External dependencies
*/
import TestRenderer from 'react-test-renderer';
import * as mockUtils from '@woocommerce/editor-components/utils';
/**
* Internal dependencies
*/
import withCategory from '../with-category';
import * as mockBaseUtils from '../../base/utils/errors';
jest.mock( '@woocommerce/editor-components/utils', () => ( {
getCategory: jest.fn(),
} ) );
jest.mock( '../../base/utils/errors', () => ( {
formatError: jest.fn(),
} ) );
const mockCategory = { name: 'Clothing' };
const attributes = { categoryId: 1 };
const TestComponent = withCategory( ( props ) => {
return (
<div
data-error={ props.error }
data-getCategory={ props.getCategory }
data-isLoading={ props.isLoading }
data-category={ props.category }
/>
);
} );
const render = () => {
return TestRenderer.create( <TestComponent attributes={ attributes } /> );
};
describe( 'withCategory Component', () => {
let renderer;
afterEach( () => {
mockUtils.getCategory.mockReset();
} );
describe( 'lifecycle events', () => {
beforeEach( () => {
mockUtils.getCategory.mockImplementation( () => Promise.resolve() );
renderer = render();
} );
it( 'getCategory is called on mount with passed in category id', () => {
const { getCategory } = mockUtils;
expect( getCategory ).toHaveBeenCalledWith( attributes.categoryId );
expect( getCategory ).toHaveBeenCalledTimes( 1 );
} );
it( 'getCategory is called on component update', () => {
const { getCategory } = mockUtils;
const newAttributes = { ...attributes, categoryId: 2 };
renderer.update( <TestComponent attributes={ newAttributes } /> );
expect( getCategory ).toHaveBeenNthCalledWith(
2,
newAttributes.categoryId
);
expect( getCategory ).toHaveBeenCalledTimes( 2 );
} );
it( 'getCategory is hooked to the prop', () => {
const { getCategory } = mockUtils;
const props = renderer.root.findByType( 'div' ).props;
props[ 'data-getCategory' ]();
expect( getCategory ).toHaveBeenCalledTimes( 2 );
} );
} );
describe( 'when the API returns category data', () => {
beforeEach( () => {
mockUtils.getCategory.mockImplementation( ( categoryId ) =>
Promise.resolve( { ...mockCategory, id: categoryId } )
);
renderer = render();
} );
it( 'sets the category props', () => {
const props = renderer.root.findByType( 'div' ).props;
expect( props[ 'data-error' ] ).toBeNull();
expect( typeof props[ 'data-getCategory' ] ).toBe( 'function' );
expect( props[ 'data-isLoading' ] ).toBe( false );
expect( props[ 'data-category' ] ).toEqual( {
...mockCategory,
id: attributes.categoryId,
} );
} );
} );
describe( 'when the API returns an error', () => {
const error = { message: 'There was an error.' };
const getCategoryPromise = Promise.reject( error );
const formattedError = { message: 'There was an error.', type: 'api' };
beforeEach( () => {
mockUtils.getCategory.mockImplementation(
() => getCategoryPromise
);
mockBaseUtils.formatError.mockImplementation(
() => formattedError
);
renderer = render();
} );
test( 'sets the error prop', async () => {
await expect( () => getCategoryPromise() ).toThrow();
const { formatError } = mockBaseUtils;
const props = renderer.root.findByType( 'div' ).props;
expect( formatError ).toHaveBeenCalledWith( error );
expect( formatError ).toHaveBeenCalledTimes( 1 );
expect( props[ 'data-error' ] ).toEqual( formattedError );
expect( typeof props[ 'data-getCategory' ] ).toBe( 'function' );
expect( props[ 'data-isLoading' ] ).toBe( false );
expect( props[ 'data-category' ] ).toBeNull();
} );
} );
} );

View File

@@ -0,0 +1,185 @@
// We need to disable the following eslint check as it's only applicable
// to testing-library/react not `react-test-renderer` used here
/* eslint-disable testing-library/await-async-query */
/**
* External dependencies
*/
import TestRenderer from 'react-test-renderer';
import * as mockUtils from '@woocommerce/editor-components/utils';
/**
* Internal dependencies
*/
import withProductVariations from '../with-product-variations';
import * as mockBaseUtils from '../../base/utils/errors';
jest.mock( '@woocommerce/editor-components/utils', () => ( {
getProductVariations: jest.fn(),
} ) );
jest.mock( '../../base/utils/errors', () => ( {
formatError: jest.fn(),
} ) );
const mockProducts = [
{ id: 1, name: 'Hoodie', variations: [ { id: 3 }, { id: 4 } ] },
{ id: 2, name: 'Backpack' },
];
const mockVariations = [
{ id: 3, name: 'Blue' },
{ id: 4, name: 'Red' },
];
const TestComponent = withProductVariations( ( props ) => {
return (
<div
data-error={ props.error }
data-expandedProduct={ props.expandedProduct }
data-isLoading={ props.isLoading }
data-variations={ props.variations }
data-variationsLoading={ props.variationsLoading }
/>
);
} );
const render = () => {
return TestRenderer.create(
<TestComponent
error={ null }
isLoading={ false }
products={ mockProducts }
selected={ [ 1 ] }
showVariations={ true }
/>
);
};
describe( 'withProductVariations Component', () => {
let renderer;
afterEach( () => {
mockUtils.getProductVariations.mockReset();
} );
describe( 'lifecycle events', () => {
beforeEach( () => {
mockUtils.getProductVariations.mockImplementation( () =>
Promise.resolve( mockVariations )
);
} );
it( 'getProductVariations is called on mount', () => {
renderer = render();
const { getProductVariations } = mockUtils;
expect( getProductVariations ).toHaveBeenCalledWith( 1 );
expect( getProductVariations ).toHaveBeenCalledTimes( 1 );
} );
it( 'getProductVariations is called on component update', () => {
renderer = TestRenderer.create(
<TestComponent
error={ null }
isLoading={ false }
products={ mockProducts }
/>
);
const { getProductVariations } = mockUtils;
expect( getProductVariations ).toHaveBeenCalledTimes( 0 );
renderer.update(
<TestComponent
error={ null }
isLoading={ false }
products={ mockProducts }
selected={ [ 1 ] }
showVariations={ true }
/>
);
expect( getProductVariations ).toHaveBeenCalledWith( 1 );
expect( getProductVariations ).toHaveBeenCalledTimes( 1 );
} );
it( 'getProductVariations is not called if selected product has no variations', () => {
TestRenderer.create(
<TestComponent
error={ null }
isLoading={ false }
products={ mockProducts }
selected={ [ 2 ] }
showVariations={ true }
/>
);
const { getProductVariations } = mockUtils;
expect( getProductVariations ).toHaveBeenCalledTimes( 0 );
} );
it( 'getProductVariations is called if selected product is a variation', () => {
TestRenderer.create(
<TestComponent
error={ null }
isLoading={ false }
products={ mockProducts }
selected={ [ 3 ] }
showVariations={ true }
/>
);
const { getProductVariations } = mockUtils;
expect( getProductVariations ).toHaveBeenCalledWith( 1 );
expect( getProductVariations ).toHaveBeenCalledTimes( 1 );
} );
} );
describe( 'when the API returns variations data', () => {
beforeEach( () => {
mockUtils.getProductVariations.mockImplementation( () =>
Promise.resolve( mockVariations )
);
renderer = render();
} );
it( 'sets the variations props', () => {
const props = renderer.root.findByType( 'div' ).props;
const expectedVariations = {
1: [
{ id: 3, name: 'Blue', parent: 1 },
{ id: 4, name: 'Red', parent: 1 },
],
};
expect( props[ 'data-error' ] ).toBeNull();
expect( props[ 'data-isLoading' ] ).toBe( false );
expect( props[ 'data-variations' ] ).toEqual( expectedVariations );
} );
} );
describe( 'when the API returns an error', () => {
const error = { message: 'There was an error.' };
const getProductVariationsPromise = Promise.reject( error );
const formattedError = { message: 'There was an error.', type: 'api' };
beforeEach( () => {
mockUtils.getProductVariations.mockImplementation(
() => getProductVariationsPromise
);
mockBaseUtils.formatError.mockImplementation(
() => formattedError
);
renderer = render();
} );
test( 'sets the error prop', async () => {
await expect( () => getProductVariationsPromise() ).toThrow();
const { formatError } = mockBaseUtils;
const props = renderer.root.findByType( 'div' ).props;
expect( formatError ).toHaveBeenCalledWith( error );
expect( formatError ).toHaveBeenCalledTimes( 1 );
expect( props[ 'data-error' ] ).toEqual( formattedError );
expect( props[ 'data-isLoading' ] ).toBe( false );
expect( props[ 'data-variations' ] ).toEqual( { 1: null } );
} );
} );
} );

View File

@@ -0,0 +1,129 @@
// We need to disable the following eslint check as it's only applicable
// to testing-library/react not `react-test-renderer` used here
/* eslint-disable testing-library/await-async-query */
/**
* External dependencies
*/
import TestRenderer from 'react-test-renderer';
import * as mockUtils from '@woocommerce/editor-components/utils';
/**
* Internal dependencies
*/
import withProduct from '../with-product';
import * as mockBaseUtils from '../../base/utils/errors';
jest.mock( '@woocommerce/editor-components/utils', () => ( {
getProduct: jest.fn(),
} ) );
jest.mock( '../../base/utils/errors', () => ( {
formatError: jest.fn(),
} ) );
const mockProduct = { name: 'T-Shirt' };
const attributes = { productId: 1 };
const TestComponent = withProduct( ( props ) => {
return (
<div
data-error={ props.error }
data-getProduct={ props.getProduct }
data-isLoading={ props.isLoading }
data-product={ props.product }
/>
);
} );
const render = () => {
return TestRenderer.create( <TestComponent attributes={ attributes } /> );
};
describe( 'withProduct Component', () => {
let renderer;
afterEach( () => {
mockUtils.getProduct.mockReset();
} );
describe( 'lifecycle events', () => {
beforeEach( () => {
mockUtils.getProduct.mockImplementation( () => Promise.resolve() );
renderer = render();
} );
it( 'getProduct is called on mount with passed in product id', () => {
const { getProduct } = mockUtils;
expect( getProduct ).toHaveBeenCalledWith( attributes.productId );
expect( getProduct ).toHaveBeenCalledTimes( 1 );
} );
it( 'getProduct is called on component update', () => {
const { getProduct } = mockUtils;
const newAttributes = { ...attributes, productId: 2 };
renderer.update( <TestComponent attributes={ newAttributes } /> );
expect( getProduct ).toHaveBeenNthCalledWith(
2,
newAttributes.productId
);
expect( getProduct ).toHaveBeenCalledTimes( 2 );
} );
it( 'getProduct is hooked to the prop', () => {
const { getProduct } = mockUtils;
const props = renderer.root.findByType( 'div' ).props;
props[ 'data-getProduct' ]();
expect( getProduct ).toHaveBeenCalledTimes( 2 );
} );
} );
describe( 'when the API returns product data', () => {
beforeEach( () => {
mockUtils.getProduct.mockImplementation( ( productId ) =>
Promise.resolve( { ...mockProduct, id: productId } )
);
renderer = render();
} );
it( 'sets the product props', () => {
const props = renderer.root.findByType( 'div' ).props;
expect( props[ 'data-error' ] ).toBeNull();
expect( typeof props[ 'data-getProduct' ] ).toBe( 'function' );
expect( props[ 'data-isLoading' ] ).toBe( false );
expect( props[ 'data-product' ] ).toEqual( {
...mockProduct,
id: attributes.productId,
} );
} );
} );
describe( 'when the API returns an error', () => {
const error = { message: 'There was an error.' };
const getProductPromise = Promise.reject( error );
const formattedError = { message: 'There was an error.', type: 'api' };
beforeEach( () => {
mockUtils.getProduct.mockImplementation( () => getProductPromise );
mockBaseUtils.formatError.mockImplementation(
() => formattedError
);
renderer = render();
} );
test( 'sets the error prop', async () => {
await expect( () => getProductPromise() ).toThrow();
const { formatError } = mockBaseUtils;
const props = renderer.root.findByType( 'div' ).props;
expect( formatError ).toHaveBeenCalledWith( error );
expect( formatError ).toHaveBeenCalledTimes( 1 );
expect( props[ 'data-error' ] ).toEqual( formattedError );
expect( typeof props[ 'data-getProduct' ] ).toBe( 'function' );
expect( props[ 'data-isLoading' ] ).toBe( false );
expect( props[ 'data-product' ] ).toBeNull();
} );
} );
} );

View File

@@ -0,0 +1,90 @@
// We need to disable the following eslint check as it's only applicable
// to testing-library/react not `react-test-renderer` used here
/* eslint-disable testing-library/await-async-query */
/**
* External dependencies
*/
import TestRenderer, { act } from 'react-test-renderer';
import * as mockUtils from '@woocommerce/editor-components/utils';
import { useDebouncedCallback } from 'use-debounce';
/**
* Internal dependencies
*/
import withSearchedProducts from '../with-searched-products';
// Add a mock implementation of debounce for testing so we can spy on the onSearch call.
jest.mock( 'use-debounce', () => {
return {
useDebouncedCallback: jest
.fn()
.mockImplementation(
( search ) => () => mockUtils.getProducts( search )
),
};
} );
jest.mock( '@woocommerce/block-settings', () => ( {
__esModule: true,
blocksConfig: {
productCount: 101,
},
} ) );
// Mock the getProducts values for tests.
mockUtils.getProducts = jest.fn().mockImplementation( () =>
Promise.resolve( [
{ id: 10, name: 'foo', parent: 0 },
{ id: 20, name: 'bar', parent: 0 },
] )
);
describe( 'withSearchedProducts Component', () => {
const { getProducts } = mockUtils;
afterEach( () => {
useDebouncedCallback.mockClear();
mockUtils.getProducts.mockClear();
} );
const TestComponent = withSearchedProducts(
( { selected, products, isLoading, onSearch } ) => {
return (
<div
data-products={ products }
data-selected={ selected }
data-isLoading={ isLoading }
data-onSearch={ onSearch }
/>
);
}
);
describe( 'lifecycle tests', () => {
const selected = [ 10 ];
let props, renderer;
act( () => {
renderer = TestRenderer.create(
<TestComponent selected={ selected } />
);
} );
it( 'has expected values for props', () => {
props = renderer.root.findByType( 'div' ).props;
expect( props[ 'data-selected' ] ).toEqual( selected );
expect( props[ 'data-products' ] ).toEqual( [
{ id: 10, name: 'foo', parent: 0 },
{ id: 20, name: 'bar', parent: 0 },
] );
} );
it( 'debounce and getProducts is called on search event', async () => {
props = renderer.root.findByType( 'div' ).props;
act( () => {
props[ 'data-onSearch' ]();
} );
expect( useDebouncedCallback ).toHaveBeenCalled();
expect( getProducts ).toHaveBeenCalledTimes( 1 );
} );
} );
} );

View File

@@ -0,0 +1,37 @@
// We need to disable the following eslint check as it's only applicable
// to testing-library/react not `react-test-renderer` used here
/* eslint-disable testing-library/await-async-query */
/**
* External dependencies
*/
import TestRenderer from 'react-test-renderer';
/**
* Internal dependencies
*/
import withTransformSingleSelectToMultipleSelect from '../with-transform-single-select-to-multiple-select';
const TestComponent = withTransformSingleSelectToMultipleSelect( ( props ) => {
return <div selected={ props.selected } />;
} );
describe( 'withTransformSingleSelectToMultipleSelect Component', () => {
describe( 'when the API returns an error', () => {
it( 'converts the selected value into an array', () => {
const selected = 123;
const renderer = TestRenderer.create(
<TestComponent selected={ selected } />
);
const props = renderer.root.findByType( 'div' ).props;
expect( props.selected ).toEqual( [ selected ] );
} );
it( 'passes an empty array as the selected prop if selected was null', () => {
const renderer = TestRenderer.create(
<TestComponent selected={ null } />
);
const props = renderer.root.findByType( 'div' ).props;
expect( props.selected ).toEqual( [] );
} );
} );
} );

View File

@@ -0,0 +1,123 @@
/**
* External dependencies
*/
import { useState, useEffect } from '@wordpress/element';
import { getAttributes, getTerms } from '@woocommerce/editor-components/utils';
/**
* Internal dependencies
*/
import { formatError } from '../base/utils/errors';
/**
* Get attribute data (name, taxonomy etc) from server data.
*
* @param {number} attributeId Attribute ID to look for.
* @param {Array|null} attributeList List of attributes.
* @param {string} matchField Field to match on. e.g. id or slug.
*/
const getAttributeData = ( attributeId, attributeList, matchField = 'id' ) => {
return Array.isArray( attributeList )
? attributeList.find( ( attr ) => attr[ matchField ] === attributeId )
: null;
};
/**
* HOC that calls the useAttributes hook.
*
* @param {Function} OriginalComponent Component being wrapped.
*/
const withAttributes = ( OriginalComponent ) => {
return ( props ) => {
const { selected = [] } = props;
const selectedSlug = selected.length ? selected[ 0 ].attr_slug : null;
const [ attributes, setAttributes ] = useState( null );
const [ expandedAttribute, setExpandedAttribute ] = useState( 0 );
const [ termsList, setTermsList ] = useState( {} );
const [ loading, setLoading ] = useState( true );
const [ termsLoading, setTermsLoading ] = useState( false );
const [ error, setError ] = useState( null );
useEffect( () => {
if ( attributes === null ) {
getAttributes()
.then( ( newAttributes ) => {
newAttributes = newAttributes.map( ( attribute ) => ( {
...attribute,
parent: 0,
} ) );
setAttributes( newAttributes );
if ( selectedSlug ) {
const selectedAttributeFromTerm = getAttributeData(
selectedSlug,
newAttributes,
'taxonomy'
);
if ( selectedAttributeFromTerm ) {
setExpandedAttribute(
selectedAttributeFromTerm.id
);
}
}
} )
.catch( async ( e ) => {
setError( await formatError( e ) );
} )
.finally( () => {
setLoading( false );
} );
}
}, [ attributes, selectedSlug ] );
useEffect( () => {
const attributeData = getAttributeData(
expandedAttribute,
attributes
);
if ( ! attributeData ) {
return;
}
setTermsLoading( true );
getTerms( expandedAttribute )
.then( ( newTerms ) => {
newTerms = newTerms.map( ( term ) => ( {
...term,
parent: expandedAttribute,
attr_slug: attributeData.taxonomy,
} ) );
setTermsList( ( previousTermsList ) => ( {
...previousTermsList,
[ expandedAttribute ]: newTerms,
} ) );
} )
.catch( async ( e ) => {
setError( await formatError( e ) );
} )
.finally( () => {
setTermsLoading( false );
} );
}, [ expandedAttribute, attributes ] );
return (
<OriginalComponent
{ ...props }
attributes={ attributes || [] }
error={ error }
expandedAttribute={ expandedAttribute }
onExpandAttribute={ setExpandedAttribute }
isLoading={ loading }
termsAreLoading={ termsLoading }
termsList={ termsList }
/>
);
};
};
export default withAttributes;

View File

@@ -0,0 +1,71 @@
/**
* External dependencies
*/
import { Component } from '@wordpress/element';
import { createHigherOrderComponent } from '@wordpress/compose';
import { getCategories } from '@woocommerce/editor-components/utils';
/**
* Internal dependencies
*/
import { formatError } from '../base/utils/errors';
/**
* HOC that queries categories for a component.
*
* @param {Function} OriginalComponent Component being wrapped.
*/
const withCategories = createHigherOrderComponent( ( OriginalComponent ) => {
return class WrappedComponent extends Component {
constructor() {
super( ...arguments );
this.state = {
error: null,
loading: false,
categories: [],
};
this.loadCategories = this.loadCategories.bind( this );
}
componentDidMount() {
this.loadCategories();
}
loadCategories() {
this.setState( { loading: true } );
getCategories()
.then( ( categories ) => {
this.setState( {
categories,
loading: false,
error: null,
} );
} )
.catch( async ( e ) => {
const error = await formatError( e );
this.setState( {
categories: [],
loading: false,
error,
} );
} );
}
render() {
const { error, loading, categories } = this.state;
return (
<OriginalComponent
{ ...this.props }
error={ error }
isLoading={ loading }
categories={ categories }
/>
);
}
};
}, 'withCategories' );
export default withCategories;

View File

@@ -0,0 +1,91 @@
/**
* External dependencies
*/
import { Component } from '@wordpress/element';
import { createHigherOrderComponent } from '@wordpress/compose';
import { getCategory } from '@woocommerce/editor-components/utils';
/**
* Internal dependencies
*/
import { formatError } from '../base/utils/errors';
/**
* HOC that queries a category for a component.
*
* @param {Function} OriginalComponent Component being wrapped.
*/
const withCategory = createHigherOrderComponent( ( OriginalComponent ) => {
return class WrappedComponent extends Component {
constructor() {
super( ...arguments );
this.state = {
error: null,
loading: false,
category:
this.props.attributes.categoryId === 'preview'
? this.props.attributes.previewCategory
: null,
};
this.loadCategory = this.loadCategory.bind( this );
}
componentDidMount() {
this.loadCategory();
}
componentDidUpdate( prevProps ) {
if (
prevProps.attributes.categoryId !==
this.props.attributes.categoryId
) {
this.loadCategory();
}
}
loadCategory() {
const { categoryId } = this.props.attributes;
if ( categoryId === 'preview' ) {
return;
}
if ( ! categoryId ) {
this.setState( {
category: null,
loading: false,
error: null,
} );
return;
}
this.setState( { loading: true } );
getCategory( categoryId )
.then( ( category ) => {
this.setState( { category, loading: false, error: null } );
} )
.catch( async ( e ) => {
const error = await formatError( e );
this.setState( { category: null, loading: false, error } );
} );
}
render() {
const { error, loading, category } = this.state;
return (
<OriginalComponent
{ ...this.props }
error={ error }
getCategory={ this.loadCategory }
isLoading={ loading }
category={ category }
/>
);
}
};
}, 'withCategory' );
export default withCategory;

View File

@@ -0,0 +1,194 @@
/**
* External dependencies
*/
import { Component } from '@wordpress/element';
import { createHigherOrderComponent } from '@wordpress/compose';
import PropTypes from 'prop-types';
import isShallowEqual from '@wordpress/is-shallow-equal';
import { getProductVariations } from '@woocommerce/editor-components/utils';
/**
* Internal dependencies
*/
import { formatError } from '../base/utils/errors';
/**
* HOC that queries variations for a component.
*
* @param {Function} OriginalComponent Component being wrapped.
*/
const withProductVariations = createHigherOrderComponent(
( OriginalComponent ) => {
class WrappedComponent extends Component {
state = {
error: null,
loading: false,
variations: {},
};
componentDidMount() {
const { selected, showVariations } = this.props;
if ( selected && showVariations ) {
this.loadVariations();
}
}
componentDidUpdate( prevProps ) {
const { isLoading, selected, showVariations } = this.props;
if (
showVariations &&
( ! isShallowEqual( prevProps.selected, selected ) ||
( prevProps.isLoading && ! isLoading ) )
) {
this.loadVariations();
}
}
loadVariations = () => {
const { products } = this.props;
const { loading, variations } = this.state;
if ( loading ) {
return;
}
const expandedProduct = this.getExpandedProduct();
if ( ! expandedProduct || variations[ expandedProduct ] ) {
return;
}
const productDetails = products.find(
( findProduct ) => findProduct.id === expandedProduct
);
if (
! productDetails.variations ||
productDetails.variations.length === 0
) {
this.setState( {
variations: {
...this.state.variations,
[ expandedProduct ]: null,
},
loading: false,
error: null,
} );
return;
}
this.setState( { loading: true } );
getProductVariations( expandedProduct )
.then( ( expandedProductVariations ) => {
const newVariations = expandedProductVariations.map(
( variation ) => ( {
...variation,
parent: expandedProduct,
} )
);
this.setState( {
variations: {
...this.state.variations,
[ expandedProduct ]: newVariations,
},
loading: false,
error: null,
} );
} )
.catch( async ( e ) => {
const error = await formatError( e );
this.setState( {
variations: {
...this.state.variations,
[ expandedProduct ]: null,
},
loading: false,
error,
} );
} );
};
isProductId( itemId ) {
const { products } = this.props;
return products.some( ( p ) => p.id === itemId );
}
findParentProduct( variationId ) {
const { products } = this.props;
const parentProduct = products.filter(
( p ) =>
p.variations &&
p.variations.find( ( { id } ) => id === variationId )
);
return parentProduct[ 0 ]?.id;
}
getExpandedProduct() {
const { isLoading, selected, showVariations } = this.props;
if ( ! showVariations ) {
return null;
}
let selectedItem =
selected && selected.length ? selected[ 0 ] : null;
// If there is no selected item, check if there was one in the past, so we
// can keep the same product expanded.
if ( selectedItem ) {
this.prevSelectedItem = selectedItem;
} else if ( this.prevSelectedItem ) {
// If previous selected item was a variation
if (
! isLoading &&
! this.isProductId( this.prevSelectedItem )
) {
selectedItem = this.prevSelectedItem;
}
}
if ( ! isLoading && selectedItem ) {
return this.isProductId( selectedItem )
? selectedItem
: this.findParentProduct( selectedItem );
}
return null;
}
render() {
const { error: propsError, isLoading } = this.props;
const { error, loading, variations } = this.state;
return (
<OriginalComponent
{ ...this.props }
error={ error || propsError }
expandedProduct={ this.getExpandedProduct() }
isLoading={ isLoading }
variations={ variations }
variationsLoading={ loading }
/>
);
}
static propTypes = {
selected: PropTypes.array,
showVariations: PropTypes.bool,
};
static defaultProps = {
selected: [],
showVariations: false,
};
}
return WrappedComponent;
},
'withProductVariations'
);
export default withProductVariations;

View File

@@ -0,0 +1,83 @@
/**
* External dependencies
*/
import { Component } from '@wordpress/element';
import { createHigherOrderComponent } from '@wordpress/compose';
import { getProduct } from '@woocommerce/editor-components/utils';
/**
* Internal dependencies
*/
import { formatError } from '../base/utils/errors';
/**
* HOC that queries a product for a component.
*
* @param {Function} OriginalComponent Component being wrapped.
*/
const withProduct = createHigherOrderComponent( ( OriginalComponent ) => {
return class WrappedComponent extends Component {
state = {
error: null,
loading: false,
product:
this.props.attributes.productId === 'preview'
? this.props.attributes.previewProduct
: null,
};
componentDidMount() {
this.loadProduct();
}
componentDidUpdate( prevProps ) {
if (
prevProps.attributes.productId !==
this.props.attributes.productId
) {
this.loadProduct();
}
}
loadProduct = () => {
const { productId } = this.props.attributes;
if ( productId === 'preview' ) {
return;
}
if ( ! productId ) {
this.setState( { product: null, loading: false, error: null } );
return;
}
this.setState( { loading: true } );
getProduct( productId )
.then( ( product ) => {
this.setState( { product, loading: false, error: null } );
} )
.catch( async ( e ) => {
const error = await formatError( e );
this.setState( { product: null, loading: false, error } );
} );
};
render() {
const { error, loading, product } = this.state;
return (
<OriginalComponent
{ ...this.props }
error={ error }
getProduct={ this.loadProduct }
isLoading={ loading }
product={ product }
/>
);
}
};
}, 'withProduct' );
export default withProduct;

View File

@@ -0,0 +1,94 @@
/**
* External dependencies
*/
import { useEffect, useState, useCallback, useRef } from '@wordpress/element';
import { blocksConfig } from '@woocommerce/block-settings';
import { getProducts } from '@woocommerce/editor-components/utils';
import { useDebouncedCallback } from 'use-debounce';
import type {
ProductResponseItem,
WithInjectedSearchedProducts,
} from '@woocommerce/types';
/**
* Internal dependencies
*/
import { formatError } from '../base/utils/errors';
interface WithSearchedProductProps {
selected: number[];
}
/**
* A higher order component that enhances the provided component with products from a search query.
*/
const withSearchedProducts = <
T extends Record< string, unknown > & WithSearchedProductProps
>(
OriginalComponent: React.ComponentType< T & WithInjectedSearchedProducts >
) => {
return ( { selected, ...props }: T ): JSX.Element => {
const [ isLoading, setIsLoading ] = useState( true );
const [ error, setError ] = useState< {
message: string;
type: string;
} | null >( null );
const [ productsList, setProductsList ] = useState<
ProductResponseItem[]
>( [] );
const isLargeCatalog = blocksConfig.productCount > 100;
const setErrorState = async ( e: {
message: string;
type: string;
} ) => {
const formattedError = ( await formatError( e ) ) as {
message: string;
type: string;
};
setError( formattedError );
setIsLoading( false );
};
const selectedRef = useRef( selected );
useEffect( () => {
getProducts( { selected: selectedRef.current } )
.then( ( results ) => {
setProductsList( results as ProductResponseItem[] );
setIsLoading( false );
} )
.catch( setErrorState );
}, [ selectedRef ] );
const debouncedSearch = useDebouncedCallback( ( search: string ) => {
getProducts( { selected, search } )
.then( ( results ) => {
setProductsList( results as ProductResponseItem[] );
setIsLoading( false );
} )
.catch( setErrorState );
}, 400 );
const onSearch = useCallback(
( search: string ) => {
setIsLoading( true );
debouncedSearch( search );
},
[ setIsLoading, debouncedSearch ]
);
return (
<OriginalComponent
{ ...( props as T ) }
selected={ selected }
error={ error }
products={ productsList }
isLoading={ isLoading }
onSearch={ isLargeCatalog ? onSearch : null }
/>
);
};
};
export default withSearchedProducts;

View File

@@ -0,0 +1,39 @@
/**
* External dependencies
*/
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore - Ignoring because @wordpress/element library does not have type definition for FunctionComponent
// eslint-disable-next-line
import { FunctionComponent } from '@wordpress/element';
export type SelectedOption = number | string | null | number[] | string[];
export interface WithMaybeSelectedOption {
selected?: SelectedOption;
}
/**
* HOC that transforms a single select to a multiple select.
*/
const withTransformSingleSelectToMultipleSelect = <
T extends Record< string, unknown >
>(
OriginalComponent: FunctionComponent< T & WithMaybeSelectedOption >
) => {
return ( props: T & WithMaybeSelectedOption ): JSX.Element => {
let { selected } = props;
selected = selected === undefined ? null : selected;
const isNil = selected === null;
return Array.isArray( selected ) ? (
<OriginalComponent { ...props } />
) : (
<OriginalComponent
{ ...props }
selected={ isNil ? [] : [ selected ] }
/>
);
};
};
export default withTransformSingleSelectToMultipleSelect;