first commit

This commit is contained in:
Rachit Bhargava
2023-07-21 17:12:10 -04:00
parent d0fe47dde4
commit 5d0f0734d8
14003 changed files with 2829464 additions and 0 deletions

View File

@@ -0,0 +1,17 @@
/**
* Returns the difference between two arrays (A - B)
*/
export function arrayDifferenceBy< T >( a: T[], b: T[], key: keyof T ) {
const keys = new Set( b.map( ( item ) => item[ key ] ) );
return a.filter( ( item ) => ! keys.has( item[ key ] ) );
}
/**
* Returns the union of two arrays (A B)
*/
export function arrayUnionBy< T >( a: T[], b: T[], key: keyof T ) {
const difference = arrayDifferenceBy( b, a, key );
return [ ...a, ...difference ];
}

View File

@@ -0,0 +1,95 @@
/**
* External dependencies
*/
import {
AttributeObject,
AttributeQuery,
AttributeTerm,
} from '@woocommerce/types';
import { sortBy } from 'lodash';
/**
* Given a query object, removes an attribute filter by a single slug.
*
* @param {Array} query Current query object.
* @param {Function} setQuery Callback to update the current query object.
* @param {Object} attribute An attribute object.
* @param {string} slug Term slug to remove.
*/
export const removeAttributeFilterBySlug = (
query: AttributeQuery[] = [],
setQuery: ( query: AttributeQuery[] ) => void,
attribute: AttributeObject,
slug = ''
) => {
// Get current filter for provided attribute.
const foundQuery = query.filter(
( item ) => item.attribute === attribute.taxonomy
);
const currentQuery = foundQuery.length ? foundQuery[ 0 ] : null;
if (
! currentQuery ||
! currentQuery.slug ||
! Array.isArray( currentQuery.slug ) ||
! currentQuery.slug.includes( slug )
) {
return;
}
const newSlugs = currentQuery.slug.filter( ( item ) => item !== slug );
// Remove current attribute filter from query.
const returnQuery = query.filter(
( item ) => item.attribute !== attribute.taxonomy
);
// Add a new query for selected terms, if provided.
if ( newSlugs.length > 0 ) {
currentQuery.slug = newSlugs.sort();
returnQuery.push( currentQuery );
}
setQuery( sortBy( returnQuery, 'attribute' ) );
};
/**
* Given a query object, sets the query up to filter by a given attribute and attribute terms.
*
* @param {Array} query Current query object.
* @param {Function} setQuery Callback to update the current query object.
* @param {Object} attribute An attribute object.
* @param {Array} attributeTerms Array of term objects.
* @param {string} operator Operator for the filter. Valid values: in, and.
*
* @return {Object} An attribute object.
*/
export const updateAttributeFilter = (
query: AttributeQuery[] = [],
setQuery: ( query: AttributeQuery[] ) => void,
attribute?: AttributeObject,
attributeTerms: AttributeTerm[] = [],
operator: 'in' | 'and' = 'in'
) => {
if ( ! attribute || ! attribute.taxonomy ) {
return [];
}
const returnQuery = query.filter(
( item ) => item.attribute !== attribute.taxonomy
);
if ( attributeTerms.length === 0 ) {
setQuery( returnQuery );
} else {
returnQuery.push( {
attribute: attribute.taxonomy,
operator,
slug: attributeTerms.map( ( { slug } ) => slug ).sort(),
} );
setQuery( sortBy( returnQuery, 'attribute' ) );
}
return returnQuery;
};

View File

@@ -0,0 +1,143 @@
/**
* External dependencies
*/
import { SearchListItem } from '@woocommerce/editor-components/search-list-control/types';
import { getSetting } from '@woocommerce/settings';
import {
AttributeObject,
AttributeSetting,
AttributeTerm,
AttributeWithTerms,
isAttributeTerm,
} from '@woocommerce/types';
import { dispatch, select } from '@wordpress/data';
const ATTRIBUTES = getSetting< AttributeSetting[] >( 'attributes', [] );
/**
* Format an attribute from the settings into an object with standardized keys.
*
* @param {Object} attribute The attribute object.
*/
const attributeSettingToObject = ( attribute: AttributeSetting ) => {
if ( ! attribute || ! attribute.attribute_name ) {
return null;
}
return {
id: parseInt( attribute.attribute_id, 10 ),
name: attribute.attribute_name,
taxonomy: 'pa_' + attribute.attribute_name,
label: attribute.attribute_label,
};
};
/**
* Format all attribute settings into objects.
*/
const attributeObjects = ATTRIBUTES.reduce(
( acc: Partial< AttributeObject >[], current ) => {
const attributeObject = attributeSettingToObject( current );
if ( attributeObject && attributeObject.id ) {
acc.push( attributeObject );
}
return acc;
},
[]
);
/**
* Converts an Attribute object into a shape compatible with the `SearchListControl`
*/
export const convertAttributeObjectToSearchItem = (
attribute: AttributeObject | AttributeTerm | AttributeWithTerms
): SearchListItem => {
const { count, id, name, parent } = attribute;
return {
count,
id,
name,
parent,
breadcrumbs: [],
children: [],
value: isAttributeTerm( attribute ) ? attribute.attr_slug : '',
};
};
/**
* Get attribute data by taxonomy.
*
* @param {number} attributeId The attribute ID.
* @return {Object|undefined} The attribute object if it exists.
*/
export const getAttributeFromID = ( attributeId: number ) => {
if ( ! attributeId ) {
return;
}
return attributeObjects.find( ( attribute ) => {
return attribute.id === attributeId;
} );
};
/**
* Get attribute data by taxonomy.
*
* @param {string} taxonomy The attribute taxonomy name e.g. pa_color.
* @return {Object|undefined} The attribute object if it exists.
*/
export const getAttributeFromTaxonomy = ( taxonomy: string ) => {
if ( ! taxonomy ) {
return;
}
return attributeObjects.find( ( attribute ) => {
return attribute.taxonomy === taxonomy;
} );
};
/**
* Get the taxonomy of an attribute by Attribute ID.
*
* @param {number} attributeId The attribute ID.
* @return {string} The taxonomy name.
*/
export const getTaxonomyFromAttributeId = ( attributeId: number ) => {
if ( ! attributeId ) {
return null;
}
const attribute = getAttributeFromID( attributeId );
return attribute ? attribute.taxonomy : null;
};
/**
* Updates an attribute in a sibling block. Useful if two settings control the same attribute, but you don't want to
* have this attribute exist on a parent block.
*/
export const updateAttributeInSiblingBlock = (
clientId: string,
attribute: string,
newValue: unknown,
siblingBlockName: string
) => {
const store = select( 'core/block-editor' );
const actions = dispatch( 'core/block-editor' );
const parentBlocks = store.getBlockParents( clientId );
let shippingMethodsBlockClientId = '';
// Loop through parent block's children until we find woocommerce/checkout-shipping-methods-block.
// Also set this attribute in the woocommerce/checkout-shipping-methods-block.
parentBlocks.forEach( ( parent ) => {
const childBlock = store
.getBlock( parent )
.innerBlocks.find( ( child ) => child.name === siblingBlockName );
if ( ! childBlock ) {
return;
}
shippingMethodsBlockClientId = childBlock.clientId;
} );
actions.updateBlockAttributes( shippingMethodsBlockClientId, {
[ attribute ]: newValue,
} );
};

View File

@@ -0,0 +1,51 @@
/**
* External dependencies
*/
import { getQueryArg, getQueryArgs, addQueryArgs } from '@wordpress/url';
import { getSettingWithCoercion } from '@woocommerce/settings';
import { isBoolean } from '@woocommerce/types';
const filteringForPhpTemplate = getSettingWithCoercion(
'is_rendering_php_template',
false,
isBoolean
);
/**
* Returns specified parameter from URL
*
* @param {string} name Parameter you want the value of.
*/
export const PREFIX_QUERY_ARG_QUERY_TYPE = 'query_type_';
export const PREFIX_QUERY_ARG_FILTER_TYPE = 'filter_';
export function getUrlParameter( name: string ) {
if ( ! window ) {
return null;
}
return getQueryArg( window.location.href, name );
}
/**
* Change the URL and reload the page if filtering for PHP templates.
*
* @param {string} newUrl New URL to be set.
*/
export function changeUrl( newUrl: string ) {
if ( filteringForPhpTemplate ) {
window.location.href = newUrl;
} else {
window.history.replaceState( {}, '', newUrl );
}
}
/**
* Run the query params through buildQueryString to normalise the params.
*
* @param {string} url URL to encode the search param from.
*/
export const normalizeQueryParams = ( url: string ) => {
const queryArgs = getQueryArgs( url );
return addQueryArgs( url, queryArgs );
};

View File

@@ -0,0 +1,9 @@
export * from './array-operations';
export * from './attributes-query';
export * from './attributes';
export * from './filters';
export * from './notices';
export * from './products';
export * from './shared-attributes';
export * from './useThrottle';
export * from './sanitize-html';

View File

@@ -0,0 +1,26 @@
/**
* External dependencies
*/
import { dispatch, select } from '@wordpress/data';
import type { Notice } from '@wordpress/notices';
export const hasNoticesOfType = (
type: 'default' | 'snackbar',
context?: string | undefined
): boolean => {
const notices: Notice[] = select( 'core/notices' ).getNotices( context );
return notices.some( ( notice: Notice ) => notice.type === type );
};
// Note, if context is blank, the default context is used.
export const removeNoticesByStatus = (
status: string,
context?: string | undefined
): void => {
const notices = select( 'core/notices' ).getNotices( context );
const { removeNotice } = dispatch( 'core/notices' );
const noticesOfType = notices.filter(
( notice ) => notice.status === status
);
noticesOfType.forEach( ( notice ) => removeNotice( notice.id, context ) );
};

View File

@@ -0,0 +1,29 @@
/**
* Get the src of the first image attached to a product (the featured image).
*
* @param {Object} product The product object to get the images from.
* @param {Array} product.images The array of images, destructured from the product object.
* @return {string} The full URL to the image.
*/
export function getImageSrcFromProduct( product ) {
if ( ! product || ! product.images || ! product.images.length ) {
return '';
}
return product.images[ 0 ].src || '';
}
/**
* Get the ID of the first image attached to a product (the featured image).
*
* @param {Object} product The product object to get the images from.
* @param {Array} product.images The array of images, destructured from the product object.
* @return {number} The ID of the image.
*/
export function getImageIdFromProduct( product ) {
if ( ! product || ! product.images || ! product.images.length ) {
return 0;
}
return product.images[ 0 ].id || 0;
}

View File

@@ -0,0 +1,20 @@
/**
* External dependencies
*/
import DOMPurify from 'dompurify';
const ALLOWED_TAGS = [ 'a', 'b', 'em', 'i', 'strong', 'p', 'br' ];
const ALLOWED_ATTR = [ 'target', 'href', 'rel', 'name', 'download' ];
export const sanitizeHTML = (
html: string,
config?: { tags?: typeof ALLOWED_TAGS; attr?: typeof ALLOWED_ATTR }
) => {
const tagsValue = config?.tags || ALLOWED_TAGS;
const attrValue = config?.attr || ALLOWED_ATTR;
return DOMPurify.sanitize( html, {
ALLOWED_TAGS: tagsValue,
ALLOWED_ATTR: attrValue,
} );
};

View File

@@ -0,0 +1,84 @@
/**
* External dependencies
*/
import { getSetting } from '@woocommerce/settings';
export const sharedAttributeBlockTypes = [
'woocommerce/product-best-sellers',
'woocommerce/product-category',
'woocommerce/product-new',
'woocommerce/product-on-sale',
'woocommerce/product-top-rated',
];
export default {
/**
* Number of columns.
*/
columns: {
type: 'number',
default: getSetting( 'default_columns', 3 ),
},
/**
* Number of rows.
*/
rows: {
type: 'number',
default: getSetting( 'default_rows', 3 ),
},
/**
* How to align cart buttons.
*/
alignButtons: {
type: 'boolean',
default: false,
},
/**
* Product category, used to display only products in the given categories.
*/
categories: {
type: 'array',
default: [],
},
/**
* Product category operator, used to restrict to products in all or any selected categories.
*/
catOperator: {
type: 'string',
default: 'any',
},
/**
* Content visibility setting
*/
contentVisibility: {
type: 'object',
default: {
image: true,
title: true,
price: true,
rating: true,
button: true,
},
},
/**
* Are we previewing?
*/
isPreview: {
type: 'boolean',
default: false,
},
/**
* Whether to display in stock, out of stock or backorder products.
*/
stockStatus: {
type: 'array',
default: Object.keys( getSetting( 'stockStatusOptions', [] ) ),
},
};

View File

@@ -0,0 +1,27 @@
/**
* Internal dependencies
*/
import { normalizeQueryParams } from '../filters';
describe( 'normalizeQueryParams', () => {
test( 'does not change url if there is no query params', () => {
const input = 'https://example.com';
const expected = 'https://example.com';
expect( normalizeQueryParams( input ) ).toBe( expected );
} );
test( 'does not change search term if there is no special character', () => {
const input = 'https://example.com?foo=bar&s=asdf1234&baz=qux';
const expected = 'https://example.com?foo=bar&s=asdf1234&baz=qux';
expect( normalizeQueryParams( input ) ).toBe( expected );
} );
test( 'decodes single quote characters', () => {
const input = 'https://example.com?foo=bar%27&s=asd%27f1234&baz=qux%27';
const expected = "https://example.com?foo=bar'&s=asd'f1234&baz=qux'";
expect( normalizeQueryParams( input ) ).toBe( expected );
} );
} );

View File

@@ -0,0 +1,122 @@
/**
* External dependencies
*/
import { select, dispatch } from '@wordpress/data';
/**
* Internal dependencies
*/
import { hasNoticesOfType, removeNoticesByStatus } from '../notices';
jest.mock( '@wordpress/data' );
describe( 'Notice utils', () => {
beforeEach( () => {
jest.resetAllMocks();
} );
describe( 'hasNoticesOfType', () => {
it( 'Correctly returns if there are notices of a given type in the core data store', () => {
select.mockReturnValue( {
getNotices: jest.fn().mockReturnValue( [
{
id: 'coupon-form',
status: 'error',
content:
'Coupon cannot be removed because it is not already applied to the cart.',
spokenMessage:
'Coupon cannot be removed because it is not already applied to the cart.',
isDismissible: true,
actions: [],
type: 'default',
icon: null,
explicitDismiss: false,
},
] ),
} );
const hasSnackbarNotices = hasNoticesOfType(
'snackbar',
'wc/cart'
);
const hasDefaultNotices = hasNoticesOfType( 'default', 'wc/cart' );
expect( hasDefaultNotices ).toBe( true );
expect( hasSnackbarNotices ).toBe( false );
} );
it( 'Handles notices being empty', () => {
select.mockReturnValue( {
getNotices: jest.fn().mockReturnValue( [] ),
} );
const hasDefaultNotices = hasNoticesOfType( 'default', 'wc/cart' );
expect( hasDefaultNotices ).toBe( false );
} );
} );
describe( 'removeNoticesByStatus', () => {
it( 'Correctly removes notices of a given status', () => {
select.mockReturnValue( {
getNotices: jest.fn().mockReturnValue( [
{
id: 'coupon-form',
status: 'error',
content:
'Coupon cannot be removed because it is not already applied to the cart.',
spokenMessage:
'Coupon cannot be removed because it is not already applied to the cart.',
isDismissible: true,
actions: [],
type: 'default',
icon: null,
explicitDismiss: false,
},
{
id: 'address-form',
status: 'error',
content: 'Address invalid',
spokenMessage: 'Address invalid',
isDismissible: true,
actions: [],
type: 'default',
icon: null,
explicitDismiss: false,
},
{
id: 'some-warning',
status: 'warning',
content: 'Warning notice.',
spokenMessage: 'Warning notice.',
isDismissible: true,
actions: [],
type: 'default',
icon: null,
explicitDismiss: false,
},
] ),
} );
dispatch.mockReturnValue( {
removeNotice: jest.fn(),
} );
removeNoticesByStatus( 'error' );
expect( dispatch().removeNotice ).toHaveBeenNthCalledWith(
1,
'coupon-form',
undefined
);
expect( dispatch().removeNotice ).toHaveBeenNthCalledWith(
2,
'address-form',
undefined
);
} );
it( 'Handles notices being empty', () => {
select.mockReturnValue( {
getNotices: jest.fn().mockReturnValue( [] ),
} );
dispatch.mockReturnValue( {
removeNotice: jest.fn(),
} );
removeNoticesByStatus( 'empty' );
expect( dispatch().removeNotice ).not.toBeCalled();
} );
} );
} );

View File

@@ -0,0 +1,84 @@
/**
* Internal dependencies
*/
import { getImageSrcFromProduct, getImageIdFromProduct } from '../products';
describe( 'getImageSrcFromProduct', () => {
test( 'returns first image src', () => {
const imageSrc = getImageSrcFromProduct( {
images: [ { src: 'foo.jpg' } ],
} );
expect( imageSrc ).toBe( 'foo.jpg' );
} );
test( 'returns empty string if no product was provided', () => {
const imageSrc = getImageSrcFromProduct();
expect( imageSrc ).toBe( '' );
} );
test( 'returns empty string if product is empty', () => {
const imageSrc = getImageSrcFromProduct( {} );
expect( imageSrc ).toBe( '' );
} );
test( 'returns empty string if product has no images', () => {
const imageSrc = getImageSrcFromProduct( { images: null } );
expect( imageSrc ).toBe( '' );
} );
test( 'returns empty string if product has 0 images', () => {
const imageSrc = getImageSrcFromProduct( { images: [] } );
expect( imageSrc ).toBe( '' );
} );
test( 'returns empty string if product image has no src attribute', () => {
const imageSrc = getImageSrcFromProduct( { images: [ {} ] } );
expect( imageSrc ).toBe( '' );
} );
} );
describe( 'getImageIdFromProduct', () => {
test( 'returns first image id', () => {
const imageUrl = getImageIdFromProduct( {
images: [ { id: 123 } ],
} );
expect( imageUrl ).toBe( 123 );
} );
test( 'returns 0 if no product was provided', () => {
const imageUrl = getImageIdFromProduct();
expect( imageUrl ).toBe( 0 );
} );
test( 'returns 0 if product is empty', () => {
const imageUrl = getImageIdFromProduct( {} );
expect( imageUrl ).toBe( 0 );
} );
test( 'returns 0 if product has no images', () => {
const imageUrl = getImageIdFromProduct( { images: null } );
expect( imageUrl ).toBe( 0 );
} );
test( 'returns 0 if product has 0 images', () => {
const imageUrl = getImageIdFromProduct( { images: [] } );
expect( imageUrl ).toBe( 0 );
} );
test( 'returns 0 if product image has no src attribute', () => {
const imageUrl = getImageIdFromProduct( { images: [ {} ] } );
expect( imageUrl ).toBe( 0 );
} );
} );

View File

@@ -0,0 +1,34 @@
/* eslint-disable you-dont-need-lodash-underscore/throttle */
/**
* External dependencies
*/
import { DebouncedFunc, throttle, ThrottleSettings } from 'lodash';
import { useCallback, useEffect, useRef } from '@wordpress/element';
/**
* Throttles a function inside a React functional component
*/
// Disabling this as lodash expects this and I didn't make using `unknown`
// work in practice.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function useThrottle< T extends ( ...args: any[] ) => any >(
cb: T,
delay: number,
options?: ThrottleSettings
): DebouncedFunc< T > {
const cbRef = useRef( cb );
useEffect( () => {
cbRef.current = cb;
} );
// Disabling because we can't pass an arrow function in this case
// eslint-disable-next-line react-hooks/exhaustive-deps
const throttledCb = useCallback(
throttle( ( ...args ) => cbRef.current( ...args ), delay, options ),
[ delay ]
);
return throttledCb;
}