Merge branch 'trunk' into e2e/strict-locator-tweak

This commit is contained in:
jamelreid 2023-01-27 10:42:30 -05:00
commit afb82d221c
481 changed files with 12150 additions and 6977 deletions

View File

@ -27,26 +27,26 @@ runs:
echo "BUILD_FILTERS=$(node ./.github/actions/setup-woocommerce-monorepo/scripts/parse-input-filter.js '${{ inputs.build-filters }}')" >> $GITHUB_OUTPUT
- name: Setup PNPM
uses: pnpm/action-setup@10693b3829bf86eb2572aef5f3571dcf5ca9287d
uses: pnpm/action-setup@c3b53f6a16e57305370b4ae5a540c2077a1d50dd
with:
version: '^7.13.3'
version: '^7.22.0'
- name: Setup Node
uses: actions/setup-node@2fddd8803e2f5c9604345a0b591c3020ee971a93
uses: actions/setup-node@64ed1c7eab4cce3362f8c340dee64e5eaeef8f7c
with:
node-version-file: .nvmrc
cache: pnpm
registry-url: 'https://registry.npmjs.org'
- name: Setup PHP
uses: shivammathur/setup-php@e04e1d97f0c0481c6e1ba40f8a538454fe5d7709
uses: shivammathur/setup-php@8e2ac35f639d3e794c1da1f28999385ab6fdf0fc
with:
php-version: ${{ inputs.php-version }}
coverage: none
tools: phpcs, sirbrillig/phpcs-changed
- name: Cache Composer Dependencies
uses: actions/cache@fd5de65bc895cf536527842281bea11763fefd77
uses: actions/cache@58c146cc91c5b9e778e71775dfe9bf1442ad9a12
with:
path: ~/.cache/composer/files
key: ${{ runner.os }}-php-${{ inputs.php-version }}-composer-${{ hashFiles('**/composer.lock') }}
@ -59,7 +59,7 @@ runs:
pnpm install ${{ steps.parse-input.outputs.INSTALL_FILTERS }}
- name: Cache Build Output
uses: actions/cache@fd5de65bc895cf536527842281bea11763fefd77
uses: actions/cache@58c146cc91c5b9e778e71775dfe9bf1442ad9a12
with:
path: node_modules/.cache/turbo
key: ${{ runner.os }}-build-output-${{ hashFiles('node_modules/.cache/turbo/*-meta.json') }}

View File

@ -17,14 +17,14 @@ jobs:
name: Verify
runs-on: ubuntu-20.04
permissions:
contents: read
pull-requests: write
issues: write
contents: read
pull-requests: write
issues: write
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@2fddd8803e2f5c9604345a0b591c3020ee971a93
uses: actions/setup-node@64ed1c7eab4cce3362f8c340dee64e5eaeef8f7c
- name: Install Octokit
run: npm --prefix .github/workflows/scripts install @octokit/action

View File

@ -1,43 +1,43 @@
name: "Pull request post-merge processing"
name: 'Pull request post-merge processing'
on:
pull_request_target:
types: [closed]
pull_request_target:
types: [closed]
permissions: {}
jobs:
process-pull-request-after-merge:
name: "Process a pull request after it's merged"
if: github.event.pull_request.merged == true
runs-on: ubuntu-20.04
permissions:
pull-requests: write
steps:
- name: "Get the action scripts"
run: |
scripts="assign-milestone-to-merged-pr.php add-post-merge-comment.php post-request-shared.php"
for script in $scripts
do
curl \
--silent \
--fail \
--header 'Authorization: bearer ${{ secrets.GITHUB_TOKEN }}' \
--header 'User-Agent: GitHub action to set the milestone for a pull request' \
--header 'Accept: application/vnd.github.v3.raw' \
--output $script \
--location "$GITHUB_API_URL/repos/${{ github.repository }}/contents/.github/workflows/scripts/$script?ref=${{ github.event.pull_request.base.ref }}"
done
env:
GITHUB_API_URL: ${{ env.GITHUB_API_URL }}
- name: "Install PHP"
uses: shivammathur/setup-php@v2
with:
php-version: '7.4'
- name: "Run the script to assign a milestone"
if: |
!github.event.pull_request.milestone &&
github.event.pull_request.base.ref == 'trunk'
run: php assign-milestone-to-merged-pr.php
env:
PULL_REQUEST_ID: ${{ github.event.pull_request.node_id }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
process-pull-request-after-merge:
name: "Process a pull request after it's merged"
if: github.event.pull_request.merged == true
runs-on: ubuntu-20.04
permissions:
pull-requests: write
steps:
- name: 'Get the action scripts'
run: |
scripts="assign-milestone-to-merged-pr.php add-post-merge-comment.php post-request-shared.php"
for script in $scripts
do
curl \
--silent \
--fail \
--header 'Authorization: bearer ${{ secrets.GITHUB_TOKEN }}' \
--header 'User-Agent: GitHub action to set the milestone for a pull request' \
--header 'Accept: application/vnd.github.v3.raw' \
--output $script \
--location "$GITHUB_API_URL/repos/${{ github.repository }}/contents/.github/workflows/scripts/$script?ref=${{ github.event.pull_request.base.ref }}"
done
env:
GITHUB_API_URL: ${{ env.GITHUB_API_URL }}
- name: 'Install PHP'
uses: shivammathur/setup-php@8e2ac35f639d3e794c1da1f28999385ab6fdf0fc
with:
php-version: '7.4'
- name: 'Run the script to assign a milestone'
if: |
!github.event.pull_request.milestone &&
github.event.pull_request.base.ref == 'trunk'
run: php assign-milestone-to-merged-pr.php
env:
PULL_REQUEST_ID: ${{ github.event.pull_request.node_id }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@ -30,7 +30,7 @@ jobs:
freeze: ${{ steps.check-freeze.outputs.freeze }}
steps:
- name: 'Install PHP'
uses: shivammathur/setup-php@v2
uses: shivammathur/setup-php@8e2ac35f639d3e794c1da1f28999385ab6fdf0fc
with:
php-version: '7.4'

View File

@ -1,37 +1,37 @@
name: Synchronize Dependencies with syncpack
on:
# Run whenever a pull request is updated
pull_request:
branches:
- trunk
paths:
- '**/package.json'
# Run whenever a pull request is updated
pull_request:
branches:
- trunk
paths:
- '**/package.json'
permissions: {}
jobs:
syncpack:
runs-on: ubuntu-latest
permissions:
contents: read
name: syncpack
steps:
- name: 'Checkout'
uses: actions/checkout@v3
- name: 'Setup node'
uses: actions/setup-node@v3
with:
node-version: 16
syncpack:
runs-on: ubuntu-latest
permissions:
contents: read
name: syncpack
steps:
- name: 'Checkout'
uses: actions/checkout@v3
- name: 'Install Syncpack'
run: npm install -g syncpack@^8.2.4
- name: 'List Mismatches'
run: syncpack list-mismatches
- name: 'Explain Remedy'
if: failure()
run: |
echo "Dependency version mismatch detected. This can usually be fixed automatically by updating the pinned version in \`.syncpackrc\` and then running: \`pnpm run sync-dependencies\`"
exit 1
- name: 'Setup node'
uses: actions/setup-node@64ed1c7eab4cce3362f8c340dee64e5eaeef8f7c
with:
node-version: 16
- name: 'Install Syncpack'
run: npm install -g syncpack@^8.2.4
- name: 'List Mismatches'
run: syncpack list-mismatches
- name: 'Explain Remedy'
if: failure()
run: |
echo "Dependency version mismatch detected. This can usually be fixed automatically by updating the pinned version in \`.syncpackrc\` and then running: \`pnpm run sync-dependencies\`"
exit 1

View File

@ -1,4 +1,4 @@
Significance: minor
Type: dev
Add advanced setting option
Create tree-control component

View File

@ -0,0 +1,4 @@
Significance: patch
Type: fix
Altering styles to correctly target fields within slot fills on product editor.

View File

@ -1,5 +1,4 @@
Significance: patch
Type: dev
Comment: 7.4 release prep
Migrate Table component to TS

View File

@ -0,0 +1,4 @@
Significance: patch
Type: fix
Include CSS for experimental tree control so it renders properly in Storybook.

View File

@ -0,0 +1,5 @@
Significance: patch
Type: fix
Comment: Move registerFill call to inside an useEffect since it was updating a component while rendering another component

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Add new WooProductTabItem component for slot filling tab items.

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Adding experimental component SlotContext

View File

@ -44,6 +44,7 @@
"@woocommerce/navigation": "workspace:*",
"@wordpress/a11y": "3.5.0",
"@wordpress/api-fetch": "^6.0.1",
"@wordpress/base-styles": "^4.3.0",
"@wordpress/block-editor": "^9.8.0",
"@wordpress/block-library": "^7.16.0",
"@wordpress/blocks": "^11.18.0",
@ -164,5 +165,11 @@
"pnpm lint:fix",
"pnpm test-staged"
]
},
"pnpm": {
"overrides": {
"react": "^17.0.2",
"react-dom": "^17.0.2"
}
}
}

View File

@ -0,0 +1,50 @@
/**
* External dependencies
*/
import { useMemo } from 'react';
/**
* Internal dependencies
*/
import { Item, LinkedTree } from '../types';
type MemoItems = {
[ value: Item[ 'value' ] ]: LinkedTree;
};
function findChildren(
items: Item[],
parent?: Item[ 'parent' ],
memo: MemoItems = {}
): LinkedTree[] {
const children: Item[] = [];
const others: Item[] = [];
items.forEach( ( item ) => {
if ( item.parent === parent ) {
children.push( item );
} else {
others.push( item );
}
memo[ item.value ] = {
parent: undefined,
data: item,
children: [],
};
} );
return children.map( ( child ) => {
const linkedTree = memo[ child.value ];
linkedTree.parent = child.parent ? memo[ child.parent ] : undefined;
linkedTree.children = findChildren( others, child.value, memo );
return linkedTree;
} );
}
export function useLinkedTree( items: Item[] ): LinkedTree[] {
const linkedTree = useMemo( () => {
return findChildren( items, undefined, {} );
}, [ items ] );
return linkedTree;
}

View File

@ -0,0 +1,30 @@
/**
* External dependencies
*/
import React from 'react';
/**
* Internal dependencies
*/
import { TreeItemProps } from '../types';
export function useTreeItem( { item, level, ...props }: TreeItemProps ) {
const nextLevel = level + 1;
return {
item,
level: nextLevel,
treeItemProps: {
...props,
},
headingProps: {
style: {
'--level': level,
} as React.CSSProperties,
},
treeProps: {
items: item.children,
level: nextLevel,
},
};
}

View File

@ -0,0 +1,21 @@
/**
* External dependencies
*/
/**
* Internal dependencies
*/
import { TreeProps } from '../types';
export function useTree( { ref, items, level = 1, ...props }: TreeProps ) {
return {
level,
items,
treeProps: {
...props,
},
treeItemProps: {
level,
},
};
}

View File

@ -0,0 +1,4 @@
export * from './tree';
export * from './tree-control';
export * from './tree-item';
export * from './types';

View File

@ -0,0 +1,42 @@
/**
* External dependencies
*/
import { BaseControl } from '@wordpress/components';
import React, { createElement } from 'react';
/**
* Internal dependencies
*/
import { TreeControl } from '../tree-control';
import { Item } from '../types';
const listItems: Item[] = [
{ value: '1', label: 'Technology' },
{ value: '1.1', label: 'Notebooks', parent: '1' },
{ value: '1.2', label: 'Phones', parent: '1' },
{ value: '1.2.1', label: 'iPhone', parent: '1.2' },
{ value: '1.2.1.1', label: 'iPhone 14 Pro', parent: '1.2.1' },
{ value: '1.2.1.2', label: 'iPhone 14 Pro Max', parent: '1.2.1' },
{ value: '1.2.2', label: 'Samsung', parent: '1.2' },
{ value: '1.2.2.1', label: 'Samsung Galaxy 22 Plus', parent: '1.2.2' },
{ value: '1.2.2.2', label: 'Samsung Galaxy 22 Ultra', parent: '1.2.2' },
{ value: '1.3', label: 'Wearables', parent: '1' },
{ value: '2', label: 'Hardware' },
{ value: '2.1', label: 'CPU', parent: '2' },
{ value: '2.2', label: 'GPU', parent: '2' },
{ value: '2.3', label: 'Memory RAM', parent: '2' },
{ value: '3', label: 'Other' },
];
export const SimpleTree: React.FC = () => {
return (
<BaseControl label="Simple tree" id="simple-tree">
<TreeControl id="simple-tree" items={ listItems } />
</BaseControl>
);
};
export default {
title: 'WooCommerce Admin/experimental/TreeControl',
component: TreeControl,
};

View File

@ -0,0 +1,20 @@
/**
* External dependencies
*/
import { createElement, forwardRef } from 'react';
/**
* Internal dependencies
*/
import { useLinkedTree } from './hooks/use-linked-tree';
import { Tree } from './tree';
import { TreeControlProps } from './types';
export const TreeControl = forwardRef( function ForwardedTree(
{ items, ...props }: TreeControlProps,
ref: React.ForwardedRef< HTMLOListElement >
) {
const linkedTree = useLinkedTree( items );
return <Tree { ...props } ref={ ref } items={ linkedTree } />;
} );

View File

@ -0,0 +1,34 @@
.experimental-woocommerce-tree-item {
margin: 0;
&__heading {
display: flex;
flex-grow: 1;
gap: $gap-smaller;
min-height: $gap-largest;
padding: 0 $gap-small 0 calc( ( var( --level ) - 1 ) * ( $gap + $gap-small ) + $gap-small );
border-radius: 2px;
&:hover,
&:focus-within {
outline: 1.5px solid var( --wp-admin-theme-color );
outline-offset: -1.5px;
}
&:hover,
&:focus-within {
background-color: $gray-0;
}
}
&__label {
display: flex;
flex-grow: 1;
align-items: center;
padding: $gap-smaller $gap-small $gap-smaller 0;
position: relative;
> span {
display: block;
}
}
}

View File

@ -0,0 +1,44 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import classNames from 'classnames';
import { createElement, forwardRef } from 'react';
/**
* Internal dependencies
*/
import { useTreeItem } from './hooks/use-tree-item';
import { Tree } from './tree';
import { TreeItemProps } from './types';
export const TreeItem = forwardRef( function ForwardedTreeItem(
props: TreeItemProps,
ref: React.ForwardedRef< HTMLLIElement >
) {
const { item, treeItemProps, headingProps, treeProps } = useTreeItem( {
...props,
ref,
} );
return (
<li
{ ...treeItemProps }
className={ classNames(
treeItemProps.className,
'experimental-woocommerce-tree-item'
) }
>
<div
{ ...headingProps }
className="experimental-woocommerce-tree-item__heading"
>
<div className="experimental-woocommerce-tree-item__label">
<span>{ item.data.label }</span>
</div>
</div>
{ Boolean( item.children.length ) && <Tree { ...treeProps } /> }
</li>
);
} );

View File

@ -0,0 +1,15 @@
@import './tree-item.scss';
.experimental-woocommerce-tree {
list-style: none;
padding: 0;
margin: 0;
&--level-1 {
max-height: 280px;
overflow-y: auto;
background-color: $white;
border: 1px solid $gray-400;
border-radius: 2px;
}
}

View File

@ -0,0 +1,42 @@
/**
* External dependencies
*/
import classNames from 'classnames';
import { createElement, forwardRef } from 'react';
/**
* Internal dependencies
*/
import { useTree } from './hooks/use-tree';
import { TreeItem } from './tree-item';
import { TreeProps } from './types';
export const Tree = forwardRef( function ForwardedTree(
props: TreeProps,
ref: React.ForwardedRef< HTMLOListElement >
) {
const { level, items, treeProps, treeItemProps } = useTree( {
...props,
ref,
} );
if ( ! items.length ) return null;
return (
<ol
{ ...treeProps }
className={ classNames(
treeProps.className,
'experimental-woocommerce-tree',
`experimental-woocommerce-tree--level-${ level }`
) }
>
{ items.map( ( child ) => (
<TreeItem
{ ...treeItemProps }
key={ child.data.value }
item={ child }
/>
) ) }
</ol>
);
} );

View File

@ -0,0 +1,31 @@
export interface Item {
parent?: string;
value: string;
label: string;
}
export interface LinkedTree {
parent?: LinkedTree;
data: Item;
children: LinkedTree[];
}
export type TreeProps = React.DetailedHTMLProps<
React.OlHTMLAttributes< HTMLOListElement >,
HTMLOListElement
> & {
level?: number;
items: LinkedTree[];
};
export type TreeItemProps = React.DetailedHTMLProps<
React.LiHTMLAttributes< HTMLLIElement >,
HTMLLIElement
> & {
level: number;
item: LinkedTree;
};
export type TreeControlProps = Omit< TreeProps, 'items' | 'level' > & {
items: Item[];
};

View File

@ -87,8 +87,15 @@ export { CollapsibleContent } from './collapsible-content';
export { createOrderedChildren, sortFillsByOrder } from './utils';
export { WooProductFieldItem as __experimentalWooProductFieldItem } from './woo-product-field-item';
export { WooProductSectionItem as __experimentalWooProductSectionItem } from './woo-product-section-item';
export { WooProductTabItem as __experimentalWooProductTabItem } from './woo-product-tab-item';
export {
ProductSectionLayout as __experimentalProductSectionLayout,
ProductFieldSection as __experimentalProductFieldSection,
} from './product-section-layout';
export * from './product-fields';
export {
SlotContextProvider,
useSlotContext,
SlotContextType,
SlotContextHelpersType,
} from './slot-context';

View File

@ -11,9 +11,9 @@
&__body {
padding: $gap-large;
> .components-base-control,
> .components-dropdown,
> .woocommerce-rich-text-editor {
.components-base-control,
.components-dropdown,
.woocommerce-rich-text-editor {
&:not(:first-child):not(.components-radio-control) {
margin-top: $gap-large - $gap-smaller;
margin-bottom: 0;

View File

@ -0,0 +1 @@
export * from './slot-context';

View File

@ -0,0 +1,104 @@
/**
* External dependencies
*/
import {
createElement,
createContext,
useContext,
useCallback,
useReducer,
} from '@wordpress/element';
type FillConfigType = {
visible: boolean;
};
type FillType = Record< string, FillConfigType >;
type FillCollection = readonly ( readonly JSX.Element[] )[];
export type SlotContextHelpersType = {
hideFill: ( id: string ) => void;
showFill: ( id: string ) => void;
getFills: () => FillType;
};
export type SlotContextType = {
fills: FillType;
getFillHelpers: () => SlotContextHelpersType;
registerFill: ( id: string ) => void;
filterRegisteredFills: ( fillsArrays: FillCollection ) => FillCollection;
};
const SlotContext = createContext< SlotContextType | undefined >( undefined );
export const SlotContextProvider: React.FC = ( { children } ) => {
const [ fills, updateFills ] = useReducer(
( data: FillType, updates: FillType ) => ( { ...data, ...updates } ),
{}
);
const updateFillConfig = (
id: string,
update: Partial< FillConfigType >
) => {
if ( ! fills[ id ] ) {
throw new Error( `No fill found with ID: ${ id }` );
}
updateFills( { [ id ]: { ...fills[ id ], ...update } } );
};
const registerFill = useCallback(
( id: string ) => {
if ( fills[ id ] ) {
return;
}
updateFills( { [ id ]: { visible: true } } );
},
[ fills ]
);
const hideFill = useCallback(
( id: string ) => updateFillConfig( id, { visible: false } ),
[ fills ]
);
const showFill = useCallback(
( id: string ) => updateFillConfig( id, { visible: true } ),
[ fills ]
);
const getFills = useCallback( () => ( { ...fills } ), [ fills ] );
return (
<SlotContext.Provider
value={ {
registerFill,
getFillHelpers() {
return { hideFill, showFill, getFills };
},
filterRegisteredFills( fillsArrays: FillCollection ) {
return fillsArrays.filter(
( arr ) =>
fills[ arr[ 0 ].props._id ]?.visible !== false
);
},
fills,
} }
>
{ children }
</SlotContext.Provider>
);
};
export const useSlotContext = () => {
const slotContext = useContext( SlotContext );
if ( slotContext === undefined ) {
throw new Error(
'useSlotContext must be used within a SlotContextProvider'
);
}
return slotContext;
};

View File

@ -1,6 +1,7 @@
/**
* External Dependencies
*/
@import 'node_modules/@wordpress/base-styles/colors.native';
@import '@automattic/tour-kit/dist/esm/styles.scss';
/**
@ -56,3 +57,4 @@
@import 'collapsible-content/style.scss';
@import 'form/style.scss';
@import 'product-section-layout/style.scss';
@import 'experimental-tree-control/tree.scss';

View File

@ -1,39 +1,32 @@
/**
* External dependencies
*/
import PropTypes from 'prop-types';
import { createElement } from '@wordpress/element';
import React from 'react';
type EmptyTableProps = {
children: React.ReactNode;
/** An integer with the number of rows the box should occupy. */
numberOfRows?: number;
};
/**
* `EmptyTable` displays a blank space with an optional message passed as a children node
* with the purpose of replacing a table with no rows.
* It mimics the same height a table would have according to the `numberOfRows` prop.
*
* @param {Object} props
* @param {Node} props.children
* @param {number} props.numberOfRows
* @return {Object} -
*/
const EmptyTable = ( { children, numberOfRows } ) => {
const EmptyTable = ( { children, numberOfRows = 5 }: EmptyTableProps ) => {
return (
<div
className="woocommerce-table is-empty"
style={ { '--number-of-rows': numberOfRows } }
style={
{ '--number-of-rows': numberOfRows } as React.CSSProperties
}
>
{ children }
</div>
);
};
EmptyTable.propTypes = {
/**
* An integer with the number of rows the box should occupy.
*/
numberOfRows: PropTypes.number,
};
EmptyTable.defaultProps = {
numberOfRows: 5,
};
export default EmptyTable;

View File

@ -1,384 +0,0 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import classnames from 'classnames';
import {
Card,
CardBody,
CardFooter,
CardHeader,
__experimentalText as Text,
} from '@wordpress/components';
import { createElement, Component, Fragment } from '@wordpress/element';
import { find, first, isEqual, without } from 'lodash';
import PropTypes from 'prop-types';
/**
* Internal dependencies
*/
import EllipsisMenu from '../ellipsis-menu';
import MenuItem from '../ellipsis-menu/menu-item';
import MenuTitle from '../ellipsis-menu/menu-title';
import Pagination from '../pagination';
import Table from './table';
import TablePlaceholder from './placeholder';
import TableSummary, { TableSummaryPlaceholder } from './summary';
/**
* This is an accessible, sortable, and scrollable table for displaying tabular data (like revenue and other analytics data).
* It accepts `headers` for column headers, and `rows` for the table content.
* `rowHeader` can be used to define the index of the row header (or false if no header).
*
* `TableCard` serves as Card wrapper & contains a card header, `<Table />`, `<TableSummary />`, and `<Pagination />`.
* This includes filtering and comparison functionality for report pages.
*/
class TableCard extends Component {
constructor( props ) {
super( props );
const showCols = this.getShowCols( props.headers );
this.state = { showCols };
this.onColumnToggle = this.onColumnToggle.bind( this );
this.onPageChange = this.onPageChange.bind( this );
}
componentDidUpdate( { headers: prevHeaders, query: prevQuery } ) {
const { headers, onColumnsChange, query } = this.props;
const { showCols } = this.state;
if ( ! isEqual( headers, prevHeaders ) ) {
/* eslint-disable react/no-did-update-set-state */
this.setState( {
showCols: this.getShowCols( headers ),
} );
/* eslint-enable react/no-did-update-set-state */
}
if (
query.orderby !== prevQuery.orderby &&
! showCols.includes( query.orderby )
) {
const newShowCols = showCols.concat( query.orderby );
/* eslint-disable react/no-did-update-set-state */
this.setState( {
showCols: newShowCols,
} );
/* eslint-enable react/no-did-update-set-state */
onColumnsChange( newShowCols );
}
}
getShowCols( headers ) {
return headers
.map( ( { key, visible } ) => {
if ( typeof visible === 'undefined' || visible ) {
return key;
}
return false;
} )
.filter( Boolean );
}
getVisibleHeaders() {
const { headers } = this.props;
const { showCols } = this.state;
return headers.filter( ( { key } ) => showCols.includes( key ) );
}
getVisibleRows() {
const { headers, rows } = this.props;
const { showCols } = this.state;
return rows.map( ( row ) => {
return headers
.map( ( { key }, i ) => {
return showCols.includes( key ) && row[ i ];
} )
.filter( Boolean );
} );
}
onColumnToggle( key ) {
const { headers, query, onQueryChange, onColumnsChange } = this.props;
return () => {
this.setState( ( prevState ) => {
const hasKey = prevState.showCols.includes( key );
if ( hasKey ) {
// Handle hiding a sorted column
if ( query.orderby === key ) {
const defaultSort =
find( headers, { defaultSort: true } ) ||
first( headers ) ||
{};
onQueryChange( 'sort' )( defaultSort.key, 'desc' );
}
const showCols = without( prevState.showCols, key );
onColumnsChange( showCols, key );
return { showCols };
}
const showCols = [ ...prevState.showCols, key ];
onColumnsChange( showCols, key );
return { showCols };
} );
};
}
onPageChange( ...params ) {
const { onPageChange, onQueryChange } = this.props;
if ( onPageChange ) {
onPageChange( ...params );
}
if ( onQueryChange ) {
onQueryChange( 'paged' )( ...params );
}
}
render() {
const {
actions,
className,
hasSearch,
isLoading,
onQueryChange,
onSort,
query,
rowHeader,
rowsPerPage,
showMenu,
summary,
title,
totalRows,
rowKey,
emptyMessage,
} = this.props;
const { showCols } = this.state;
const allHeaders = this.props.headers;
const headers = this.getVisibleHeaders();
const rows = this.getVisibleRows();
const classes = classnames( 'woocommerce-table', className, {
'has-actions': !! actions,
'has-menu': showMenu,
'has-search': hasSearch,
} );
return (
<Card className={ classes }>
<CardHeader>
<Text size={ 16 } weight={ 600 } as="h2" color="#23282d">
{ title }
</Text>
<div className="woocommerce-table__actions">
{ actions }
</div>
{ showMenu && (
<EllipsisMenu
label={ __(
'Choose which values to display',
'woocommerce'
) }
renderContent={ () => (
<Fragment>
<MenuTitle>
{ __( 'Columns:', 'woocommerce' ) }
</MenuTitle>
{ allHeaders.map(
( { key, label, required } ) => {
if ( required ) {
return null;
}
return (
<MenuItem
checked={ showCols.includes(
key
) }
isCheckbox
isClickable
key={ key }
onInvoke={ this.onColumnToggle(
key
) }
>
{ label }
</MenuItem>
);
}
) }
</Fragment>
) }
/>
) }
</CardHeader>
<CardBody size={ null }>
{ isLoading ? (
<Fragment>
<span className="screen-reader-text">
{ __(
'Your requested data is loading',
'woocommerce'
) }
</span>
<TablePlaceholder
numberOfRows={ rowsPerPage }
headers={ headers }
rowHeader={ rowHeader }
caption={ title }
query={ query }
/>
</Fragment>
) : (
<Table
rows={ rows }
headers={ headers }
rowHeader={ rowHeader }
caption={ title }
query={ query }
onSort={ onSort || onQueryChange( 'sort' ) }
rowKey={ rowKey }
emptyMessage={ emptyMessage }
/>
) }
</CardBody>
<CardFooter justify="center">
{ isLoading ? (
<TableSummaryPlaceholder />
) : (
<Fragment>
<Pagination
key={ parseInt( query.paged, 10 ) || 1 }
page={ parseInt( query.paged, 10 ) || 1 }
perPage={ rowsPerPage }
total={ totalRows }
onPageChange={ this.onPageChange }
onPerPageChange={ onQueryChange( 'per_page' ) }
/>
{ summary && <TableSummary data={ summary } /> }
</Fragment>
) }
</CardFooter>
</Card>
);
}
}
TableCard.propTypes = {
/**
* If a search is provided in actions and should reorder actions on mobile.
*/
hasSearch: PropTypes.bool,
/**
* An array of column headers (see `Table` props).
*/
headers: PropTypes.arrayOf(
PropTypes.shape( {
hiddenByDefault: PropTypes.bool,
defaultSort: PropTypes.bool,
isSortable: PropTypes.bool,
key: PropTypes.string,
label: PropTypes.oneOfType( [ PropTypes.string, PropTypes.node ] ),
required: PropTypes.bool,
} )
),
/**
* A list of IDs, matching to the row list so that ids[ 0 ] contains the object ID for the object displayed in row[ 0 ].
*/
ids: PropTypes.arrayOf( PropTypes.number ),
/**
* Defines if the table contents are loading.
* It will display `TablePlaceholder` component instead of `Table` if that's the case.
*/
isLoading: PropTypes.bool,
/**
* A function which returns a callback function to update the query string for a given `param`.
*/
onQueryChange: PropTypes.func,
/**
* A function which returns a callback function which is called upon the user changing the visiblity of columns.
*/
onColumnsChange: PropTypes.func,
/**
* A function which is called upon the user changing the sorting of the table.
*/
onSort: PropTypes.func,
/**
* An object of the query parameters passed to the page, ex `{ page: 2, per_page: 5 }`.
*/
query: PropTypes.object,
/**
* Which column should be the row header, defaults to the first item (`0`) (but could be set to `1`, if the first col
* is checkboxes, for example). Set to false to disable row headers.
*/
rowHeader: PropTypes.oneOfType( [ PropTypes.number, PropTypes.bool ] ),
/**
* An array of arrays of display/value object pairs (see `Table` props).
*/
rows: PropTypes.arrayOf(
PropTypes.arrayOf(
PropTypes.shape( {
display: PropTypes.node,
value: PropTypes.oneOfType( [
PropTypes.string,
PropTypes.number,
PropTypes.bool,
] ),
} )
)
).isRequired,
/**
* The total number of rows to display per page.
*/
rowsPerPage: PropTypes.number.isRequired,
/**
* Boolean to determine whether or not ellipsis menu is shown.
*/
showMenu: PropTypes.bool,
/**
* An array of objects with `label` & `value` properties, which display in a line under the table.
* Optional, can be left off to show no summary.
*/
summary: PropTypes.arrayOf(
PropTypes.shape( {
label: PropTypes.node,
value: PropTypes.oneOfType( [
PropTypes.string,
PropTypes.number,
] ),
} )
),
/**
* The title used in the card header, also used as the caption for the content in this table.
*/
title: PropTypes.string.isRequired,
/**
* The total number of rows (across all pages).
*/
totalRows: PropTypes.number.isRequired,
/**
* The rowKey used for the key value on each row, this can be a string of the key or a function that returns the value.
* This uses the index if not defined.
*/
rowKey: PropTypes.func,
/**
* Customize the message to show when there are no rows in the table.
*/
emptyMessage: PropTypes.string,
};
TableCard.defaultProps = {
isLoading: false,
onQueryChange: () => () => {},
onColumnsChange: () => {},
onSort: undefined,
query: {},
rowHeader: 0,
rows: [],
showMenu: true,
emptyMessage: undefined,
};
export default TableCard;

View File

@ -0,0 +1,248 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import classnames from 'classnames';
import { createElement, Fragment, useState } from '@wordpress/element';
import { find, first, without } from 'lodash';
import React from 'react';
import {
Card,
CardBody,
CardFooter,
CardHeader,
// @ts-expect-error: Suppressing Module '"@wordpress/components"' has no exported member '__experimentalText'
__experimentalText as Text,
} from '@wordpress/components';
/**
* Internal dependencies
*/
import EllipsisMenu from '../ellipsis-menu';
import MenuItem from '../ellipsis-menu/menu-item';
import MenuTitle from '../ellipsis-menu/menu-title';
import Pagination from '../pagination';
import Table from './table';
import TablePlaceholder from './placeholder';
import TableSummary, { TableSummaryPlaceholder } from './summary';
import { TableCardProps } from './types';
const defaultOnQueryChange =
( param: string ) => ( path?: string, direction?: string ) => {};
const defaultOnColumnsChange = (
showCols: Array< string >,
key?: string
) => {};
/**
* This is an accessible, sortable, and scrollable table for displaying tabular data (like revenue and other analytics data).
* It accepts `headers` for column headers, and `rows` for the table content.
* `rowHeader` can be used to define the index of the row header (or false if no header).
*
* `TableCard` serves as Card wrapper & contains a card header, `<Table />`, `<TableSummary />`, and `<Pagination />`.
* This includes filtering and comparison functionality for report pages.
*/
const TableCard: React.VFC< TableCardProps > = ( {
actions,
className,
hasSearch,
headers = [],
ids,
isLoading = false,
onQueryChange = defaultOnQueryChange,
onColumnsChange = defaultOnColumnsChange,
onSort,
query = {},
rowHeader = 0,
rows = [],
rowsPerPage,
showMenu = true,
summary,
title,
totalRows,
rowKey,
emptyMessage = undefined,
...props
} ) => {
// eslint-disable-next-line no-console
const getShowCols = ( _headers: TableCardProps[ 'headers' ] = [] ) => {
return _headers
.map( ( { key, visible } ) => {
if ( typeof visible === 'undefined' || visible ) {
return key;
}
return false;
} )
.filter( Boolean ) as string[];
};
const [ showCols, setShowCols ] = useState( getShowCols( headers ) );
const onColumnToggle = ( key: string ) => {
return () => {
const hasKey = showCols.includes( key );
if ( hasKey ) {
// Handle hiding a sorted column
if ( query.orderby === key ) {
const defaultSort = find( headers, {
defaultSort: true,
} ) ||
first( headers ) || { key: undefined };
onQueryChange( 'sort' )( defaultSort.key, 'desc' );
}
const newShowCols = without( showCols, key );
onColumnsChange( newShowCols, key );
setShowCols( newShowCols );
} else {
const newShowCols = [ ...showCols, key ] as string[];
onColumnsChange( newShowCols, key );
setShowCols( newShowCols );
}
};
};
const onPageChange = (
newPage: string,
direction?: 'previous' | 'next'
) => {
if ( props.onPageChange ) {
props.onPageChange( parseInt( newPage, 10 ), direction );
}
if ( onQueryChange ) {
onQueryChange( 'paged' )( newPage, direction );
}
};
const allHeaders = headers;
const visibleHeaders = headers.filter( ( { key } ) =>
showCols.includes( key )
);
const visibleRows = rows.map( ( row ) => {
return headers
.map( ( { key }, i ) => {
return showCols.includes( key ) && row[ i ];
} )
.filter( Boolean );
} );
const classes = classnames( 'woocommerce-table', className, {
'has-actions': !! actions,
'has-menu': showMenu,
'has-search': hasSearch,
} );
return (
<Card className={ classes }>
<CardHeader>
<Text size={ 16 } weight={ 600 } as="h2" color="#23282d">
{ title }
</Text>
<div className="woocommerce-table__actions">{ actions }</div>
{ showMenu && (
<EllipsisMenu
label={ __(
'Choose which values to display',
'woocommerce'
) }
renderContent={ () => (
<Fragment>
{ /* @ts-expect-error: Ignoring the error until we migrate ellipsis-menu to TS*/ }
<MenuTitle>
{ /* @ts-expect-error: Allow string */ }
{ __( 'Columns:', 'woocommerce' ) }
</MenuTitle>
{ allHeaders.map(
( { key, label, required } ) => {
if ( required ) {
return null;
}
return (
<MenuItem
checked={ showCols.includes(
key
) }
isCheckbox
isClickable
key={ key }
onInvoke={
key !== undefined
? onColumnToggle( key )
: undefined
}
>
{ label }
</MenuItem>
);
}
) }
</Fragment>
) }
/>
) }
</CardHeader>
{ /* Ignoring the error to make it backward compatible for now. */ }
{ /* @ts-expect-error: size must be one of small, medium, largel, xSmall, extraSmall. */ }
<CardBody size={ null }>
{ isLoading ? (
<Fragment>
<span className="screen-reader-text">
{ __(
'Your requested data is loading',
'woocommerce'
) }
</span>
<TablePlaceholder
numberOfRows={ rowsPerPage }
headers={ visibleHeaders }
rowHeader={ rowHeader }
caption={ title }
query={ query }
/>
</Fragment>
) : (
<Table
rows={ visibleRows as TableCardProps[ 'rows' ] }
headers={
visibleHeaders as TableCardProps[ 'headers' ]
}
rowHeader={ rowHeader }
caption={ title }
query={ query }
onSort={
onSort ||
( onQueryChange( 'sort' ) as (
key: string,
direction: string
) => void )
}
rowKey={ rowKey }
emptyMessage={ emptyMessage }
/>
) }
</CardBody>
{ /* @ts-expect-error: justify is missing from the latest @types/wordpress__components */ }
<CardFooter justify="center">
{ isLoading ? (
<TableSummaryPlaceholder />
) : (
<Fragment>
<Pagination
key={ parseInt( query.paged as string, 10 ) || 1 }
page={ parseInt( query.paged as string, 10 ) || 1 }
perPage={ rowsPerPage }
total={ totalRows }
onPageChange={ onPageChange }
onPerPageChange={ onQueryChange( 'per_page' ) }
/>
{ summary && <TableSummary data={ summary } /> }
</Fragment>
) }
</CardFooter>
</Card>
);
};
export default TableCard;

View File

@ -1,68 +0,0 @@
/**
* External dependencies
*/
import { createElement, Component } from '@wordpress/element';
import { range } from 'lodash';
import PropTypes from 'prop-types';
/**
* Internal dependencies
*/
import Table from './table';
/**
* `TablePlaceholder` behaves like `Table` but displays placeholder boxes instead of data. This can be used while loading.
*/
class TablePlaceholder extends Component {
render() {
const { numberOfRows, ...tableProps } = this.props;
const rows = range( numberOfRows ).map( () =>
this.props.headers.map( () => ( {
display: <span className="is-placeholder" />,
} ) )
);
return (
<Table
ariaHidden={ true }
className="is-loading"
rows={ rows }
{ ...tableProps }
/>
);
}
}
TablePlaceholder.propTypes = {
/**
* An object of the query parameters passed to the page, ex `{ page: 2, per_page: 5 }`.
*/
query: PropTypes.object,
/**
* A label for the content in this table.
*/
caption: PropTypes.string.isRequired,
/**
* An array of column headers (see `Table` props).
*/
headers: PropTypes.arrayOf(
PropTypes.shape( {
hiddenByDefault: PropTypes.bool,
defaultSort: PropTypes.bool,
isSortable: PropTypes.bool,
key: PropTypes.string,
label: PropTypes.node,
required: PropTypes.bool,
} )
),
/**
* An integer with the number of rows to display.
*/
numberOfRows: PropTypes.number,
};
TablePlaceholder.defaultProps = {
numberOfRows: 5,
};
export default TablePlaceholder;

View File

@ -0,0 +1,55 @@
/**
* External dependencies
*/
import { createElement } from '@wordpress/element';
import { range } from 'lodash';
/**
* Internal dependencies
*/
import Table from './table';
import { QueryProps, TableHeader } from './types';
type TablePlaceholderProps = {
/** An object of the query parameters passed to the page */
query?: QueryProps;
/** A label for the content in this table. */
caption: string;
/** An integer with the number of rows to display. */
numberOfRows?: number;
/**
* Which column should be the row header, defaults to the first item (`0`) (but could be set to `1`, if the first col
* is checkboxes, for example). Set to false to disable row headers.
*/
rowHeader?: number | false;
/** An array of column headers (see `Table` props). */
headers: Array< TableHeader >;
};
/**
* `TablePlaceholder` behaves like `Table` but displays placeholder boxes instead of data. This can be used while loading.
*/
const TablePlaceholder: React.VFC< TablePlaceholderProps > = ( {
query,
caption,
headers,
numberOfRows = 5,
...props
} ) => {
const rows = range( numberOfRows ).map( () =>
headers.map( () => ( {
display: <span className="is-placeholder" />,
} ) )
);
const tableProps = { query, caption, headers, numberOfRows, ...props };
return (
<Table
ariaHidden={ true }
className="is-loading"
rows={ rows }
{ ...tableProps }
/>
);
};
export default TablePlaceholder;

View File

@ -2,6 +2,7 @@
* External dependencies
*/
import { EmptyTable } from '@woocommerce/components';
import { createElement } from '@wordpress/element';
export const Basic = () => <EmptyTable>There are no entries.</EmptyTable>;

View File

@ -1,42 +0,0 @@
/**
* External dependencies
*/
import { TableCard } from '@woocommerce/components';
import { useState } from '@wordpress/element';
/**
* Internal dependencies
*/
import { headers, rows, summary } from './index';
const TableCardExample = () => {
const [ { query }, setState ] = useState( {
query: {
paged: 1,
},
} );
return (
<TableCard
title="Revenue last week"
rows={ rows }
headers={ headers }
onQueryChange={ ( param ) => ( value ) =>
setState( {
query: {
[ param ]: value,
},
} ) }
query={ query }
rowsPerPage={ 7 }
totalRows={ 10 }
summary={ summary }
/>
);
};
export const Basic = () => <TableCardExample />;
export default {
title: 'WooCommerce Admin/components/TableCard',
component: TableCard,
};

View File

@ -0,0 +1,93 @@
/**
* External dependencies
*/
import { TableCard } from '@woocommerce/components';
import { useState, createElement } from '@wordpress/element';
import { Button } from '@wordpress/components';
/**
* Internal dependencies
*/
import { headers, rows, summary } from './index';
const TableCardExample = () => {
const [ { query }, setState ] = useState( {
query: {
paged: 1,
},
} );
return (
<TableCard
title="Revenue last week"
rows={ rows }
headers={ headers }
onQueryChange={ ( param ) => ( value ) =>
setState( {
// @ts-expect-error: ignore for storybook
query: {
[ param ]: value,
},
} ) }
query={ query }
rowsPerPage={ 7 }
totalRows={ 10 }
summary={ summary }
/>
);
};
const TableCardWithActionsExample = () => {
const [ { query }, setState ] = useState( {
query: {
paged: 1,
},
} );
const [ action1Text, setAction1Text ] = useState( 'Action 1' );
const [ action2Text, setAction2Text ] = useState( 'Action 2' );
return (
<TableCard
actions={ [
<Button
key={ 0 }
onClick={ () => {
setAction1Text( 'Action 1 Clicked' );
} }
>
{ action1Text }
</Button>,
<Button
key={ 0 }
onClick={ () => {
setAction2Text( 'Action 2 Clicked' );
} }
>
{ action2Text }
</Button>,
] }
title="Revenue last week"
rows={ rows }
headers={ headers }
onQueryChange={ ( param ) => ( value ) =>
setState( {
// @ts-expect-error: ignore for storybook
query: {
[ param ]: value,
},
} ) }
query={ query }
rowsPerPage={ 7 }
totalRows={ 10 }
summary={ summary }
/>
);
};
export const Basic = () => <TableCardExample />;
export const Actions = () => <TableCardWithActionsExample />;
export default {
title: 'WooCommerce Admin/components/TableCard',
component: TableCard,
};

View File

@ -3,17 +3,21 @@
*/
import { Card } from '@wordpress/components';
import { TablePlaceholder } from '@woocommerce/components';
import { createElement } from '@wordpress/element';
/**
* Internal dependencies
*/
import { headers } from './index';
export const Basic = () => (
<Card size={ null }>
<TablePlaceholder caption="Revenue last week" headers={ headers } />
</Card>
);
export const Basic = () => {
return (
/* @ts-expect-error: size must be one of small, medium, largel, xSmall, extraSmall. */
<Card size={ null }>
<TablePlaceholder caption="Revenue last week" headers={ headers } />
</Card>
);
};
export default {
title: 'WooCommerce Admin/components/TablePlaceholder',

View File

@ -3,14 +3,18 @@
*/
import { Card, CardFooter } from '@wordpress/components';
import { TableSummaryPlaceholder } from '@woocommerce/components';
import { createElement } from '@wordpress/element';
export const Basic = () => (
<Card>
<CardFooter justify="center">
<TableSummaryPlaceholder />
</CardFooter>
</Card>
);
export const Basic = () => {
return (
<Card>
{ /* @ts-expect-error: justify is missing from the latest type def. */ }
<CardFooter justify="center">
<TableSummaryPlaceholder />
</CardFooter>
</Card>
);
};
export default {
title: 'WooCommerce Admin/components/TableSummaryPlaceholder',

View File

@ -3,6 +3,7 @@
*/
import { Card } from '@wordpress/components';
import { Table } from '@woocommerce/components';
import { createElement } from '@wordpress/element';
/**
* Internal dependencies
@ -20,17 +21,20 @@ export const Basic = () => (
</Card>
);
export const NoDataCustomMessage = () => (
<Card size={ null }>
<Table
caption="Revenue last week"
rows={ [] }
headers={ headers }
rowKey={ ( row ) => row[ 0 ].value }
emptyMessage="Custom empty message"
/>
</Card>
);
export const NoDataCustomMessage = () => {
return (
/* @ts-expect-error: size must be one of small, medium, largel, xSmall, extraSmall. */
<Card size={ null }>
<Table
caption="Revenue last week"
rows={ [] }
headers={ headers }
rowKey={ ( row ) => row[ 0 ].value }
emptyMessage="Custom empty message"
/>
</Card>
);
};
export default {
title: 'WooCommerce Admin/components/Table',

View File

@ -1,17 +1,17 @@
/**
* External dependencies
*/
import PropTypes from 'prop-types';
import { createElement } from '@wordpress/element';
/**
* A component to display summarized table data - the list of data passed in on a single line.
*
* @param {Object} props
* @param {Array} props.data
* @return {Object} -
* Internal dependencies
*/
const TableSummary = ( { data } ) => {
import { TableSummaryProps } from './types';
/**
* A component to display summarized table data - the list of data passed in on a single line.
*/
const TableSummary = ( { data }: TableSummaryProps ) => {
return (
<ul className="woocommerce-table__summary" role="complementary">
{ data.map( ( { label, value }, i ) => (
@ -28,13 +28,6 @@ const TableSummary = ( { data } ) => {
);
};
TableSummary.propTypes = {
/**
* An array of objects with `label` & `value` properties, which display on a single line.
*/
data: PropTypes.array,
};
export default TableSummary;
/**

View File

@ -1,491 +0,0 @@
/**
* External dependencies
*/
import { __, sprintf } from '@wordpress/i18n';
import {
createElement,
Component,
createRef,
Fragment,
} from '@wordpress/element';
import classnames from 'classnames';
import { Button } from '@wordpress/components';
import { find, get, noop } from 'lodash';
import PropTypes from 'prop-types';
import { withInstanceId } from '@wordpress/compose';
import { Icon, chevronUp, chevronDown } from '@wordpress/icons';
import deprecated from '@wordpress/deprecated';
const ASC = 'asc';
const DESC = 'desc';
const getDisplay = ( cell ) => cell.display || null;
/**
* A table component, without the Card wrapper. This is a basic table display, sortable, but no default filtering.
*
* Row data should be passed to the component as a list of arrays, where each array is a row in the table.
* Headers are passed in separately as an array of objects with column-related properties. For example,
* this data would render the following table.
*
* ```js
* const headers = [ { label: 'Month' }, { label: 'Orders' }, { label: 'Revenue' } ];
* const rows = [
* [
* { display: 'January', value: 1 },
* { display: 10, value: 10 },
* { display: '$530.00', value: 530 },
* ],
* [
* { display: 'February', value: 2 },
* { display: 13, value: 13 },
* { display: '$675.00', value: 675 },
* ],
* [
* { display: 'March', value: 3 },
* { display: 9, value: 9 },
* { display: '$460.00', value: 460 },
* ],
* ]
* ```
*
* | Month | Orders | Revenue |
* | ---------|--------|---------|
* | January | 10 | $530.00 |
* | February | 13 | $675.00 |
* | March | 9 | $460.00 |
*/
class Table extends Component {
constructor( props ) {
super( props );
this.state = {
tabIndex: null,
isScrollableRight: false,
isScrollableLeft: false,
};
this.container = createRef();
this.sortBy = this.sortBy.bind( this );
this.updateTableShadow = this.updateTableShadow.bind( this );
this.getRowKey = this.getRowKey.bind( this );
}
componentDidMount() {
const { scrollWidth, clientWidth } = this.container.current;
const scrollable = scrollWidth > clientWidth;
/* eslint-disable react/no-did-mount-set-state */
this.setState( {
tabIndex: scrollable ? '0' : null,
} );
/* eslint-enable react/no-did-mount-set-state */
this.updateTableShadow();
window.addEventListener( 'resize', this.updateTableShadow );
}
componentDidUpdate() {
this.updateTableShadow();
}
componentWillUnmount() {
window.removeEventListener( 'resize', this.updateTableShadow );
}
sortBy( key ) {
const { headers, query } = this.props;
return () => {
const currentKey =
query.orderby ||
get( find( headers, { defaultSort: true } ), 'key', false );
const currentDir =
query.order ||
get(
find( headers, { key: currentKey } ),
'defaultOrder',
DESC
);
let dir = DESC;
if ( key === currentKey ) {
dir = DESC === currentDir ? ASC : DESC;
}
this.props.onSort( key, dir );
};
}
updateTableShadow() {
const table = this.container.current;
const { isScrollableRight, isScrollableLeft } = this.state;
const scrolledToEnd =
table.scrollWidth - table.scrollLeft <= table.offsetWidth;
if ( scrolledToEnd && isScrollableRight ) {
this.setState( { isScrollableRight: false } );
} else if ( ! scrolledToEnd && ! this.state.isScrollableRight ) {
this.setState( { isScrollableRight: true } );
}
const scrolledToStart = table.scrollLeft <= 0;
if ( scrolledToStart && isScrollableLeft ) {
this.setState( { isScrollableLeft: false } );
} else if ( ! scrolledToStart && ! isScrollableLeft ) {
this.setState( { isScrollableLeft: true } );
}
}
getRowKey( row, index ) {
if ( this.props.rowKey && typeof this.props.rowKey === 'function' ) {
return this.props.rowKey( row, index );
}
return index;
}
render() {
const {
ariaHidden,
caption,
className,
classNames,
headers,
instanceId,
query,
rowHeader,
rows,
emptyMessage,
} = this.props;
const { isScrollableRight, isScrollableLeft, tabIndex } = this.state;
if ( classNames ) {
deprecated( `Table component's classNames prop`, {
since: '11.1.0',
version: '12.0.0',
alternative: 'className',
plugin: '@woocommerce/components',
} );
}
const classes = classnames(
'woocommerce-table__table',
classNames,
className,
{
'is-scrollable-right': isScrollableRight,
'is-scrollable-left': isScrollableLeft,
}
);
const sortedBy =
query.orderby ||
get( find( headers, { defaultSort: true } ), 'key', false );
const sortDir =
query.order ||
get( find( headers, { key: sortedBy } ), 'defaultOrder', DESC );
const hasData = !! rows.length;
return (
<div
className={ classes }
ref={ this.container }
tabIndex={ tabIndex }
aria-hidden={ ariaHidden }
aria-labelledby={ `caption-${ instanceId }` }
role="group"
onScroll={ this.updateTableShadow }
>
<table>
<caption
id={ `caption-${ instanceId }` }
className="woocommerce-table__caption screen-reader-text"
>
{ caption }
{ tabIndex === '0' && (
<small>
{ __( '(scroll to see more)', 'woocommerce' ) }
</small>
) }
</caption>
<tbody>
<tr>
{ headers.map( ( header, i ) => {
const {
cellClassName,
isLeftAligned,
isSortable,
isNumeric,
key,
label,
screenReaderLabel,
} = header;
const labelId = `header-${ instanceId }-${ i }`;
const thProps = {
className: classnames(
'woocommerce-table__header',
cellClassName,
{
'is-left-aligned':
isLeftAligned || ! isNumeric,
'is-sortable': isSortable,
'is-sorted': sortedBy === key,
'is-numeric': isNumeric,
}
),
};
if ( isSortable ) {
thProps[ 'aria-sort' ] = 'none';
if ( sortedBy === key ) {
thProps[ 'aria-sort' ] =
sortDir === ASC
? 'ascending'
: 'descending';
}
}
// We only sort by ascending if the col is already sorted descending
const iconLabel =
sortedBy === key && sortDir !== ASC
? sprintf(
__(
'Sort by %s in ascending order',
'woocommerce'
),
screenReaderLabel || label
)
: sprintf(
__(
'Sort by %s in descending order',
'woocommerce'
),
screenReaderLabel || label
);
const textLabel = (
<Fragment>
<span
aria-hidden={ Boolean(
screenReaderLabel
) }
>
{ label }
</span>
{ screenReaderLabel && (
<span className="screen-reader-text">
{ screenReaderLabel }
</span>
) }
</Fragment>
);
return (
<th
role="columnheader"
scope="col"
key={ header.key || i }
{ ...thProps }
>
{ isSortable ? (
<Fragment>
<Button
aria-describedby={ labelId }
onClick={
hasData
? this.sortBy( key )
: noop
}
>
{ sortedBy === key &&
sortDir === ASC ? (
<Icon
icon={ chevronUp }
/>
) : (
<Icon
icon={ chevronDown }
/>
) }
{ textLabel }
</Button>
<span
className="screen-reader-text"
id={ labelId }
>
{ iconLabel }
</span>
</Fragment>
) : (
textLabel
) }
</th>
);
} ) }
</tr>
{ hasData ? (
rows.map( ( row, i ) => (
<tr key={ this.getRowKey( row, i ) }>
{ row.map( ( cell, j ) => {
const {
cellClassName,
isLeftAligned,
isNumeric,
} = headers[ j ];
const isHeader = rowHeader === j;
const Cell = isHeader ? 'th' : 'td';
const cellClasses = classnames(
'woocommerce-table__item',
cellClassName,
{
'is-left-aligned':
isLeftAligned ||
! isNumeric,
'is-numeric': isNumeric,
'is-sorted':
sortedBy ===
headers[ j ].key,
}
);
const cellKey =
this.getRowKey(
row,
i
).toString() + j;
return (
<Cell
scope={
isHeader ? 'row' : null
}
key={ cellKey }
className={ cellClasses }
>
{ getDisplay( cell ) }
</Cell>
);
} ) }
</tr>
) )
) : (
<tr>
<td
className="woocommerce-table__empty-item"
colSpan={ headers.length }
>
{ emptyMessage ??
__(
'No data to display',
'woocommerce'
) }
</td>
</tr>
) }
</tbody>
</table>
</div>
);
}
}
Table.propTypes = {
/**
* Controls whether this component is hidden from screen readers. Used by the loading state, before there is data to read.
* Don't use this on real tables unless the table data is loaded elsewhere on the page.
*/
ariaHidden: PropTypes.bool,
/**
* A label for the content in this table
*/
caption: PropTypes.string.isRequired,
/**
* Additional CSS classes.
*/
className: PropTypes.string,
/**
* An array of column headers, as objects.
*/
headers: PropTypes.arrayOf(
PropTypes.shape( {
/**
* Boolean, true if this column is the default for sorting. Only one column should have this set.
*/
defaultSort: PropTypes.bool,
/**
* String, asc|desc if this column is the default for sorting. Only one column should have this set.
*/
defaultOrder: PropTypes.string,
/**
* Boolean, true if this column should be aligned to the left.
*/
isLeftAligned: PropTypes.bool,
/**
* Boolean, true if this column is a number value.
*/
isNumeric: PropTypes.bool,
/**
* Boolean, true if this column is sortable.
*/
isSortable: PropTypes.bool,
/**
* The API parameter name for this column, passed to `orderby` when sorting via API.
*/
key: PropTypes.string,
/**
* The display label for this column.
*/
label: PropTypes.node,
/**
* Boolean, true if this column should always display in the table (not shown in toggle-able list).
*/
required: PropTypes.bool,
/**
* The label used for screen readers for this column.
*/
screenReaderLabel: PropTypes.string,
} )
),
/**
* A function called when sortable table headers are clicked, gets the `header.key` as argument.
*/
onSort: PropTypes.func,
/**
* The query string represented in object form
*/
query: PropTypes.object,
/**
* An array of arrays of display/value object pairs.
*/
rows: PropTypes.arrayOf(
PropTypes.arrayOf(
PropTypes.shape( {
/**
* Display value, used for rendering- strings or elements are best here.
*/
display: PropTypes.node,
/**
* "Real" value used for sorting, and should be a string or number. A column with `false` value will not be sortable.
*/
value: PropTypes.oneOfType( [
PropTypes.string,
PropTypes.number,
PropTypes.bool,
] ),
} )
)
).isRequired,
/**
* Which column should be the row header, defaults to the first item (`0`) (but could be set to `1`, if the first col
* is checkboxes, for example). Set to false to disable row headers.
*/
rowHeader: PropTypes.oneOfType( [ PropTypes.number, PropTypes.bool ] ),
/**
* The rowKey used for the key value on each row, a function that returns the key.
* Defaults to index.
*/
rowKey: PropTypes.func,
/**
* Customize the message to show when there are no rows in the table.
*/
emptyMessage: PropTypes.string,
};
Table.defaultProps = {
ariaHidden: false,
headers: [],
onSort: noop,
query: {},
rowHeader: 0,
emptyMessage: undefined,
};
export default withInstanceId( Table );

View File

@ -0,0 +1,374 @@
/**
* External dependencies
*/
import { __, sprintf } from '@wordpress/i18n';
import {
createElement,
useRef,
Fragment,
useState,
useEffect,
} from '@wordpress/element';
import classnames from 'classnames';
import { Button } from '@wordpress/components';
import { find, get, noop } from 'lodash';
import { withInstanceId } from '@wordpress/compose';
import { Icon, chevronUp, chevronDown } from '@wordpress/icons';
import deprecated from '@wordpress/deprecated';
import React from 'react';
/**
* Internal dependencies
*/
import { TableRow, TableProps } from './types';
const ASC = 'asc';
const DESC = 'desc';
const getDisplay = ( cell: { display?: React.ReactNode } ) =>
cell.display || null;
/**
* A table component, without the Card wrapper. This is a basic table display, sortable, but no default filtering.
*
* Row data should be passed to the component as a list of arrays, where each array is a row in the table.
* Headers are passed in separately as an array of objects with column-related properties. For example,
* this data would render the following table.
*
* ```js
* const headers = [ { label: 'Month' }, { label: 'Orders' }, { label: 'Revenue' } ];
* const rows = [
* [
* { display: 'January', value: 1 },
* { display: 10, value: 10 },
* { display: '$530.00', value: 530 },
* ],
* [
* { display: 'February', value: 2 },
* { display: 13, value: 13 },
* { display: '$675.00', value: 675 },
* ],
* [
* { display: 'March', value: 3 },
* { display: 9, value: 9 },
* { display: '$460.00', value: 460 },
* ],
* ]
* ```
*
* | Month | Orders | Revenue |
* | ---------|--------|---------|
* | January | 10 | $530.00 |
* | February | 13 | $675.00 |
* | March | 9 | $460.00 |
*/
const Table: React.VFC< TableProps > = ( {
instanceId,
headers = [],
rows = [],
ariaHidden,
caption,
className,
onSort = ( f ) => f,
query = {},
rowHeader,
rowKey,
emptyMessage,
...props
} ) => {
const { classNames } = props;
const [ tabIndex, setTabIndex ] = useState< number | undefined >(
undefined
);
const [ isScrollableRight, setIsScrollableRight ] = useState( false );
const [ isScrollableLeft, setIsScrollableLeft ] = useState( false );
const container = useRef< HTMLDivElement >( null );
if ( classNames ) {
deprecated( `Table component's classNames prop`, {
since: '11.1.0',
version: '12.0.0',
alternative: 'className',
plugin: '@woocommerce/components',
} );
}
const classes = classnames(
'woocommerce-table__table',
classNames,
className,
{
'is-scrollable-right': isScrollableRight,
'is-scrollable-left': isScrollableLeft,
}
);
const sortBy = ( key: string ) => {
return () => {
const currentKey =
query.orderby ||
get( find( headers, { defaultSort: true } ), 'key', false );
const currentDir =
query.order ||
get(
find( headers, { key: currentKey } ),
'defaultOrder',
DESC
);
let dir = DESC;
if ( key === currentKey ) {
dir = DESC === currentDir ? ASC : DESC;
}
onSort( key, dir );
};
};
const getRowKey = ( row: TableRow[], index: number ) => {
if ( rowKey && typeof rowKey === 'function' ) {
return rowKey( row, index );
}
return index;
};
const updateTableShadow = () => {
const table = container.current;
if ( table?.scrollWidth && table?.scrollHeight && table?.offsetWidth ) {
const scrolledToEnd =
table?.scrollWidth - table?.scrollLeft <= table?.offsetWidth;
if ( scrolledToEnd && isScrollableRight ) {
setIsScrollableRight( false );
} else if ( ! scrolledToEnd && ! isScrollableRight ) {
setIsScrollableRight( true );
}
}
if ( table?.scrollLeft ) {
const scrolledToStart = table?.scrollLeft <= 0;
if ( scrolledToStart && isScrollableLeft ) {
setIsScrollableLeft( false );
} else if ( ! scrolledToStart && ! isScrollableLeft ) {
setIsScrollableLeft( true );
}
}
};
const sortedBy =
query.orderby ||
get( find( headers, { defaultSort: true } ), 'key', false );
const sortDir =
query.order ||
get( find( headers, { key: sortedBy } ), 'defaultOrder', DESC );
const hasData = !! rows.length;
useEffect( () => {
const scrollWidth = container.current?.scrollWidth;
const clientWidth = container.current?.clientWidth;
if ( scrollWidth === undefined || clientWidth === undefined ) {
return;
}
const scrollable = scrollWidth > clientWidth;
setTabIndex( scrollable ? 0 : undefined );
updateTableShadow();
window.addEventListener( 'resize', updateTableShadow );
return () => {
window.removeEventListener( 'resize', updateTableShadow );
};
}, [] );
useEffect( updateTableShadow, [ headers, rows, emptyMessage ] );
return (
<div
className={ classes }
ref={ container }
tabIndex={ tabIndex }
aria-hidden={ ariaHidden }
aria-labelledby={ `caption-${ instanceId }` }
role="group"
onScroll={ updateTableShadow }
>
<table>
<caption
id={ `caption-${ instanceId }` }
className="woocommerce-table__caption screen-reader-text"
>
{ caption }
{ tabIndex === 0 && (
<small>
{ __( '(scroll to see more)', 'woocommerce' ) }
</small>
) }
</caption>
<tbody>
<tr>
{ headers.map( ( header, i ) => {
const {
cellClassName,
isLeftAligned,
isSortable,
isNumeric,
key,
label,
screenReaderLabel,
} = header;
const labelId = `header-${ instanceId }-${ i }`;
const thProps: { [ key: string ]: string } = {
className: classnames(
'woocommerce-table__header',
cellClassName,
{
'is-left-aligned':
isLeftAligned || ! isNumeric,
'is-sortable': isSortable,
'is-sorted': sortedBy === key,
'is-numeric': isNumeric,
}
),
};
if ( isSortable ) {
thProps[ 'aria-sort' ] = 'none';
if ( sortedBy === key ) {
thProps[ 'aria-sort' ] =
sortDir === ASC
? 'ascending'
: 'descending';
}
}
// We only sort by ascending if the col is already sorted descending
const iconLabel =
sortedBy === key && sortDir !== ASC
? sprintf(
__(
'Sort by %s in ascending order',
'woocommerce'
),
screenReaderLabel || label
)
: sprintf(
__(
'Sort by %s in descending order',
'woocommerce'
),
screenReaderLabel || label
);
const textLabel = (
<Fragment>
<span
aria-hidden={ Boolean(
screenReaderLabel
) }
>
{ label }
</span>
{ screenReaderLabel && (
<span className="screen-reader-text">
{ screenReaderLabel }
</span>
) }
</Fragment>
);
return (
<th
role="columnheader"
scope="col"
key={ header.key || i }
{ ...thProps }
>
{ isSortable ? (
<Fragment>
<Button
aria-describedby={ labelId }
onClick={
hasData
? sortBy( key )
: noop
}
>
{ sortedBy === key &&
sortDir === ASC ? (
<Icon icon={ chevronUp } />
) : (
<Icon
icon={ chevronDown }
/>
) }
{ textLabel }
</Button>
<span
className="screen-reader-text"
id={ labelId }
>
{ iconLabel }
</span>
</Fragment>
) : (
textLabel
) }
</th>
);
} ) }
</tr>
{ hasData ? (
rows.map( ( row, i ) => (
<tr key={ getRowKey( row, i ) }>
{ row.map( ( cell, j ) => {
const {
cellClassName,
isLeftAligned,
isNumeric,
} = headers[ j ];
const isHeader = rowHeader === j;
const Cell = isHeader ? 'th' : 'td';
const cellClasses = classnames(
'woocommerce-table__item',
cellClassName,
{
'is-left-aligned':
isLeftAligned || ! isNumeric,
'is-numeric': isNumeric,
'is-sorted':
sortedBy === headers[ j ].key,
}
);
const cellKey =
getRowKey( row, i ).toString() + j;
return (
<Cell
scope={
isHeader ? 'row' : undefined
}
key={ cellKey }
className={ cellClasses }
>
{ getDisplay( cell ) }
</Cell>
);
} ) }
</tr>
) )
) : (
<tr>
<td
className="woocommerce-table__empty-item"
colSpan={ headers.length }
>
{ emptyMessage ??
__( 'No data to display', 'woocommerce' ) }
</td>
</tr>
) }
</tbody>
</table>
</div>
);
};
export default withInstanceId( Table );

View File

@ -0,0 +1,189 @@
export type QueryProps = {
orderby?: string;
order?: string;
page?: number;
per_page?: number;
/**
* Allowing string for backward compatibility
*/
paged?: number | string;
};
export type TableHeader = {
/**
* Boolean, true if this column is the default for sorting. Only one column should have this set.
*/
defaultSort?: boolean;
/**
* String, asc|desc if this column is the default for sorting. Only one column should have this set.
*/
defaultOrder?: string;
/**
* Boolean, true if this column should be aligned to the left.
*/
isLeftAligned?: boolean;
/**
* Boolean, true if this column is a number value.
*/
isNumeric?: boolean;
/**
* Boolean, true if this column is sortable.
*/
isSortable?: boolean;
/**
* The API parameter name for this column, passed to `orderby` when sorting via API.
*/
key: string;
/**
* The display label for this column.
*/
label?: React.ReactNode;
/**
* Boolean, true if this column should always display in the table (not shown in toggle-able list).
*/
required?: boolean;
/**
* The label used for screen readers for this column.
*/
screenReaderLabel?: string;
/**
* Additional classname for the header cell
*/
cellClassName?: string;
/**
* Boolean value to control visibility of a header
*/
visible?: boolean;
};
export type TableRow = {
/**
* Display value, used for rendering- strings or elements are best here.
*/
display?: React.ReactNode;
/**
* "Real" value used for sorting, and should be a string or number. A column with `false` value will not be sortable.
*/
value?: string | number | boolean;
};
/**
* Props shared between TableProps and TableCardProps.
*/
type CommonTableProps = {
/**
* The rowKey used for the key value on each row, a function that returns the key.
* Defaults to index.
*/
rowKey?: ( row: TableRow[], index: number ) => number;
/**
* Customize the message to show when there are no rows in the table.
*/
emptyMessage?: string;
/**
* The query string represented in object form
*/
query?: QueryProps;
/**
* Which column should be the row header, defaults to the first item (`0`) (but could be set to `1`, if the first col
* is checkboxes, for example). Set to false to disable row headers.
*/
rowHeader?: number | false;
/**
* An array of column headers (see `Table` props).
*/
headers?: Array< TableHeader >;
/**
* An array of arrays of display/value object pairs (see `Table` props).
*/
rows?: Array< Array< TableRow > >;
/**
* Additional CSS classes.
*/
className?: string;
/**
* A function called when sortable table headers are clicked, gets the `header.key` as argument.
*/
onSort?: ( key: string, direction: string ) => void;
};
export type TableProps = CommonTableProps & {
/** A unique ID for this instance of the component. This is automatically generated by withInstanceId. */
instanceId: number | string;
/**
* Controls whether this component is hidden from screen readers. Used by the loading state, before there is data to read.
* Don't use this on real tables unless the table data is loaded elsewhere on the page.
*/
ariaHidden?: boolean;
/**
* A label for the content in this table
*/
caption?: string;
/**
* Additional classnames
*/
classNames?: string | Record< string, string >;
};
export type TableSummaryProps = {
// An array of objects with `label` & `value` properties, which display on a single line.
data: Array< {
label: string;
value: boolean | number | string | React.ReactNode;
} >;
};
export type TableCardProps = CommonTableProps & {
/**
* An array of custom React nodes that is placed at the top right corner.
*/
actions?: Array< React.ReactNode >;
/**
* If a search is provided in actions and should reorder actions on mobile.
*/
hasSearch?: boolean;
/**
* A list of IDs, matching to the row list so that ids[ 0 ] contains the object ID for the object displayed in row[ 0 ].
*/
ids?: Array< number >;
/**
* Defines if the table contents are loading.
* It will display `TablePlaceholder` component instead of `Table` if that's the case.
*/
isLoading?: boolean;
/**
* A function which returns a callback function to update the query string for a given `param`.
*/
// Allowing any for backward compatibitlity
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onQueryChange?: ( param: string ) => ( ...props: any ) => void;
/**
* A function which returns a callback function which is called upon the user changing the visiblity of columns.
*/
onColumnsChange?: ( showCols: Array< string >, key?: string ) => void;
/**
* A callback function that is invoked when the current page is changed.
*/
onPageChange?: ( newPage: number, direction?: 'previous' | 'next' ) => void;
/**
* The total number of rows to display per page.
*/
rowsPerPage: number;
/**
* Boolean to determine whether or not ellipsis menu is shown.
*/
showMenu?: boolean;
/**
* An array of objects with `label` & `value` properties, which display in a line under the table.
* Optional, can be left off to show no summary.
*/
summary?: TableSummaryProps[ 'data' ];
/**
* The title used in the card header, also used as the caption for the content in this table.
*/
title: string;
/**
* The total number of rows (across all pages).
*/
totalRows: number;
};

View File

@ -1,7 +1,7 @@
/**
* External dependencies
*/
import React, { isValidElement, Fragment } from 'react';
import { isValidElement, Fragment } from 'react';
import { Slot, Fill } from '@wordpress/components';
import { cloneElement, createElement } from '@wordpress/element';
@ -13,15 +13,19 @@ import { cloneElement, createElement } from '@wordpress/element';
* @param {Array} props - Fill props.
* @return {Node} Node.
*/
function createOrderedChildren< T = Fill.Props >(
function createOrderedChildren< T = Fill.Props, S = Record< string, unknown > >(
children: React.ReactNode,
order: number,
props: T
props: T,
injectProps?: S
) {
if ( typeof children === 'function' ) {
return cloneElement( children( props ), { order } );
return cloneElement( children( { ...props, order, ...injectProps } ), {
order,
...injectProps,
} );
} else if ( isValidElement( children ) ) {
return cloneElement( children, { ...props, order } );
return cloneElement( children, { ...props, order, ...injectProps } );
}
throw Error( 'Invalid children type' );
}

View File

@ -1,7 +1,7 @@
/**
* External dependencies
*/
import React from 'react';
import React, { useEffect } from 'react';
import { Slot, Fill } from '@wordpress/components';
import { createElement, Children } from '@wordpress/element';
@ -9,6 +9,7 @@ import { createElement, Children } from '@wordpress/element';
* Internal dependencies
*/
import { createOrderedChildren, sortFillsByOrder } from '../utils';
import { useSlotContext, SlotContextHelpersType } from '../slot-context';
type WooProductFieldItemProps = {
id: string;
@ -23,36 +24,57 @@ type WooProductFieldSlotProps = {
export const WooProductFieldItem: React.FC< WooProductFieldItemProps > & {
Slot: React.FC< Slot.Props & WooProductFieldSlotProps >;
} = ( { children, order = 20, section } ) => (
<Fill name={ `woocommerce_product_field_${ section }` }>
{ ( fillProps: Fill.Props ) => {
return createOrderedChildren< Fill.Props >(
children,
order,
fillProps
);
} }
</Fill>
);
} = ( { children, order = 20, section, id } ) => {
const { registerFill, getFillHelpers } = useSlotContext();
WooProductFieldItem.Slot = ( { fillProps, section } ) => (
<Slot
name={ `woocommerce_product_field_${ section }` }
fillProps={ fillProps }
>
{ ( fills ) => {
if ( ! sortFillsByOrder ) {
return null;
}
useEffect( () => {
registerFill( id );
}, [] );
return Children.map(
sortFillsByOrder( fills )?.props.children,
( child ) => (
<div className="woocommerce-product-form__field">
{ child }
</div>
)
);
} }
</Slot>
);
return (
<Fill name={ `woocommerce_product_field_${ section }` }>
{ ( fillProps: Fill.Props ) => {
return createOrderedChildren<
Fill.Props & SlotContextHelpersType,
{ _id: string }
>(
children,
order,
{
...fillProps,
...getFillHelpers(),
},
{ _id: id }
);
} }
</Fill>
);
};
WooProductFieldItem.Slot = ( { fillProps, section } ) => {
// eslint-disable-next-line react-hooks/rules-of-hooks
const { filterRegisteredFills } = useSlotContext();
return (
<Slot
name={ `woocommerce_product_field_${ section }` }
fillProps={ fillProps }
>
{ ( fills ) => {
if ( ! sortFillsByOrder ) {
return null;
}
return Children.map(
sortFillsByOrder( filterRegisteredFills( fills ) )?.props
.children,
( child ) => (
<div className="woocommerce-product-form__field">
{ child }
</div>
)
);
} }
</Slot>
);
};

View File

@ -0,0 +1,35 @@
# WooProductTabItem Slot & Fill
A Slotfill component that will allow you to add a new tab to the product editor.
## Usage
```jsx
<WooProductTabItem id={ key } location="tab/general" order={ 2 } pluginId="test-plugin" tabProps={ { title: 'New tab', name: 'new-tab' } } >
<Card>
<CardBody>{ /* Tab content */ }</CardBody>
</Card>
</WooProductTabItem>
<WooProductTabItem.Slot location="tab/general" />
```
### WooProductTabItem (fill)
This is the fill component. You must provide the `id` prop to identify your section fill with a unique string. This component will accept a series of props:
| Prop | Type | Description |
| ---------- | ------ | -------------------------------------------------------------------------------------------------------------- |
| `id` | String | A unique string to identify your fill. Used for configuiration management. |
| `location` | String | The string used to identify the particular location that you want to render your section. |
| `pluginId` | String | A unique plugin ID to identify the plugin/extension that this fill is associated with. |
| `tabProps` | Object | An object containing tab props: name, title, className, disabled (see TabPanel.Tab from @wordpress/components) |
| `order` | Number | (optional) This number will dictate the order that the sections rendered by a Slot will be appear. |
### WooProductTabItem.Slot (slot)
This is the slot component, and will not be used as frequently. It must also receive the required `location` prop that will be identical to the fill `location`.
| Name | Type | Description |
| ---------- | ------ | ---------------------------------------------------------------------------------------------------- |
| `location` | String | Unique to the location that the Slot appears, and must be the same as the one provided to any fills. |

View File

@ -0,0 +1 @@
export * from './woo-product-tab-item';

View File

@ -0,0 +1,109 @@
/**
* External dependencies
*/
import React, { ReactElement, ReactNode } from 'react';
import { Slot, Fill, TabPanel } from '@wordpress/components';
import { createElement, Fragment } from '@wordpress/element';
/**
* Internal dependencies
*/
import { createOrderedChildren } from '../utils';
type WooProductTabItemProps = {
id: string;
pluginId: string;
template?: string;
order?: number;
tabProps:
| TabPanel.Tab
// eslint-disable-next-line @typescript-eslint/no-explicit-any
| ( ( fillProps: Record< string, any > | undefined ) => TabPanel.Tab );
templates?: Array< { name: string; order?: number } >;
};
type WooProductFieldSlotProps = {
template: string;
children: (
tabs: TabPanel.Tab[],
tabChildren: Record< string, ReactNode >
) => ReactElement | null;
};
export const WooProductTabItem: React.FC< WooProductTabItemProps > & {
Slot: React.VFC<
Omit< Slot.Props, 'children' > & WooProductFieldSlotProps
>;
} = ( { children, order, template, tabProps, templates } ) => {
if ( ! template && ! templates ) {
// eslint-disable-next-line no-console
console.warn(
'WooProductTabItem fill is missing template or templates property.'
);
return null;
}
templates = templates || [ { name: template as string, order } ];
return (
<>
{ templates.map( ( templateData ) => (
<Fill
name={ `woocommerce_product_tab_${ templateData.name }` }
key={ templateData.name }
>
{ ( fillProps: Fill.Props ) => {
return createOrderedChildren< Fill.Props >(
children,
templateData.order || 20,
{},
{
tabProps,
templateName: templateData.name,
order: templateData.order || 20,
...fillProps,
}
);
} }
</Fill>
) ) }
</>
);
};
WooProductTabItem.Slot = ( { fillProps, template, children } ) => (
<Slot
name={ `woocommerce_product_tab_${ template }` }
fillProps={ fillProps }
>
{ ( fills ) => {
const tabData = fills.reduce(
( { childrenMap, tabs }, fill ) => {
const props: WooProductTabItemProps = fill[ 0 ].props;
if ( props && props.tabProps ) {
childrenMap[ props.tabProps.name ] = fill[ 0 ];
const tabProps =
typeof props.tabProps === 'function'
? props.tabProps( fillProps )
: props.tabProps;
tabs.push( {
...tabProps,
order: props.order ?? 20,
} );
}
return {
childrenMap,
tabs,
};
},
{ childrenMap: {}, tabs: [] } as {
childrenMap: Record< string, ReactElement >;
tabs: Array< TabPanel.Tab & { order: number } >;
}
);
const orderedTabs = tabData.tabs.sort( ( a, b ) => {
return a.order - b.order;
} );
return children( orderedTabs, tabData.childrenMap );
} }
</Slot>
);

View File

@ -2,7 +2,7 @@
"phpVersion": null,
"core": null,
"plugins": [
"https://downloads.wordpress.org/plugin/woocommerce.7.1.0.zip",
"https://downloads.wordpress.org/plugin/woocommerce.7.3.0.zip",
"."
],
"config": {

View File

@ -1,4 +1,4 @@
Significance: patch
Type: update
Disable TikTok in the OBW
bump WooCommerce version

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Add FeedbackModal and ProductMVPFeedbackModal components

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Add a function to help decide if comments section should be shown

View File

@ -29,6 +29,10 @@ type CustomerEffortScoreProps = {
onModalShownCallback?: () => void;
onModalDismissedCallback?: () => void;
icon?: React.ReactElement | null;
shouldShowComments?: (
firstQuestionScore: number,
secondQuestionScore: number
) => boolean;
};
/**
@ -48,6 +52,7 @@ type CustomerEffortScoreProps = {
* @param {Function} props.onNoticeDismissedCallback Function to call when the notice is dismissed.
* @param {Function} props.onModalShownCallback Function to call when the modal is shown.
* @param {Function} props.onModalDismissedCallback Function to call when modal is dismissed.
* @param {Function} props.shouldShowComments Callback to determine if comments section should be shown.
* @param {Object} props.icon Icon (React component) to be shown on the notice.
*/
const CustomerEffortScore: React.VFC< CustomerEffortScoreProps > = ( {
@ -62,6 +67,10 @@ const CustomerEffortScore: React.VFC< CustomerEffortScoreProps > = ( {
onModalShownCallback = noop,
onModalDismissedCallback = noop,
icon,
shouldShowComments = ( firstQuestionScore, secondQuestionScore ) =>
[ firstQuestionScore, secondQuestionScore ].some(
( score ) => score === 1 || score === 2
),
} ) => {
const [ shouldCreateNotice, setShouldCreateNotice ] = useState( true );
const [ visible, setVisible ] = useState( false );
@ -108,6 +117,7 @@ const CustomerEffortScore: React.VFC< CustomerEffortScoreProps > = ( {
secondQuestion={ secondQuestion }
recordScoreCallback={ recordScoreCallback }
onCloseModal={ onModalDismissedCallback }
shouldShowComments={ shouldShowComments }
/>
);
};
@ -145,6 +155,10 @@ CustomerEffortScore.propTypes = {
* The second survey question.
*/
secondQuestion: PropTypes.string,
/**
* A function to determine whether or not the comments field shown be shown.
*/
shouldShowComments: PropTypes.func,
};
export { CustomerEffortScore };

View File

@ -32,6 +32,7 @@ import { __ } from '@wordpress/i18n';
* @param {string} props.defaultScore Default score.
* @param {Function} props.onCloseModal Callback for when user closes modal by clicking cancel.
* @param {Function} props.customOptions List of custom score options, contains label and value.
* @param {Function} props.shouldShowComments A function to determine whether or not the comments field shown be shown.
*/
function CustomerFeedbackModal( {
recordScoreCallback,
@ -42,6 +43,10 @@ function CustomerFeedbackModal( {
defaultScore = NaN,
onCloseModal,
customOptions,
shouldShowComments = ( firstQuestionScore, secondQuestionScore ) =>
[ firstQuestionScore, secondQuestionScore ].some(
( score ) => score === 1 || score === 2
),
}: {
recordScoreCallback: (
score: number,
@ -55,6 +60,10 @@ function CustomerFeedbackModal( {
defaultScore?: number;
onCloseModal?: () => void;
customOptions?: { label: string; value: string }[];
shouldShowComments?: (
firstQuestionScore: number,
secondQuestionScore: number
) => boolean;
} ): JSX.Element | null {
const options =
customOptions && customOptions.length > 0
@ -200,29 +209,33 @@ function CustomerFeedbackModal( {
</div>
) }
{ [ firstQuestionScore, secondQuestionScore ].some(
( score ) => score === 1 || score === 2
) && (
<div className="woocommerce-customer-effort-score__comments">
<TextareaControl
label={ __(
'How is that screen useful to you? What features would you add or change?',
'woocommerce'
) }
help={ __(
'Your feedback will go to the WooCommerce development team',
'woocommerce'
) }
value={ comments }
placeholder={ __(
'Optional, but much apprecated. We love reading your feedback!',
'woocommerce'
) }
onChange={ ( value: string ) => setComments( value ) }
rows={ 5 }
/>
</div>
) }
{ typeof shouldShowComments === 'function' &&
shouldShowComments(
firstQuestionScore,
secondQuestionScore
) && (
<div className="woocommerce-customer-effort-score__comments">
<TextareaControl
label={ __(
'How is that screen useful to you? What features would you add or change?',
'woocommerce'
) }
help={ __(
'Your feedback will go to the WooCommerce development team',
'woocommerce'
) }
value={ comments }
placeholder={ __(
'Optional, but much apprecated. We love reading your feedback!',
'woocommerce'
) }
onChange={ ( value: string ) =>
setComments( value )
}
rows={ 5 }
/>
</div>
) }
{ showNoScoreMessage && (
<div

View File

@ -0,0 +1,12 @@
.woocommerce-feedback-modal__buttons {
text-align: right;
.components-button {
margin-left: 1em;
}
}
.woocommerce-feedback-modal .woocommerce-feedback-modal__description {
max-width: 550px;
margin: 0 0 1.5em 0;
}

View File

@ -0,0 +1,106 @@
/**
* External dependencies
*/
import { createElement, useState } from '@wordpress/element';
import PropTypes from 'prop-types';
import { Button, Modal } from '@wordpress/components';
import { Text } from '@woocommerce/experimental';
import { __ } from '@wordpress/i18n';
/**
* Provides a modal requesting customer feedback.
*
* Answers and comments are sent to a callback function.
*
* @param {Object} props Component props.
* @param {Function} props.onSubmit Function to call when the results are sent.
* @param {string} props.title Title displayed in the modal.
* @param {string} props.description Description displayed in the modal.
* @param {string} props.isSubmitButtonDisabled Boolean to enable/disable the send button.
* @param {string} props.submitButtonLabel Label for the send button.
* @param {string} props.cancelButtonLabel Label for the cancel button.
* @param {Function} props.onModalClose Callback for when user closes modal by clicking cancel.
* @param {Function} props.children Children to be rendered.
*/
function FeedbackModal( {
onSubmit,
title,
description,
onModalClose,
children,
isSubmitButtonDisabled,
submitButtonLabel,
cancelButtonLabel,
}: {
onSubmit: () => void;
title: string;
description?: string;
onModalClose?: () => void;
children?: JSX.Element;
isSubmitButtonDisabled?: boolean;
submitButtonLabel?: string;
cancelButtonLabel?: string;
} ): JSX.Element | null {
const [ isOpen, setOpen ] = useState( true );
const closeModal = () => {
setOpen( false );
if ( onModalClose ) {
onModalClose();
}
};
if ( ! isOpen ) {
return null;
}
return (
<Modal
className="woocommerce-feedback-modal"
title={ title }
onRequestClose={ closeModal }
shouldCloseOnClickOutside={ false }
>
<Text
variant="body"
as="p"
className="woocommerce-feedback-modal__description"
size={ 14 }
lineHeight="20px"
marginBottom="1.5em"
>
{ description }
</Text>
{ children }
<div className="woocommerce-feedback-modal__buttons">
<Button isTertiary onClick={ closeModal } name="cancel">
{ cancelButtonLabel }
</Button>
<Button
isPrimary={ ! isSubmitButtonDisabled }
isSecondary={ isSubmitButtonDisabled }
onClick={ () => {
onSubmit();
setOpen( false );
} }
name="send"
disabled={ isSubmitButtonDisabled }
>
{ submitButtonLabel }
</Button>
</div>
</Modal>
);
}
FeedbackModal.propTypes = {
onSubmit: PropTypes.func.isRequired,
title: PropTypes.string,
description: PropTypes.string,
onModalClose: PropTypes.func,
isSubmitButtonDisabled: PropTypes.bool,
submitButtonLabel: PropTypes.string,
cancelButtonLabel: PropTypes.string,
};
export { FeedbackModal };

View File

@ -0,0 +1 @@
export * from './feedback-modal';

View File

@ -0,0 +1,54 @@
/**
* External dependencies
*/
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import { createElement } from '@wordpress/element';
/**
* Internal dependencies
*/
import { FeedbackModal } from '../index';
const mockRecordScoreCallback = jest.fn();
describe( 'FeedbackModal', () => {
it( 'should render a modal', async () => {
render(
<FeedbackModal
onSubmit={ mockRecordScoreCallback }
title="Testing"
submitButtonLabel="Send"
cancelButtonLabel="Cancel"
/>
);
// Wait for the modal to render.
await screen.findByRole( 'dialog' );
expect(
screen.getByRole( 'button', { name: /Send/i } )
).toBeInTheDocument();
expect(
screen.getByRole( 'button', { name: /Cancel/i } )
).toBeInTheDocument();
} );
it( 'should close modal when cancel button pressed', async () => {
render(
<FeedbackModal
onSubmit={ mockRecordScoreCallback }
title="Testing"
submitButtonLabel="Send"
cancelButtonLabel="Cancel"
/>
);
// Wait for the modal to render.
await screen.findByRole( 'dialog' );
// Press cancel button.
fireEvent.click( screen.getByRole( 'button', { name: /Cancel/i } ) );
expect( screen.queryByRole( 'dialog' ) ).not.toBeInTheDocument();
} );
} );

View File

@ -1,3 +1,5 @@
export * from './customer-effort-score';
export * from './customer-feedback-simple';
export * from './customer-feedback-modal';
export * from './product-mvp-feedback-modal';
export * from './feedback-modal';

View File

@ -0,0 +1 @@
export * from './product-mvp-feedback-modal';

View File

@ -0,0 +1,23 @@
.woocommerce-product-mvp-feedback-modal {
&__subtitle {
margin-top: $gap-smaller !important;
}
&__checkboxes {
margin: $gap-small 0;
}
&__comments {
margin-top: 2em;
margin-bottom: 1.5em;
label {
display: block;
font-weight: bold;
text-transform: none;
font-size: 14px;
}
textarea {
width: 100%;
}
}
}

View File

@ -0,0 +1,156 @@
/**
* External dependencies
*/
import { createElement, Fragment, useState } from '@wordpress/element';
import PropTypes from 'prop-types';
import { CheckboxControl, TextareaControl } from '@wordpress/components';
import { Text } from '@woocommerce/experimental';
import { __ } from '@wordpress/i18n';
/**
* Internal dependencies
*/
import { FeedbackModal } from '../feedback-modal';
/**
* Provides a modal requesting customer feedback.
*
*
* @param {Object} props Component props.
* @param {Function} props.recordScoreCallback Function to call when the results are sent.
* @param {Function} props.onCloseModal Callback for when user closes modal by clicking cancel.
*/
function ProductMVPFeedbackModal( {
recordScoreCallback,
onCloseModal,
}: {
recordScoreCallback: ( checked: string[], comments: string ) => void;
onCloseModal?: () => void;
} ): JSX.Element | null {
const [ missingFeatures, setMissingFeatures ] = useState( false );
const [ missingPlugins, setMissingPlugins ] = useState( false );
const [ difficultToUse, setDifficultToUse ] = useState( false );
const [ slowBuggyOrBroken, setSlowBuggyOrBroken ] = useState( false );
const [ other, setOther ] = useState( false );
const checkboxes = [
{
key: 'missing-features',
label: __( 'Missing features', 'woocommerce' ),
checked: missingFeatures,
onChange: setMissingFeatures,
},
{
key: 'missing-plugins',
label: __( 'Missing plugins', 'woocommerce' ),
checked: missingPlugins,
onChange: setMissingPlugins,
},
{
key: 'difficult-to-use',
label: __( 'It is difficult to use', 'woocommerce' ),
checked: difficultToUse,
onChange: setDifficultToUse,
},
{
key: 'slow-buggy-or-broken',
label: __( 'It is slow, buggy, or broken', 'woocommerce' ),
checked: slowBuggyOrBroken,
onChange: setSlowBuggyOrBroken,
},
{
key: 'other',
label: __( 'Other (describe below)', 'woocommerce' ),
checked: other,
onChange: setOther,
},
];
const [ comments, setComments ] = useState( '' );
const onSendFeedback = () => {
const checked = checkboxes
.filter( ( checkbox ) => checkbox.checked )
.map( ( checkbox ) => checkbox.key );
recordScoreCallback( checked, comments );
};
const isSendButtonDisabled =
! comments &&
! missingFeatures &&
! missingPlugins &&
! difficultToUse &&
! slowBuggyOrBroken &&
! other;
return (
<FeedbackModal
title={ __(
'Thanks for trying out the new product editor!',
'woocommerce'
) }
description={ __(
'Were working on making it better, and your feedback will help improve the experience for thousands of merchants like you.',
'woocommerce'
) }
onSubmit={ onSendFeedback }
onModalClose={ onCloseModal }
isSubmitButtonDisabled={ isSendButtonDisabled }
submitButtonLabel={ __( 'Send feedback', 'woocommerce' ) }
cancelButtonLabel={ __( 'Skip', 'woocommerce' ) }
>
<>
<Text
variant="subtitle.small"
as="p"
weight="600"
size="14"
lineHeight="20px"
>
{ __(
'What made you switch back to the classic product editor?',
'woocommerce'
) }
</Text>
<Text
weight="400"
size="12"
as="p"
lineHeight="16px"
color="#757575"
className="woocommerce-product-mvp-feedback-modal__subtitle"
>
{ __( '(Check all that apply)', 'woocommerce' ) }
</Text>
<div className="woocommerce-product-mvp-feedback-modal__checkboxes">
{ checkboxes.map( ( checkbox, index ) => (
<CheckboxControl
key={ index }
label={ checkbox.label }
name={ checkbox.key }
checked={ checkbox.checked }
onChange={ checkbox.onChange }
/>
) ) }
</div>
<div className="woocommerce-product-mvp-feedback-modal__comments">
<TextareaControl
label={ __( 'Additional comments', 'woocommerce' ) }
value={ comments }
placeholder={ __(
'Optional, but much apprecated. We love reading your feedback!',
'woocommerce'
) }
onChange={ ( value: string ) => setComments( value ) }
rows={ 5 }
/>
</div>
</>
</FeedbackModal>
);
}
ProductMVPFeedbackModal.propTypes = {
recordScoreCallback: PropTypes.func.isRequired,
onCloseModal: PropTypes.func,
};
export { ProductMVPFeedbackModal };

View File

@ -0,0 +1,54 @@
/**
* External dependencies
*/
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import { createElement } from '@wordpress/element';
/**
* Internal dependencies
*/
import { ProductMVPFeedbackModal } from '../index';
const mockRecordScoreCallback = jest.fn();
describe( 'ProductMVPFeedbackModal', () => {
it( 'should close the ProductMVPFeedback modal when skip button pressed', async () => {
render(
<ProductMVPFeedbackModal
recordScoreCallback={ mockRecordScoreCallback }
/>
);
// Wait for the modal to render.
await screen.findByRole( 'dialog' );
// Press cancel button.
fireEvent.click( screen.getByRole( 'button', { name: /Skip/i } ) );
expect( screen.queryByRole( 'dialog' ) ).not.toBeInTheDocument();
} );
it( 'should enable Send button when an option is checked', async () => {
render(
<ProductMVPFeedbackModal
recordScoreCallback={ mockRecordScoreCallback }
/>
);
// Wait for the modal to render.
await screen.findByRole( 'dialog' );
fireEvent.click( screen.getByRole( 'checkbox', { name: /other/i } ) );
fireEvent.click(
screen.getByRole( 'button', { name: /Send feedback/i } )
);
} );
it( 'should call the function sent as recordScoreCallback with the checked options', async () => {
render(
<ProductMVPFeedbackModal
recordScoreCallback={ mockRecordScoreCallback }
/>
);
// Wait for the modal to render.
await screen.findByRole( 'dialog' );
fireEvent.click( screen.getByRole( 'checkbox', { name: /other/i } ) );
expect( mockRecordScoreCallback ).toHaveBeenCalledWith(
[ 'other' ],
''
);
} );
} );

View File

@ -1,4 +1,6 @@
@import 'customer-feedback-simple/customer-feedback-simple.scss';
@import 'product-mvp-feedback-modal/product-mvp-feedback-modal.scss';
@import 'feedback-modal/feedback-modal.scss';
.woocommerce-customer-effort-score__selection {
margin: 1em 0 1.5em 0;

View File

@ -0,0 +1,4 @@
Significance: patch
Type: tweak
Tweak the product form types and exports.

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Update type definition for ProductForm

View File

@ -76,7 +76,11 @@ export {
// Export types
export * from './types';
export * from './countries/types';
export { ProductForm } from './product-form/types';
export {
ProductForm,
ProductFormField,
ProductFormSection,
} from './product-form/types';
export * from './onboarding/types';
export * from './plugins/types';
export * from './products/types';
@ -172,6 +176,7 @@ import { ProductCategorySelectors } from './product-categories/types';
import { ProductAttributeTermsSelectors } from './product-attribute-terms/types';
import { ProductVariationSelectors } from './product-variations/types';
import { TaxClassSelectors } from './tax-classes/types';
import { ProductFormSelectors } from './product-form/selectors';
// As we add types to all the package selectors we can fill out these unknown types with real ones. See one
// of the already typed selectors for an example of how you can do this.
@ -219,6 +224,8 @@ export type WCSelectorType< T > = T extends typeof REVIEWS_STORE_NAME
? ShippingZonesSelectors
: T extends typeof EXPERIMENTAL_TAX_CLASSES_STORE_NAME
? TaxClassSelectors
: T extends typeof EXPERIMENTAL_PRODUCT_FORM_STORE_NAME
? ProductFormSelectors
: never;
export interface WCDataSelector {

View File

@ -2,9 +2,9 @@
* Internal dependencies
*/
import TYPES from './action-types';
import { Field, ProductForm } from './types';
import { ProductFormField, ProductForm } from './types';
export function getFieldsSuccess( fields: Field[] ) {
export function getFieldsSuccess( fields: ProductFormField[] ) {
return {
type: TYPES.GET_FIELDS_SUCCESS as const,
fields,
@ -24,6 +24,7 @@ export function getProductFormSuccess( productForm: ProductForm ) {
fields: productForm.fields,
sections: productForm.sections,
subsections: productForm.subsections,
tabs: productForm.tabs,
};
}

View File

@ -17,6 +17,7 @@ const reducer: Reducer< ProductFormState, Action > = (
fields: [],
sections: [],
subsections: [],
tabs: [],
},
action
) => {
@ -42,6 +43,7 @@ const reducer: Reducer< ProductFormState, Action > = (
fields: action.fields,
sections: action.sections,
subsections: action.subsections,
tabs: action.tabs,
};
break;
case TYPES.GET_PRODUCT_FORM_ERROR:

View File

@ -14,7 +14,7 @@ import {
getProductFormError,
} from './actions';
import { WC_ADMIN_NAMESPACE } from '../constants';
import { Field, ProductForm } from './types';
import { ProductFormField, ProductForm } from './types';
import { STORE_NAME } from './constants';
const resolveSelect =
@ -23,7 +23,7 @@ const resolveSelect =
export function* getFields() {
try {
const url = WC_ADMIN_NAMESPACE + '/product-form/fields';
const results: Field[] = yield apiFetch( {
const results: ProductFormField[] = yield apiFetch( {
path: url,
method: 'GET',
} );

View File

@ -1,6 +1,7 @@
/**
* Internal dependencies
*/
import { WPDataSelector, WPDataSelectors } from '../types';
import { ProductFormState } from './types';
export const getFields = ( state: ProductFormState ) => {
@ -15,3 +16,9 @@ export const getProductForm = ( state: ProductFormState ) => {
const { errors, ...form } = state;
return form;
};
export type ProductFormSelectors = {
getFields: WPDataSelector< typeof getFields >;
getField: WPDataSelector< typeof getField >;
getProductForm: WPDataSelector< typeof getProductForm >;
} & WPDataSelectors;

View File

@ -9,23 +9,30 @@ type FieldProperties = {
label: string;
};
export type Field = BaseComponent & {
export type ProductFormField = BaseComponent & {
type: string;
section: string;
properties: FieldProperties;
};
export type Section = BaseComponent & {
export type ProductFormSection = BaseComponent & {
title: string;
description: string;
location: string;
};
export type Subsection = BaseComponent;
export type Tabs = BaseComponent & {
name: string;
title: string;
};
export type ProductForm = {
fields: Field[];
sections: Section[];
fields: ProductFormField[];
sections: ProductFormSection[];
subsections: Subsection[];
tabs: Tabs[];
};
export type ProductFormState = ProductForm & {

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Add WooOnboardingTaskListHeader component

View File

@ -0,0 +1,37 @@
/**
* External dependencies
*/
import { createElement } from '@wordpress/element';
import { Slot, Fill } from '@wordpress/components';
type WooOnboardingTaskListHeaderProps = {
id: string;
};
/**
* A Fill for adding Onboarding Task List headers.
*
* @slotFill WooOnboardingTaskListHeader
* @scope woocommerce-tasks
* @param {Object} props React props.
* @param {string} props.id Task id.
*/
export const WooOnboardingTaskListHeader = ( {
id,
...props
}: WooOnboardingTaskListHeaderProps & Slot.Props ) => (
<Fill
name={ 'woocommerce_onboarding_task_list_header_' + id }
{ ...props }
/>
);
WooOnboardingTaskListHeader.Slot = ( {
id,
fillProps,
}: WooOnboardingTaskListHeaderProps & Slot.Props ) => (
<Slot
name={ 'woocommerce_onboarding_task_list_header_' + id }
fillProps={ fillProps }
/>
);

View File

@ -0,0 +1 @@
export * from './WooOnboardingTaskListHeader';

View File

@ -13,4 +13,5 @@ export { default as WCPayLogo } from './images/wcpay-logo';
export { WooPaymentGatewaySetup } from './components/WooPaymentGatewaySetup';
export { WooPaymentGatewayConfigure } from './components/WooPaymentGatewayConfigure';
export { WooOnboardingTaskListItem } from './components/WooOnboardingTaskListItem';
export { WooOnboardingTaskListHeader } from './components/WooOnboardingTaskListHeader';
export { WooOnboardingTask } from './components/WooOnboardingTask';

View File

@ -86,6 +86,7 @@ export const CustomerEffortScoreModalContainer: React.FC = () => {
visibleCESModalData.props?.onCloseModal?.();
hideCesModal();
} }
shouldShowComments={ visibleCESModalData.props?.shouldShowComments }
/>
);
};

View File

@ -1,6 +1,7 @@
/**
* External dependencies
*/
import { useEffect } from 'react';
import { compose } from '@wordpress/compose';
import { withDispatch, withSelect } from '@wordpress/data';
import { OPTIONS_STORE_NAME } from '@woocommerce/data';
@ -29,18 +30,19 @@ function CustomerEffortScoreTracksContainer( {
resolving,
clearQueue,
} ) {
if ( resolving ) {
return null;
}
const queueForPage = queue.filter(
( item ) =>
item.pagenow === window.pagenow &&
item.adminpage === window.adminpage
);
useEffect( () => {
if ( queueForPage.length ) {
clearQueue();
}
}, [ queueForPage ] );
if ( queueForPage.length ) {
clearQueue();
if ( resolving ) {
return null;
}
return (

View File

@ -3,6 +3,8 @@ const TYPES = {
ADD_CES_SURVEY: 'ADD_CES_SURVEY',
SHOW_CES_MODAL: 'SHOW_CES_MODAL',
HIDE_CES_MODAL: 'HIDE_CES_MODAL',
SHOW_PRODUCT_MVP_FEEDBACK_MODAL: 'SHOW_PRODUCT_MVP_FEEDBACK_MODAL',
HIDE_PRODUCT_MVP_FEEDBACK_MODAL: 'HIDE_PRODUCT_MVP_FEEDBACK_MODAL',
};
export default TYPES;

View File

@ -141,3 +141,21 @@ export function addCesSurveyForCustomerSearch() {
},
} );
}
/**
* Add show product MVP Feedback modal.
*/
export function showProductMVPFeedbackModal() {
return {
type: TYPES.SHOW_PRODUCT_MVP_FEEDBACK_MODAL,
};
}
/**
* Hide product MVP Feedback modal.
*/
export function hideProductMVPFeedbackModal() {
return {
type: TYPES.HIDE_PRODUCT_MVP_FEEDBACK_MODAL,
};
}

View File

@ -7,6 +7,7 @@ const DEFAULT_STATE = {
queue: [],
cesModalData: undefined,
showCESModal: false,
showProductMVPFeedbackModal: false,
};
const reducer = ( state = DEFAULT_STATE, action ) => {
@ -62,6 +63,16 @@ const reducer = ( state = DEFAULT_STATE, action ) => {
...state,
queue: [ ...state.queue, newTrack ],
};
case TYPES.SHOW_PRODUCT_MVP_FEEDBACK_MODAL:
return {
...state,
showProductMVPFeedbackModal: true,
};
case TYPES.HIDE_PRODUCT_MVP_FEEDBACK_MODAL:
return {
...state,
showProductMVPFeedbackModal: false,
};
default:
return state;

View File

@ -5,3 +5,7 @@ export function getCesSurveyQueue( state ) {
export function getVisibleCESModalData( state ) {
return state.showCESModal ? state.cesModalData : undefined;
}
export function isProductMVPFeedbackModalVisible( state ) {
return state.showProductMVPFeedbackModal;
}

View File

@ -21,9 +21,12 @@ import { STORE_KEY } from './data/constants';
export const PRODUCT_MVP_CES_ACTION_OPTION_NAME =
'woocommerce_ces_product_mvp_ces_action';
export const NEW_PRODUCT_MANAGEMENT =
'woocommerce_new_product_management_enabled';
export const ProductMVPCESFooter: React.FC = () => {
const { showCesModal } = useDispatch( STORE_KEY );
const { showCesModal, showProductMVPFeedbackModal } =
useDispatch( STORE_KEY );
const { updateOptions } = useDispatch( OPTIONS_STORE_NAME );
const {
cesAction,
@ -83,6 +86,7 @@ export const ProductMVPCESFooter: React.FC = () => {
"Thanks for the feedback. We'll put it to good use!",
'woocommerce'
),
shouldShowComments: () => true,
},
{},
{
@ -98,6 +102,16 @@ export const ProductMVPCESFooter: React.FC = () => {
} );
};
const onDisablingNewProductExperience = () => {
updateOptions( {
[ PRODUCT_MVP_CES_ACTION_OPTION_NAME ]: 'hide',
} );
updateOptions( {
[ NEW_PRODUCT_MANAGEMENT ]: 'no',
} );
showProductMVPFeedbackModal();
};
const onDisablingCES = () => {
updateOptions( {
[ PRODUCT_MVP_CES_ACTION_OPTION_NAME ]: 'hide',
@ -125,7 +139,7 @@ export const ProductMVPCESFooter: React.FC = () => {
{ __( 'Share feedback', 'woocommerce' ) }
</Button>
<Button
onClick={ onDisablingCES }
onClick={ onDisablingNewProductExperience }
variant="tertiary"
>
{ __( 'Turn it off', 'woocommerce' ) }

View File

@ -0,0 +1,61 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { useDispatch, useSelect } from '@wordpress/data';
import { ProductMVPFeedbackModal } from '@woocommerce/customer-effort-score';
import { recordEvent } from '@woocommerce/tracks';
import { getAdminLink } from '@woocommerce/settings';
import { useFormContext } from '@woocommerce/components';
import { Product } from '@woocommerce/data';
/**
* Internal dependencies
*/
import { STORE_KEY } from './data/constants';
export const ProductMVPFeedbackModalContainer: React.FC = () => {
const { values } = useFormContext< Product >();
const { hideProductMVPFeedbackModal } = useDispatch( STORE_KEY );
const { isProductMVPModalVisible } = useSelect( ( select ) => {
const { isProductMVPFeedbackModalVisible } = select( STORE_KEY );
return {
isProductMVPModalVisible: isProductMVPFeedbackModalVisible(),
};
} );
const classicEditorUrl = values.id
? getAdminLink( `post.php?post=${ values.id }&action=edit` )
: getAdminLink( 'post-new.php?post_type=product' );
const recordScore = ( checked: string[], comments: string ) => {
recordEvent( 'product_mvp_feedback', {
action: 'disable',
checked,
comments: comments || '',
} );
hideProductMVPFeedbackModal();
window.location.href = `${ classicEditorUrl }&new-product-experience-disabled=true`;
};
const onCloseModal = () => {
recordEvent( 'product_mvp_feedback', {
action: 'disable',
checked: '',
comments: '',
} );
hideProductMVPFeedbackModal();
window.location.href = classicEditorUrl;
};
if ( ! isProductMVPModalVisible ) {
return null;
}
return (
<ProductMVPFeedbackModal
recordScoreCallback={ recordScore }
onCloseModal={ onCloseModal }
/>
);
};

View File

@ -19,21 +19,23 @@ async function isProductMVPCESHidden(): Promise< boolean > {
export const useProductMVPCESFooter = () => {
const { updateOptions } = useDispatch( OPTIONS_STORE_NAME );
const showCesFooter = ( actionName = 'show' ) => {
updateOptions( {
[ PRODUCT_MVP_CES_ACTION_OPTION_NAME ]: actionName,
} );
};
const onSaveDraft = async () => {
if ( ( await isProductMVPCESHidden() ) === false ) {
updateOptions( {
[ PRODUCT_MVP_CES_ACTION_OPTION_NAME ]: 'new_product',
} );
showCesFooter( 'new_product' );
}
};
const onPublish = async () => {
if ( ( await isProductMVPCESHidden() ) === false ) {
updateOptions( {
[ PRODUCT_MVP_CES_ACTION_OPTION_NAME ]: 'new_product',
} );
showCesFooter( 'new_product' );
}
};
return { onSaveDraft, onPublish };
return { onSaveDraft, onPublish, showCesFooter };
};

View File

@ -0,0 +1,43 @@
/**
* External dependencies
*/
import { useEffect, useLayoutEffect, useState } from '@wordpress/element';
export const useHeaderHeight = () => {
const [ headerHeight, setHeaderHeight ] = useState( 60 );
const [ adminBarHeight, setAdminBarHeight ] = useState( 32 );
useEffect( () => {
const wpbody = document.querySelector( '#wpbody' ) as Node;
const observer = new MutationObserver( () => {
setHeaderHeight(
parseInt( ( wpbody as HTMLElement ).style.marginTop, 10 )
);
} );
observer.observe( wpbody, {
attributes: true,
} );
return () => {
observer.disconnect();
};
}, [] );
useLayoutEffect( () => {
const handleResize = () => {
const adminBar = document.querySelector(
'#wpadminbar'
) as HTMLElement;
setAdminBarHeight( adminBar.clientHeight );
};
window.addEventListener( 'resize', handleResize );
return () => {
window.removeEventListener( 'resize', handleResize );
};
}, [] );
return {
adminBarHeight,
headerHeight,
};
};

View File

@ -0,0 +1,36 @@
/**
* External dependencies
*/
import { useSlot } from '@woocommerce/experimental';
import classnames from 'classnames';
/**
* Internal dependencies
*/
import {
EXPERIMENTAL_WC_HOMESCREEN_HEADER_BANNER_SLOT_NAME,
WooHomescreenHeaderBannerItem,
} from './utils';
export const WooHomescreenHeaderBanner = ( {
className,
}: {
className: string;
} ) => {
const slot = useSlot( EXPERIMENTAL_WC_HOMESCREEN_HEADER_BANNER_SLOT_NAME );
const hasFills = Boolean( slot?.fills?.length );
if ( ! hasFills ) {
return null;
}
return (
<div
className={ classnames(
'woocommerce-homescreen__header',
className
) }
>
<WooHomescreenHeaderBannerItem.Slot />
</div>
);
};

View File

@ -0,0 +1,2 @@
export * from './header-banner-slot';
export * from './utils';

View File

@ -0,0 +1,58 @@
/**
* External dependencies
*/
import { Slot, Fill } from '@wordpress/components';
/**
* Internal dependencies
*/
import { createOrderedChildren, sortFillsByOrder } from '../../utils';
export const EXPERIMENTAL_WC_HOMESCREEN_HEADER_BANNER_SLOT_NAME =
'woocommerce_homescreen_experimental_header_banner_item';
/**
* Create a Fill for extensions to add items to the WooCommerce Admin Homescreen header banner.
*
* @slotFill WooHomescreenHeaderBannerItem
* @scope woocommerce-admin
* @example
* const MyHeaderItem = () => (
* <WooHomescreenHeaderBannerItem>My header item</WooHomescreenHeaderBannerItem>
* );
*
* registerPlugin( 'my-extension', {
* render: MyHeaderItem,
* scope: 'woocommerce-admin',
* } );
* @param {Object} param0
* @param {Array} param0.children - Node children.
* @param {Array} param0.order - Node order.
*/
export const WooHomescreenHeaderBannerItem = ( {
children,
order = 1,
}: {
children: React.ReactNode;
order?: number;
} ) => {
return (
<Fill name={ EXPERIMENTAL_WC_HOMESCREEN_HEADER_BANNER_SLOT_NAME }>
{ ( fillProps: Fill.Props ) => {
return createOrderedChildren( children, order, fillProps );
} }
</Fill>
);
};
WooHomescreenHeaderBannerItem.Slot = ( {
fillProps,
}: {
fillProps?: Slot.Props;
} ) => (
<Slot
name={ EXPERIMENTAL_WC_HOMESCREEN_HEADER_BANNER_SLOT_NAME }
fillProps={ fillProps }
>
{ sortFillsByOrder }
</Slot>
);

View File

@ -43,6 +43,7 @@ import './style.scss';
import '../dashboard/style.scss';
import { getAdminSetting } from '~/utils/admin-settings';
import { ProgressTitle } from '../task-lists';
import { WooHomescreenHeaderBanner } from './header-banner-slot';
const Tasks = lazy( () =>
import( /* webpackChunkName: "tasks" */ '../tasks' ).then( ( module ) => ( {
@ -126,7 +127,9 @@ export const Layout = ( {
return (
<Suspense fallback={ <TasksPlaceholder query={ query } /> }>
{ activeSetupTaskList && isDashboardShown && (
<ProgressTitle taskListId={ activeSetupTaskList } />
<>
<ProgressTitle taskListId={ activeSetupTaskList } />
</>
) }
<Tasks query={ query } />
</Suspense>
@ -135,6 +138,13 @@ export const Layout = ( {
return (
<>
{ isDashboardShown && (
<WooHomescreenHeaderBanner
className={ classnames( 'woocommerce-homescreen', {
'woocommerce-homescreen-column': ! twoColumns,
} ) }
/>
) }
<div
className={ classnames( 'woocommerce-homescreen', {
'two-columns': twoColumns,

View File

@ -0,0 +1,4 @@
.woocommerce-marketing-card-header-title {
@include font-size( 20 );
line-height: 1.4;
}

View File

@ -0,0 +1,12 @@
/**
* Internal dependencies
*/
import './CardHeaderTitle.scss';
export const CardHeaderTitle: React.FC = ( { children } ) => {
return (
<div className="woocommerce-marketing-card-header-title">
{ children }
</div>
);
};

View File

@ -0,0 +1 @@
export { CardHeaderTitle } from './CardHeaderTitle';

View File

@ -0,0 +1,4 @@
.woocommerce-centered-spinner {
display: flex;
justify-content: center;
}

View File

@ -0,0 +1,17 @@
/**
* External dependencies
*/
import { Spinner } from '@woocommerce/components';
/**
* Internal dependencies
*/
import './CenteredSpinner.scss';
export const CenteredSpinner = () => {
return (
<div className="woocommerce-centered-spinner">
<Spinner />
</div>
);
};

View File

@ -0,0 +1 @@
export { CenteredSpinner } from './CenteredSpinner';

View File

@ -1,9 +1,5 @@
.woocommerce-collapsible-card {
.components-card-header {
font-size: 20px;
font-weight: 400;
line-height: 28px;
letter-spacing: 0;
cursor: pointer;
}
}

Some files were not shown because too many files have changed in this diff Show More