khaihihi
This commit is contained in:
@@ -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 );
|
||||
} );
|
||||
} );
|
||||
@@ -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();
|
||||
} );
|
||||
} );
|
||||
} );
|
||||
} );
|
||||
@@ -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();
|
||||
} );
|
||||
} );
|
||||
} );
|
||||
} );
|
||||
@@ -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 };
|
||||
} );
|
||||
} );
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,4 @@
|
||||
.with-scroll-to-top__scroll-point {
|
||||
position: relative;
|
||||
top: -$gap-larger;
|
||||
}
|
||||
@@ -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 );
|
||||
} );
|
||||
} );
|
||||
} );
|
||||
Reference in New Issue
Block a user