initial commit
This commit is contained in:
@ -0,0 +1,160 @@
|
||||
/**
|
||||
* 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();
|
||||
} );
|
||||
|
||||
test( 'sets the error prop', async () => {
|
||||
await expect( () => getReviewsPromise() ).toThrow();
|
||||
|
||||
const { formatError } = mockBaseUtils;
|
||||
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( [] );
|
||||
} );
|
||||
} );
|
||||
} );
|
@ -0,0 +1,24 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { getValidBlockAttributes } from '@woocommerce/base-utils';
|
||||
|
||||
/**
|
||||
* HOC that filters given attributes by valid block attribute values, or uses defaults if undefined.
|
||||
*
|
||||
* @param {Object} blockAttributes Component being wrapped.
|
||||
*/
|
||||
const withFilteredAttributes = ( blockAttributes ) => ( OriginalComponent ) => {
|
||||
return ( ownProps ) => {
|
||||
const validBlockAttributes = getValidBlockAttributes(
|
||||
blockAttributes,
|
||||
ownProps
|
||||
);
|
||||
|
||||
return (
|
||||
<OriginalComponent { ...ownProps } { ...validBlockAttributes } />
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
export default withFilteredAttributes;
|
221
packages/woocommerce-blocks/assets/js/base/hocs/with-reviews.js
Normal file
221
packages/woocommerce-blocks/assets/js/base/hocs/with-reviews.js
Normal file
@ -0,0 +1,221 @@
|
||||
/**
|
||||
* 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';
|
||||
|
||||
/**
|
||||
* HOC that queries reviews for a component.
|
||||
*
|
||||
* @param {Function} OriginalComponent Component being wrapped.
|
||||
*/
|
||||
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 );
|
||||
|
||||
isMounted = false;
|
||||
|
||||
state = {
|
||||
error: null,
|
||||
loading: true,
|
||||
reviews: this.isPreview ? this.props.attributes.previewReviews : [],
|
||||
totalReviews: this.isPreview
|
||||
? this.props.attributes.previewReviews.length
|
||||
: 0,
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
this.isMounted = true;
|
||||
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() {
|
||||
this.isMounted = false;
|
||||
|
||||
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,
|
||||
} ) => {
|
||||
if ( this.isMounted ) {
|
||||
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 ) => {
|
||||
if ( ! this.isMounted ) {
|
||||
return;
|
||||
}
|
||||
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,87 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { useRef } from '@wordpress/element';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import './style.scss';
|
||||
|
||||
interface ScrollToTopProps {
|
||||
focusableSelector?: string;
|
||||
}
|
||||
|
||||
const maybeScrollToTop = ( scrollPoint: HTMLElement ): void => {
|
||||
if ( ! scrollPoint ) {
|
||||
return;
|
||||
}
|
||||
|
||||
const yPos = scrollPoint.getBoundingClientRect().bottom;
|
||||
const isScrollPointVisible = yPos >= 0 && yPos <= window.innerHeight;
|
||||
|
||||
if ( ! isScrollPointVisible ) {
|
||||
scrollPoint.scrollIntoView();
|
||||
}
|
||||
};
|
||||
|
||||
const moveFocusToElement = (
|
||||
scrollPoint: HTMLElement,
|
||||
focusableSelector: string
|
||||
): void => {
|
||||
const focusableElements =
|
||||
scrollPoint.parentElement?.querySelectorAll( focusableSelector ) || [];
|
||||
|
||||
if ( focusableElements.length ) {
|
||||
const targetElement = focusableElements[ 0 ] as HTMLElement;
|
||||
maybeScrollToTop( targetElement );
|
||||
targetElement?.focus();
|
||||
} else {
|
||||
maybeScrollToTop( scrollPoint );
|
||||
}
|
||||
};
|
||||
|
||||
const scrollToHTMLElement = (
|
||||
scrollPoint: HTMLElement,
|
||||
options: ScrollToTopProps
|
||||
): void => {
|
||||
const { focusableSelector } = options || {};
|
||||
|
||||
if ( ! window || ! Number.isFinite( window.innerHeight ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ( focusableSelector ) {
|
||||
moveFocusToElement( scrollPoint, focusableSelector );
|
||||
} else {
|
||||
maybeScrollToTop( scrollPoint );
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* HOC that provides a function to scroll to the top of the component.
|
||||
*/
|
||||
const withScrollToTop = (
|
||||
OriginalComponent: React.FunctionComponent< Record< string, unknown > >
|
||||
) => {
|
||||
return ( props: Record< string, unknown > ): JSX.Element => {
|
||||
const scrollPointRef = useRef< HTMLDivElement >( null );
|
||||
const scrollToTop = ( args: ScrollToTopProps ) => {
|
||||
if ( scrollPointRef.current !== null ) {
|
||||
scrollToHTMLElement( scrollPointRef.current, args );
|
||||
}
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="with-scroll-to-top__scroll-point"
|
||||
ref={ scrollPointRef }
|
||||
aria-hidden
|
||||
/>
|
||||
<OriginalComponent { ...props } scrollToTop={ scrollToTop } />
|
||||
</>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
export default withScrollToTop;
|
@ -0,0 +1,4 @@
|
||||
.with-scroll-to-top__scroll-point {
|
||||
position: relative;
|
||||
top: -$gap-larger;
|
||||
}
|
@ -0,0 +1,89 @@
|
||||
/**
|
||||
* 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,
|
||||
scrollIntoView: scrollIntoViewMock,
|
||||
};
|
||||
const render = ( { inView } ) => {
|
||||
const getBoundingClientRect = () => ( {
|
||||
bottom: inView ? 0 : -10,
|
||||
} );
|
||||
return TestRenderer.create( <TestComponent />, {
|
||||
createNodeMock: ( element ) => {
|
||||
if ( element.type === 'button' ) {
|
||||
return {
|
||||
...mockedButton,
|
||||
getBoundingClientRect,
|
||||
};
|
||||
}
|
||||
if ( element.type === 'div' ) {
|
||||
return {
|
||||
getBoundingClientRect,
|
||||
parentElement: {
|
||||
querySelectorAll: () => [
|
||||
{ ...mockedButton, getBoundingClientRect },
|
||||
],
|
||||
},
|
||||
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