This commit is contained in:
KhaiNguyen
2020-02-13 10:39:37 +07:00
commit 59401cb805
12867 changed files with 4646216 additions and 0 deletions

View File

@@ -0,0 +1,36 @@
/**
* External dependencies
*/
import TestRenderer from 'react-test-renderer';
/**
* Internal dependencies
*/
import withComponentId from '../with-component-id';
const TestComponent = withComponentId( ( props ) => {
return <div componentId={ props.componentId } />;
} );
const render = () => {
return TestRenderer.create( <TestComponent /> );
};
describe( 'withComponentId Component', () => {
let renderer;
it( 'initializes with expected id on initial render', () => {
renderer = render();
const props = renderer.root.findByType( 'div' ).props;
expect( props.componentId ).toBe( 0 );
} );
it( 'does not increment on re-render', () => {
renderer.update( <TestComponent someValue={ 3 } /> );
const props = renderer.root.findByType( 'div' ).props;
expect( props.componentId ).toBe( 0 );
} );
it( 'increments on a new component instance', () => {
renderer.update( <TestComponent key={ 42 } /> );
const props = renderer.root.findByType( 'div' ).props;
expect( props.componentId ).toBe( 1 );
} );
} );

View File

@@ -0,0 +1,140 @@
/**
* External dependencies
*/
import TestRenderer from 'react-test-renderer';
/**
* Internal dependencies
*/
import withProducts from '../with-products';
import * as mockUtils from '../utils';
import * as mockBaseUtils from '../../utils/errors';
jest.mock( '../utils', () => ( {
getProducts: jest.fn(),
} ) );
jest.mock( '../../utils/errors', () => ( {
formatError: jest.fn(),
} ) );
const mockProducts = [ { id: 10, name: 'foo' }, { id: 20, name: 'bar' } ];
const defaultArgs = {
orderby: 'menu_order',
order: 'asc',
per_page: 9,
page: 1,
};
const TestComponent = withProducts( ( props ) => {
return (
<div
error={ props.error }
getProducts={ props.getProducts }
appendReviews={ props.appendReviews }
onChangeArgs={ props.onChangeArgs }
isLoading={ props.isLoading }
products={ props.products }
totalProducts={ props.totalProducts }
/>
);
} );
const render = () => {
return TestRenderer.create(
<TestComponent
attributes={ {
columns: 3,
rows: 3,
} }
currentPage={ 1 }
sortValue="menu_order"
productId={ 1 }
productsToDisplay={ 2 }
/>
);
};
describe( 'withProducts Component', () => {
let renderer;
afterEach( () => {
mockUtils.getProducts.mockReset();
} );
describe( 'lifecycle events', () => {
beforeEach( () => {
mockUtils.getProducts
.mockImplementationOnce( () =>
Promise.resolve( {
products: mockProducts.slice( 0, 2 ),
totalProducts: mockProducts.length,
} )
)
.mockImplementationOnce( () =>
Promise.resolve( {
products: mockProducts.slice( 2, 3 ),
totalProducts: mockProducts.length,
} )
);
renderer = render();
} );
it( 'getProducts is called on mount', () => {
const { getProducts } = mockUtils;
expect( getProducts ).toHaveBeenCalledWith( defaultArgs );
expect( getProducts ).toHaveBeenCalledTimes( 1 );
} );
} );
describe( 'when the API returns product data', () => {
beforeEach( () => {
mockUtils.getProducts.mockImplementation( () =>
Promise.resolve( {
products: mockProducts,
totalProducts: mockProducts.length,
} )
);
renderer = render();
} );
it( 'sets products based on API response', () => {
const props = renderer.root.findByType( 'div' ).props;
expect( props.error ).toBeNull();
expect( props.isLoading ).toBe( false );
expect( props.products ).toEqual( mockProducts );
expect( props.totalProducts ).toEqual( mockProducts.length );
} );
} );
describe( 'when the API returns an error', () => {
const error = { message: 'There was an error.' };
const getProductsPromise = Promise.reject( error );
const formattedError = { message: 'There was an error.', type: 'api' };
beforeEach( () => {
mockUtils.getProducts.mockImplementation(
() => getProductsPromise
);
mockBaseUtils.formatError.mockImplementation(
() => formattedError
);
renderer = render();
} );
it( 'sets the error prop', ( done ) => {
const { formatError } = mockBaseUtils;
getProductsPromise.catch( () => {
const props = renderer.root.findByType( 'div' ).props;
expect( formatError ).toHaveBeenCalledWith( error );
expect( formatError ).toHaveBeenCalledTimes( 1 );
expect( props.error ).toEqual( formattedError );
expect( props.isLoading ).toBe( false );
expect( props.products ).toEqual( [] );
expect( props.totalProducts ).toEqual( 0 );
done();
} );
} );
} );
} );

View File

@@ -0,0 +1,162 @@
/**
* External dependencies
*/
import TestRenderer from 'react-test-renderer';
/**
* Internal dependencies
*/
import withReviews from '../with-reviews';
import * as mockUtils from '../../../blocks/reviews/utils';
import * as mockBaseUtils from '../../utils/errors';
jest.mock( '../../../blocks/reviews/utils', () => ( {
getSortArgs: () => ( {
order: 'desc',
orderby: 'date_gmt',
} ),
getReviews: jest.fn(),
} ) );
jest.mock( '../../utils/errors', () => ( {
formatError: jest.fn(),
} ) );
const mockReviews = [
{ reviewer: 'Alice', review: 'Lorem ipsum', rating: 2 },
{ reviewer: 'Bob', review: 'Dolor sit amet', rating: 3 },
{ reviewer: 'Carol', review: 'Consectetur adipiscing elit', rating: 5 },
];
const defaultArgs = {
offset: 0,
order: 'desc',
orderby: 'date_gmt',
per_page: 2,
product_id: 1,
};
const TestComponent = withReviews( ( props ) => {
return (
<div
error={ props.error }
getReviews={ props.getReviews }
appendReviews={ props.appendReviews }
onChangeArgs={ props.onChangeArgs }
isLoading={ props.isLoading }
reviews={ props.reviews }
totalReviews={ props.totalReviews }
/>
);
} );
const render = () => {
return TestRenderer.create(
<TestComponent
attributes={ {} }
order="desc"
orderby="date_gmt"
productId={ 1 }
reviewsToDisplay={ 2 }
/>
);
};
describe( 'withReviews Component', () => {
let renderer;
afterEach( () => {
mockUtils.getReviews.mockReset();
} );
describe( 'lifecycle events', () => {
beforeEach( () => {
mockUtils.getReviews
.mockImplementationOnce( () =>
Promise.resolve( {
reviews: mockReviews.slice( 0, 2 ),
totalReviews: mockReviews.length,
} )
)
.mockImplementationOnce( () =>
Promise.resolve( {
reviews: mockReviews.slice( 2, 3 ),
totalReviews: mockReviews.length,
} )
);
renderer = render();
} );
it( 'getReviews is called on mount with default args', () => {
const { getReviews } = mockUtils;
expect( getReviews ).toHaveBeenCalledWith( defaultArgs );
expect( getReviews ).toHaveBeenCalledTimes( 1 );
} );
it( 'getReviews is called on component update', () => {
const { getReviews } = mockUtils;
renderer.update(
<TestComponent
order="desc"
orderby="date_gmt"
productId={ 1 }
reviewsToDisplay={ 3 }
/>
);
expect( getReviews ).toHaveBeenNthCalledWith( 2, {
...defaultArgs,
offset: 2,
per_page: 1,
} );
expect( getReviews ).toHaveBeenCalledTimes( 2 );
} );
} );
describe( 'when the API returns product data', () => {
beforeEach( () => {
mockUtils.getReviews.mockImplementation( () =>
Promise.resolve( {
reviews: mockReviews.slice( 0, 2 ),
totalReviews: mockReviews.length,
} )
);
renderer = render();
} );
it( 'sets reviews based on API response', () => {
const props = renderer.root.findByType( 'div' ).props;
expect( props.error ).toBeNull();
expect( props.isLoading ).toBe( false );
expect( props.reviews ).toEqual( mockReviews.slice( 0, 2 ) );
expect( props.totalReviews ).toEqual( mockReviews.length );
} );
} );
describe( 'when the API returns an error', () => {
const error = { message: 'There was an error.' };
const getReviewsPromise = Promise.reject( error );
const formattedError = { message: 'There was an error.', type: 'api' };
beforeEach( () => {
mockUtils.getReviews.mockImplementation( () => getReviewsPromise );
mockBaseUtils.formatError.mockImplementation(
() => formattedError
);
renderer = render();
} );
it( 'sets the error prop', ( done ) => {
const { formatError } = mockBaseUtils;
getReviewsPromise.catch( () => {
const props = renderer.root.findByType( 'div' ).props;
expect( formatError ).toHaveBeenCalledWith( error );
expect( formatError ).toHaveBeenCalledTimes( 1 );
expect( props.error ).toEqual( formattedError );
expect( props.isLoading ).toBe( false );
expect( props.reviews ).toEqual( [] );
done();
} );
} );
} );
} );

View File

@@ -0,0 +1,28 @@
/**
* External dependencies
*/
import apiFetch from '@wordpress/api-fetch';
export const getProducts = ( queryArgs ) => {
const args = {
catalog_visibility: 'visible',
status: 'publish',
...queryArgs,
};
return apiFetch( {
path:
'/wc/blocks/products?' +
Object.entries( args )
.map( ( arg ) => arg.join( '=' ) )
.join( '&' ),
parse: false,
} ).then( ( response ) => {
return response.json().then( ( products ) => {
const totalProducts = parseInt(
response.headers.get( 'x-wp-total' ),
10
);
return { products, totalProducts };
} );
} );
};

View File

@@ -0,0 +1,31 @@
/**
* External dependencies
*/
import { Component } from 'react';
/**
* HOC that gives a component a unique ID.
*
* This is an alternative for withInstanceId from @wordpress/compose to avoid
* using that dependency on the frontend.
*/
const withComponentId = ( OriginalComponent ) => {
let instances = 0;
class WrappedComponent extends Component {
instanceId = instances++;
render() {
return (
<OriginalComponent
{ ...this.props }
componentId={ this.instanceId }
/>
);
}
}
WrappedComponent.displayName = 'withComponentId';
return WrappedComponent;
};
export default withComponentId;

View File

@@ -0,0 +1,110 @@
/**
* External dependencies
*/
import { Component } from 'react';
/**
* Internal dependencies
*/
import { getProducts } from './utils';
import { formatError } from '../utils/errors.js';
const withProducts = ( OriginalComponent ) => {
class WrappedComponent extends Component {
constructor() {
super( ...arguments );
this.state = {
products: [],
error: null,
loading: true,
totalProducts: 0,
};
this.loadProducts = this.loadProducts.bind( this );
}
componentDidMount() {
this.loadProducts();
}
componentDidUpdate( prevProps ) {
if (
prevProps.currentPage !== this.props.currentPage ||
prevProps.sortValue !== this.props.sortValue ||
prevProps.attributes.columns !==
this.props.attributes.columns ||
prevProps.attributes.rows !== this.props.attributes.rows
) {
this.loadProducts();
}
}
getSortArgs( orderName ) {
switch ( orderName ) {
case 'menu_order':
case 'popularity':
case 'rating':
case 'date':
case 'price':
return {
orderby: orderName,
order: 'asc',
};
case 'price-desc':
return {
orderby: 'price',
order: 'desc',
};
}
}
loadProducts() {
const { attributes, currentPage, sortValue } = this.props;
this.setState( { loading: true, products: [] } );
const args = {
...this.getSortArgs( sortValue ),
per_page: attributes.columns * attributes.rows,
page: currentPage,
};
getProducts( args )
.then( ( productsData ) => {
this.setState( {
products: productsData.products,
totalProducts: productsData.totalProducts,
loading: false,
error: null,
} );
} )
.catch( async ( e ) => {
const error = await formatError( e );
this.setState( {
products: [],
totalProducts: 0,
loading: false,
error,
} );
} );
}
render() {
const { error, loading, products, totalProducts } = this.state;
return (
<OriginalComponent
{ ...this.props }
products={ products }
totalProducts={ totalProducts }
error={ error }
isLoading={ loading }
/>
);
}
}
return WrappedComponent;
};
export default withProducts;

View File

@@ -0,0 +1,206 @@
/**
* External dependencies
*/
import { Component } from 'react';
import PropTypes from 'prop-types';
import isShallowEqual from '@wordpress/is-shallow-equal';
/**
* Internal dependencies
*/
import { getReviews } from '../../blocks/reviews/utils';
import { formatError } from '../utils/errors.js';
const withReviews = ( OriginalComponent ) => {
class WrappedComponent extends Component {
static propTypes = {
order: PropTypes.oneOf( [ 'asc', 'desc' ] ).isRequired,
orderby: PropTypes.string.isRequired,
reviewsToDisplay: PropTypes.number.isRequired,
categoryIds: PropTypes.oneOfType( [
PropTypes.string,
PropTypes.array,
] ),
delayFunction: PropTypes.func,
onReviewsAppended: PropTypes.func,
onReviewsLoadError: PropTypes.func,
onReviewsReplaced: PropTypes.func,
productId: PropTypes.oneOfType( [
PropTypes.string,
PropTypes.number,
] ),
};
static defaultProps = {
delayFunction: ( f ) => f,
onReviewsAppended: () => {},
onReviewsLoadError: () => {},
onReviewsReplaced: () => {},
};
isPreview = !! this.props.attributes.previewReviews;
delayedAppendReviews = this.props.delayFunction( this.appendReviews );
state = {
error: null,
loading: true,
reviews: this.isPreview ? this.props.attributes.previewReviews : [],
totalReviews: this.isPreview
? this.props.attributes.previewReviews.length
: 0,
};
componentDidMount() {
this.replaceReviews();
}
componentDidUpdate( prevProps ) {
if ( prevProps.reviewsToDisplay < this.props.reviewsToDisplay ) {
// Since this attribute might be controlled via something with
// short intervals between value changes, this allows for optionally
// delaying review fetches via the provided delay function.
this.delayedAppendReviews();
} else if ( this.shouldReplaceReviews( prevProps, this.props ) ) {
this.replaceReviews();
}
}
shouldReplaceReviews( prevProps, nextProps ) {
return (
prevProps.orderby !== nextProps.orderby ||
prevProps.order !== nextProps.order ||
prevProps.productId !== nextProps.productId ||
! isShallowEqual( prevProps.categoryIds, nextProps.categoryIds )
);
}
componentWillUnMount() {
if ( this.delayedAppendReviews.cancel ) {
this.delayedAppendReviews.cancel();
}
}
getArgs( reviewsToSkip ) {
const {
categoryIds,
order,
orderby,
productId,
reviewsToDisplay,
} = this.props;
const args = {
order,
orderby,
per_page: reviewsToDisplay - reviewsToSkip,
offset: reviewsToSkip,
};
if ( categoryIds && categoryIds.length ) {
args.category_id = Array.isArray( categoryIds )
? categoryIds.join( ',' )
: categoryIds;
}
if ( productId ) {
args.product_id = productId;
}
return args;
}
replaceReviews() {
if ( this.isPreview ) {
return;
}
const { onReviewsReplaced } = this.props;
this.updateListOfReviews().then( onReviewsReplaced );
}
appendReviews() {
if ( this.isPreview ) {
return;
}
const { onReviewsAppended, reviewsToDisplay } = this.props;
const { reviews } = this.state;
// Given that this function is delayed, props might have been updated since
// it was called so we need to check again if fetching new reviews is necessary.
if ( reviewsToDisplay <= reviews.length ) {
return;
}
this.updateListOfReviews( reviews ).then( onReviewsAppended );
}
updateListOfReviews( oldReviews = [] ) {
const { reviewsToDisplay } = this.props;
const { totalReviews } = this.state;
const reviewsToLoad =
Math.min( totalReviews, reviewsToDisplay ) - oldReviews.length;
this.setState( {
loading: true,
reviews: oldReviews.concat( Array( reviewsToLoad ).fill( {} ) ),
} );
return getReviews( this.getArgs( oldReviews.length ) )
.then(
( {
reviews: newReviews,
totalReviews: newTotalReviews,
} ) => {
this.setState( {
reviews: oldReviews
.filter(
( review ) => Object.keys( review ).length
)
.concat( newReviews ),
totalReviews: newTotalReviews,
loading: false,
error: null,
} );
return { newReviews };
}
)
.catch( this.setError );
}
setError = async ( e ) => {
const { onReviewsLoadError } = this.props;
const error = await formatError( e );
this.setState( { reviews: [], loading: false, error } );
onReviewsLoadError( error );
};
render() {
const { reviewsToDisplay } = this.props;
const { error, loading, reviews, totalReviews } = this.state;
return (
<OriginalComponent
{ ...this.props }
error={ error }
isLoading={ loading }
reviews={ reviews.slice( 0, reviewsToDisplay ) }
totalReviews={ totalReviews }
/>
);
}
}
const {
displayName = OriginalComponent.name || 'Component',
} = OriginalComponent;
WrappedComponent.displayName = `WithReviews( ${ displayName } )`;
return WrappedComponent;
};
export default withReviews;

View File

@@ -0,0 +1,75 @@
/**
* External dependencies
*/
import { Component, createRef, Fragment } from 'react';
/**
* Internal dependencies
*/
import './style.scss';
/**
* HOC that provides a function to scroll to the top of the component.
*/
const withScrollToTop = ( OriginalComponent ) => {
class WrappedComponent extends Component {
constructor() {
super();
this.scrollPointRef = createRef();
}
scrollToTopIfNeeded = () => {
const scrollPointRefYPosition = this.scrollPointRef.current.getBoundingClientRect()
.bottom;
const isScrollPointRefVisible =
scrollPointRefYPosition >= 0 &&
scrollPointRefYPosition <= window.innerHeight;
if ( ! isScrollPointRefVisible ) {
this.scrollPointRef.current.scrollIntoView();
}
};
moveFocusToTop = ( focusableSelector ) => {
const focusableElements = this.scrollPointRef.current.parentElement.querySelectorAll(
focusableSelector
);
if ( focusableElements.length ) {
focusableElements[ 0 ].focus();
}
};
scrollToTop = ( args ) => {
if ( ! window || ! Number.isFinite( window.innerHeight ) ) {
return;
}
this.scrollToTopIfNeeded();
if ( args && args.focusableSelector ) {
this.moveFocusToTop( args.focusableSelector );
}
};
render() {
return (
<Fragment>
<div
className="with-scroll-to-top__scroll-point"
ref={ this.scrollPointRef }
aria-hidden
/>
<OriginalComponent
{ ...this.props }
scrollToTop={ this.scrollToTop }
/>
</Fragment>
);
}
}
WrappedComponent.displayName = 'withScrollToTop';
return WrappedComponent;
};
export default withScrollToTop;

View File

@@ -0,0 +1,4 @@
.with-scroll-to-top__scroll-point {
position: relative;
top: -$gap-larger;
}

View File

@@ -0,0 +1,81 @@
/**
* External dependencies
*/
import TestRenderer from 'react-test-renderer';
/**
* Internal dependencies
*/
import withScrollToTop from '../index';
const TestComponent = withScrollToTop( ( props ) => (
<span { ...props }>
<button />
</span>
) );
const focusedMock = jest.fn();
const scrollIntoViewMock = jest.fn();
const mockedButton = {
focus: focusedMock,
};
const render = ( { inView } ) => {
return TestRenderer.create( <TestComponent />, {
createNodeMock: ( element ) => {
if ( element.type === 'button' ) {
return mockedButton;
}
if ( element.type === 'div' ) {
return {
getBoundingClientRect: () => ( {
bottom: inView ? 0 : -10,
} ),
parentElement: {
querySelectorAll: () => [ mockedButton ],
},
scrollIntoView: scrollIntoViewMock,
};
}
return null;
},
} );
};
describe( 'withScrollToTop Component', () => {
afterEach( () => {
focusedMock.mockReset();
scrollIntoViewMock.mockReset();
} );
describe( 'if component is not in view', () => {
beforeEach( () => {
const renderer = render( { inView: false } );
const props = renderer.root.findByType( 'span' ).props;
props.scrollToTop( { focusableSelector: 'button' } );
} );
it( 'scrolls to top of the component when scrollToTop is called', () => {
expect( scrollIntoViewMock ).toHaveBeenCalledTimes( 1 );
} );
it( 'moves focus to top of the component when scrollToTop is called', () => {
expect( focusedMock ).toHaveBeenCalledTimes( 1 );
} );
} );
describe( 'if component is in view', () => {
beforeEach( () => {
const renderer = render( { inView: true } );
const props = renderer.root.findByType( 'span' ).props;
props.scrollToTop( { focusableSelector: 'button' } );
} );
it( "doesn't scroll to top of the component when scrollToTop is called", () => {
expect( scrollIntoViewMock ).toHaveBeenCalledTimes( 0 );
} );
it( 'moves focus to top of the component when scrollToTop is called', () => {
expect( focusedMock ).toHaveBeenCalledTimes( 1 );
} );
} );
} );