Merge branch 'trunk' into e2e/strict-locator-tweak
This commit is contained in:
commit
afb82d221c
|
@ -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') }}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 }}
|
||||
|
|
|
@ -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'
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
Significance: minor
|
||||
Type: dev
|
||||
|
||||
Add advanced setting option
|
||||
Create tree-control component
|
|
@ -0,0 +1,4 @@
|
|||
Significance: patch
|
||||
Type: fix
|
||||
|
||||
Altering styles to correctly target fields within slot fills on product editor.
|
|
@ -1,5 +1,4 @@
|
|||
Significance: patch
|
||||
Type: dev
|
||||
Comment: 7.4 release prep
|
||||
|
||||
|
||||
Migrate Table component to TS
|
|
@ -0,0 +1,4 @@
|
|||
Significance: patch
|
||||
Type: fix
|
||||
|
||||
Include CSS for experimental tree control so it renders properly in Storybook.
|
|
@ -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
|
||||
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
Significance: minor
|
||||
Type: add
|
||||
|
||||
Add new WooProductTabItem component for slot filling tab items.
|
|
@ -0,0 +1,4 @@
|
|||
Significance: minor
|
||||
Type: add
|
||||
|
||||
Adding experimental component SlotContext
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
}
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
}
|
|
@ -0,0 +1,4 @@
|
|||
export * from './tree';
|
||||
export * from './tree-control';
|
||||
export * from './tree-item';
|
||||
export * from './types';
|
|
@ -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,
|
||||
};
|
|
@ -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 } />;
|
||||
} );
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
} );
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
} );
|
|
@ -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[];
|
||||
};
|
|
@ -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';
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
export * from './slot-context';
|
|
@ -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;
|
||||
};
|
|
@ -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';
|
||||
|
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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>;
|
||||
|
|
@ -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,
|
||||
};
|
|
@ -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,
|
||||
};
|
|
@ -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',
|
|
@ -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',
|
|
@ -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',
|
|
@ -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;
|
||||
|
||||
/**
|
|
@ -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 );
|
|
@ -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 );
|
|
@ -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;
|
||||
};
|
|
@ -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' );
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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. |
|
|
@ -0,0 +1 @@
|
|||
export * from './woo-product-tab-item';
|
|
@ -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>
|
||||
);
|
|
@ -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": {
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
Significance: patch
|
||||
Type: update
|
||||
|
||||
Disable TikTok in the OBW
|
||||
bump WooCommerce version
|
|
@ -0,0 +1,4 @@
|
|||
Significance: minor
|
||||
Type: add
|
||||
|
||||
Add FeedbackModal and ProductMVPFeedbackModal components
|
|
@ -0,0 +1,4 @@
|
|||
Significance: minor
|
||||
Type: add
|
||||
|
||||
Add a function to help decide if comments section should be shown
|
|
@ -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 };
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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 };
|
|
@ -0,0 +1 @@
|
|||
export * from './feedback-modal';
|
|
@ -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();
|
||||
} );
|
||||
} );
|
|
@ -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';
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
export * from './product-mvp-feedback-modal';
|
|
@ -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%;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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={ __(
|
||||
'We’re 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 };
|
|
@ -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' ],
|
||||
''
|
||||
);
|
||||
} );
|
||||
} );
|
|
@ -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;
|
||||
|
|
|
@ -0,0 +1,4 @@
|
|||
Significance: patch
|
||||
Type: tweak
|
||||
|
||||
Tweak the product form types and exports.
|
|
@ -0,0 +1,4 @@
|
|||
Significance: minor
|
||||
Type: add
|
||||
|
||||
Update type definition for ProductForm
|
|
@ -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 {
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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',
|
||||
} );
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 & {
|
||||
|
|
|
@ -0,0 +1,4 @@
|
|||
Significance: minor
|
||||
Type: add
|
||||
|
||||
Add WooOnboardingTaskListHeader component
|
|
@ -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 }
|
||||
/>
|
||||
);
|
|
@ -0,0 +1 @@
|
|||
export * from './WooOnboardingTaskListHeader';
|
|
@ -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';
|
||||
|
|
|
@ -86,6 +86,7 @@ export const CustomerEffortScoreModalContainer: React.FC = () => {
|
|||
visibleCESModalData.props?.onCloseModal?.();
|
||||
hideCesModal();
|
||||
} }
|
||||
shouldShowComments={ visibleCESModalData.props?.shouldShowComments }
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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' ) }
|
||||
|
|
|
@ -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 }
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -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 };
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,2 @@
|
|||
export * from './header-banner-slot';
|
||||
export * from './utils';
|
|
@ -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>
|
||||
);
|
|
@ -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,
|
||||
|
|
|
@ -0,0 +1,4 @@
|
|||
.woocommerce-marketing-card-header-title {
|
||||
@include font-size( 20 );
|
||||
line-height: 1.4;
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -0,0 +1 @@
|
|||
export { CardHeaderTitle } from './CardHeaderTitle';
|
|
@ -0,0 +1,4 @@
|
|||
.woocommerce-centered-spinner {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -0,0 +1 @@
|
|||
export { CenteredSpinner } from './CenteredSpinner';
|
|
@ -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
Loading…
Reference in New Issue