Merge branch 'trunk' into feature/34903-multichannel-marketing-frontend/main

Conflicts:
	plugins/woocommerce/src/Admin/Marketing/MarketingChannelInterface.php
This commit is contained in:
Gan Eng Chin 2023-01-21 00:09:27 +08:00
commit 4e42823b48
No known key found for this signature in database
GPG Key ID: 94D5D972860ADB01
258 changed files with 5690 additions and 2620 deletions

View File

@ -118,102 +118,3 @@ jobs:
${{ env.ALLURE_REPORT_DIR }} ${{ env.ALLURE_REPORT_DIR }}
if-no-files-found: ignore if-no-files-found: ignore
retention-days: 5 retention-days: 5
# test-summary:
# name: Post test results
# if: |
# always() &&
# ! github.event.pull_request.head.repo.fork &&
# (
# contains( needs.*.result, 'success' ) ||
# contains( needs.*.result, 'failure' )
# )
# runs-on: ubuntu-20.04
# permissions:
# contents: read
# needs: [cot-api-tests-run, cot-e2e-tests-run]
# steps:
# - name: Create dirs
# run: |
# mkdir -p repo
# mkdir -p artifacts/api
# mkdir -p artifacts/e2e
# mkdir -p output
#
# - name: Checkout code
# uses: actions/checkout@v3
# with:
# path: repo
#
# - name: Download API test report artifact
# uses: actions/download-artifact@v3
# with:
# name: api-test-report---pr-${{ github.event.number }}
# path: artifacts/api
#
# - name: Download Playwright E2E test report artifact
# uses: actions/download-artifact@v3
# with:
# name: e2e-test-report---pr-${{ github.event.number }}
# path: artifacts/e2e
#
# - name: Prepare test summary
# id: prepare-test-summary
# uses: actions/github-script@v6
# env:
# API_SUMMARY_PATH: ${{ github.workspace }}/artifacts/api/allure-report/widgets/summary.json
# E2E_PW_SUMMARY_PATH: ${{ github.workspace }}/artifacts/e2e/allure-report/widgets/summary.json
# PR_NUMBER: ${{ github.event.number }}
# SHA: ${{ github.event.pull_request.head.sha }}
# with:
# result-encoding: string
# script: |
# const script = require( './repo/.github/workflows/scripts/prepare-test-summary.js' )
# return await script( { core } )
#
# - name: Find PR comment by github-actions[bot]
# uses: peter-evans/find-comment@v2
# id: find-comment
# with:
# issue-number: ${{ github.event.pull_request.number }}
# comment-author: 'github-actions[bot]'
# body-includes: Test Results Summary
#
# - name: Create or update PR comment
# uses: peter-evans/create-or-update-comment@v2
# with:
# comment-id: ${{ steps.find-comment.outputs.comment-id }}
# issue-number: ${{ github.event.pull_request.number }}
# body: ${{ steps.prepare-test-summary.outputs.result }}
# edit-mode: replace
#
# publish-test-reports:
# name: Publish test reports
# if: |
# always() &&
# ! github.event.pull_request.head.repo.fork &&
# (
# contains( needs.*.result, 'success' ) ||
# contains( needs.*.result, 'failure' )
# )
# runs-on: ubuntu-20.04
# needs: [cot-api-tests-run, cot-e2e-tests-run]
# env:
# GITHUB_TOKEN: ${{ secrets.REPORTS_TOKEN }}
# PR_NUMBER: ${{ github.event.number }}
# RUN_ID: ${{ github.run_id }}
# COMMIT_SHA: ${{ github.event.pull_request.head.sha }}
# steps:
# - name: Publish test reports
# env:
# API_ARTIFACT: api-test-report---pr-${{ github.event.number }}
# E2E_ARTIFACT: e2e-test-report---pr-${{ github.event.number }}
# run: |
# gh workflow run publish-test-reports-pr.yml \
# -f run_id=$RUN_ID \
# -f api_artifact=$API_ARTIFACT \
# -f e2e_artifact=$E2E_ARTIFACT \
# -f pr_number=$PR_NUMBER \
# -f commit_sha=$COMMIT_SHA \
# -f s3_root=public \
# --repo woocommerce/woocommerce-test-reports

View File

@ -23,7 +23,7 @@ jobs:
uses: ./.github/actions/setup-woocommerce-monorepo uses: ./.github/actions/setup-woocommerce-monorepo
- name: Lint - name: Lint
run: pnpm run -r --filter='woocommerce/client/admin...' --filter='!@woocommerce/e2e*' --filter='!@woocommerce/api' --color lint run: pnpm run -r --filter='release-posts' --filter='woocommerce/client/admin...' --filter='!@woocommerce/e2e*' --filter='!@woocommerce/api' --color lint
- name: Test - name: Test
run: pnpm run test --filter='woocommerce/client/admin...' --filter='!@woocommerce/e2e*' --filter='!@woocommerce/api' --color run: pnpm run test --filter='woocommerce/client/admin...' --filter='!@woocommerce/e2e*' --filter='!@woocommerce/api' --color

View File

@ -21,7 +21,7 @@ jobs:
create-changelog-prs: create-changelog-prs:
runs-on: ubuntu-20.04 runs-on: ubuntu-20.04
permissions: permissions:
contents: read contents: write
pull-requests: write pull-requests: write
steps: steps:
- name: Checkout code - name: Checkout code
@ -46,6 +46,9 @@ jobs:
- name: 'Generate the changelog file' - name: 'Generate the changelog file'
run: pnpm --filter=woocommerce run changelog write --add-pr-num -n -vvv --use-version ${{ inputs.releaseVersion }} run: pnpm --filter=woocommerce run changelog write --add-pr-num -n -vvv --use-version ${{ inputs.releaseVersion }}
- name: Checkout pnpm-lock.yaml to prevent issues
run: git checkout pnpm-lock.yaml
- name: 'git rm deleted files' - name: 'git rm deleted files'
run: git rm $(git ls-files --deleted) run: git rm $(git ls-files --deleted)

View File

@ -60,7 +60,8 @@ jobs:
name: 'Maybe create next milestone and release branch' name: 'Maybe create next milestone and release branch'
runs-on: ubuntu-20.04 runs-on: ubuntu-20.04
permissions: permissions:
contents: read contents: write
issues: write
needs: verify-code-freeze needs: verify-code-freeze
if: needs.verify-code-freeze.outputs.freeze == 0 if: needs.verify-code-freeze.outputs.freeze == 0
outputs: outputs:
@ -89,7 +90,7 @@ jobs:
name: Preps trunk for next development cycle name: Preps trunk for next development cycle
runs-on: ubuntu-20.04 runs-on: ubuntu-20.04
permissions: permissions:
contents: read contents: write
pull-requests: write pull-requests: write
needs: maybe-create-next-milestone-and-release-branch needs: maybe-create-next-milestone-and-release-branch
steps: steps:

View File

@ -0,0 +1,34 @@
name: WooCommerce Beta Tester Release
permissions: {}
on:
workflow_dispatch:
inputs:
version:
description: 'The version number for the release'
required: true
jobs:
release:
name: Run release scripts
runs-on: ubuntu-20.04
permissions:
contents: write
steps:
- uses: actions/checkout@v3
- name: Setup WooCommerce Monorepo
uses: ./.github/actions/setup-woocommerce-monorepo
- name: Build WooCommerce Beta Tester Zip
working-directory: plugins/woocommerce-beta-tester
run: pnpm build:zip
- name: Create release
id: create_release
uses: woocommerce/action-gh-release@master
with:
tag_name: wc-beta-tester-${{ inputs.version }}
name: WooCommerce Beta Tester Release ${{ inputs.version }}
draft: false
files: plugins/woocommerce-beta-tester/woocommerce-beta-tester.zip

View File

@ -0,0 +1,4 @@
Significance: minor
Type: dev
Create tree-control component

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Add 6 basic fields to the product fields registry for use in extensibility within the new Product MVP.

View File

@ -0,0 +1,4 @@
Significance: patch
Type: add
Add an optional "InputProps" to experimental SelectControl component

View File

@ -0,0 +1,4 @@
Significance: patch
Type: dev
Migrate Table component to TS

View File

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

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Adding ProductSectionLayout component and changing default order for WooProductSectionItem component.

View File

@ -7,6 +7,7 @@ import {
UseComboboxState, UseComboboxState,
UseComboboxStateChangeOptions, UseComboboxStateChangeOptions,
useMultipleSelection, useMultipleSelection,
GetInputPropsOptions,
} from 'downshift'; } from 'downshift';
import { import {
useState, useState,
@ -65,6 +66,7 @@ export type SelectControlProps< ItemType > = {
selected: ItemType | ItemType[] | null; selected: ItemType | ItemType[] | null;
className?: string; className?: string;
disabled?: boolean; disabled?: boolean;
inputProps?: GetInputPropsOptions;
suffix?: JSX.Element | null; suffix?: JSX.Element | null;
/** /**
* This is a feature already implemented in downshift@7.0.0 through the * This is a feature already implemented in downshift@7.0.0 through the
@ -119,6 +121,7 @@ function SelectControl< ItemType = DefaultItemType >( {
selected, selected,
className, className,
disabled, disabled,
inputProps = {},
suffix = <SuffixIcon icon={ search } />, suffix = <SuffixIcon icon={ search } />,
__experimentalOpenMenuOnFocus = false, __experimentalOpenMenuOnFocus = false,
}: SelectControlProps< ItemType > ) { }: SelectControlProps< ItemType > ) {
@ -268,6 +271,7 @@ function SelectControl< ItemType = DefaultItemType >( {
onBlur: () => setIsFocused( false ), onBlur: () => setIsFocused( false ),
placeholder, placeholder,
disabled, disabled,
...inputProps,
} ) } } ) }
suffix={ suffix } suffix={ suffix }
> >

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -3,6 +3,10 @@
*/ */
import { select } from '@wordpress/data'; import { select } from '@wordpress/data';
import { createElement } from '@wordpress/element'; import { createElement } from '@wordpress/element';
import {
// @ts-expect-error `__experimentalInputControl` does exist.
__experimentalInputControl as InputControl,
} from '@wordpress/components';
/** /**
* Internal dependencies * Internal dependencies
@ -19,10 +23,7 @@ export function renderField( name: string, props: Record< string, any > ) {
return <fieldConfig.render { ...props } />; return <fieldConfig.render { ...props } />;
} }
if ( fieldConfig.type ) { if ( fieldConfig.type ) {
return createElement( 'input', { return <InputControl type={ fieldConfig.type } { ...props } />;
type: fieldConfig.type,
...props,
} );
} }
return null; return null;
} }

View File

@ -0,0 +1,15 @@
/**
* External dependencies
*/
import { ComponentType } from 'react';
/**
* Internal dependencies
*/
import { ProductFieldDefinition } from '../../store/types';
import render from './render';
export const basicSelectControlSettings: ProductFieldDefinition = {
name: 'basic-select-control',
render: render as ComponentType,
};

View File

@ -0,0 +1,34 @@
/**
* External dependencies
*/
import { createElement } from '@wordpress/element';
import { RadioControl, SelectControl } from '@wordpress/components';
/**
* Internal dependencies
*/
import { BaseProductFieldProps } from '../types';
type SelectControlFieldProps = BaseProductFieldProps< string | string[] > & {
multiple?: boolean;
options: SelectControl.Option[];
};
const SelectControlField: React.FC< SelectControlFieldProps > = ( {
label,
value,
onChange,
multiple,
options = [],
} ) => {
return (
<SelectControl
multiple={ multiple }
label={ label }
options={ options }
onChange={ onChange }
value={ value }
/>
);
};
export default SelectControlField;

View File

@ -0,0 +1,15 @@
/**
* External dependencies
*/
import { ComponentType } from 'react';
/**
* Internal dependencies
*/
import { ProductFieldDefinition } from '../../store/types';
import render from './render';
export const checkboxSettings: ProductFieldDefinition = {
name: 'checkbox',
render: render as ComponentType,
};

View File

@ -0,0 +1,28 @@
/**
* External dependencies
*/
import { createElement } from '@wordpress/element';
import { CheckboxControl } from '@wordpress/components';
/**
* Internal dependencies
*/
import { BaseProductFieldProps } from '../types';
type CheckboxFieldProps = BaseProductFieldProps< boolean >;
const CheckboxField: React.FC< CheckboxFieldProps > = ( {
label,
value,
onChange,
} ) => {
return (
<CheckboxControl
label={ label }
onChange={ onChange }
selected={ value }
/>
);
};
export default CheckboxField;

View File

@ -0,0 +1,29 @@
/**
* Internal dependencies
*/
import { registerProductField } from '../api';
import { ProductFieldDefinition } from '../store/types';
import { basicSelectControlSettings } from './basic-select-control';
import { checkboxSettings } from './checkbox';
import { radioSettings } from './radio';
import { textSettings } from './text';
import { toggleSettings } from './toggle';
const getAllProductFields = (): ProductFieldDefinition[] =>
[
...[ 'number' ].map( ( type ) => ( {
name: type,
type,
} ) ),
textSettings,
toggleSettings,
radioSettings,
basicSelectControlSettings,
checkboxSettings,
].filter( Boolean );
export const registerCoreProductFields = ( fields = getAllProductFields() ) => {
fields.forEach( ( field ) => {
registerProductField( field.name, field );
} );
};

View File

@ -0,0 +1,15 @@
/**
* External dependencies
*/
import { ComponentType } from 'react';
/**
* Internal dependencies
*/
import { ProductFieldDefinition } from '../../store/types';
import render from './render';
export const radioSettings: ProductFieldDefinition = {
name: 'radio',
render: render as ComponentType,
};

View File

@ -0,0 +1,34 @@
/**
* External dependencies
*/
import { createElement } from '@wordpress/element';
import { RadioControl } from '@wordpress/components';
/**
* Internal dependencies
*/
import { BaseProductFieldProps } from '../types';
type RadioFieldProps = BaseProductFieldProps< string > & {
options: {
label: string;
value: string;
}[];
};
const RadioField: React.FC< RadioFieldProps > = ( {
label,
value,
onChange,
options = [],
} ) => {
return (
<RadioControl
label={ label }
options={ options }
onChange={ onChange }
selected={ value }
/>
);
};
export default RadioField;

View File

@ -0,0 +1,15 @@
/**
* External dependencies
*/
import { ComponentType } from 'react';
/**
* Internal dependencies
*/
import { ProductFieldDefinition } from '../../store/types';
import render from './render';
export const textSettings: ProductFieldDefinition = {
name: 'text',
render: render as ComponentType,
};

View File

@ -0,0 +1,24 @@
/**
* External dependencies
*/
import { createElement } from '@wordpress/element';
import { TextControl } from '@wordpress/components';
/**
* Internal dependencies
*/
import { BaseProductFieldProps } from '../types';
type TextFieldProps = BaseProductFieldProps< string >;
const TextField: React.FC< TextFieldProps > = ( {
label,
value,
onChange,
} ) => {
return (
<TextControl label={ label } onChange={ onChange } value={ value } />
);
};
export default TextField;

View File

@ -0,0 +1,15 @@
/**
* External dependencies
*/
import { ComponentType } from 'react';
/**
* Internal dependencies
*/
import { ProductFieldDefinition } from '../../store/types';
import render from './render';
export const toggleSettings: ProductFieldDefinition = {
name: 'toggle',
render: render as ComponentType,
};

View File

@ -0,0 +1,40 @@
/**
* External dependencies
*/
import { createElement, Fragment } from '@wordpress/element';
import { ToggleControl } from '@wordpress/components';
/**
* Internal dependencies
*/
import { BaseProductFieldProps } from '../types';
import { Tooltip } from '../../../tooltip';
type ToggleFieldProps = BaseProductFieldProps< boolean > & {
tooltip?: string;
};
const ToggleField: React.FC< ToggleFieldProps > = ( {
label,
value,
onChange,
tooltip,
disabled = false,
} ) => {
return (
<ToggleControl
label={
<>
{ label }
{ tooltip && <Tooltip text={ tooltip } /> }
</>
}
checked={ value }
onChange={ onChange }
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore disabled prop exists
disabled={ disabled }
/>
);
};
export default ToggleField;

View File

@ -0,0 +1,6 @@
export type BaseProductFieldProps< T > = {
value: T;
onChange: ( value: T ) => void;
label: string;
disabled?: boolean;
};

View File

@ -1,2 +1,3 @@
export { store } from './store'; export { store } from './store';
export * from './api'; export * from './api';
export * from './fields';

View File

@ -1,11 +1,11 @@
/** /**
* External dependencies * External dependencies
*/ */
import { ComponentType } from 'react'; import { ComponentType, HTMLInputTypeAttribute } from 'react';
export type ProductFieldDefinition = { export type ProductFieldDefinition = {
name: string; name: string;
type?: string; type?: HTMLInputTypeAttribute;
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
render?: ComponentType; render?: ComponentType;
}; };

View File

@ -3,56 +3,92 @@
*/ */
import React from 'react'; import React from 'react';
import { useState, createElement } from '@wordpress/element'; import { useState, createElement } from '@wordpress/element';
import { createRegistry, RegistryProvider, select } from '@wordpress/data'; import { createRegistry, RegistryProvider } from '@wordpress/data';
import {
// @ts-expect-error `__experimentalInputControl` does exist.
__experimentalInputControl as InputControl,
} from '@wordpress/components';
/** /**
* Internal dependencies * Internal dependencies
*/ */
import { store } from '../store'; import { store } from '../store';
import { registerProductField, renderField } from '../api'; import { renderField } from '../api';
import { registerCoreProductFields } from '../fields';
const registry = createRegistry(); const registry = createRegistry();
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore No types for this exist yet. // @ts-ignore No types for this exist yet.
registry.register( store ); registry.register( store );
registerProductField( 'text', { registerCoreProductFields();
name: 'text',
render: ( props ) => {
return <InputControl type="text" { ...props } />;
},
} );
registerProductField( 'number', { const fieldConfigs = [
name: 'number', {
render: () => { name: 'text-field',
return <InputControl type="number" />; type: 'text',
label: 'Text field',
}, },
} ); {
name: 'number-field',
type: 'number',
label: 'Number field',
},
{
name: 'toggle-field',
type: 'toggle',
label: 'Toggle field',
},
{
name: 'checkbox-field',
type: 'checkbox',
label: 'Checkbox field',
},
{
name: 'radio-field',
type: 'radio',
label: 'Radio field',
options: [
{ label: 'Option', value: 'option' },
{ label: 'Option 2', value: 'option2' },
{ label: 'Option 3', value: 'option3' },
],
},
{
name: 'basic-select-control-field',
type: 'basic-select-control',
label: 'Basic select control field',
options: [
{ label: 'Option', value: 'option' },
{ label: 'Option 2', value: 'option2' },
{ label: 'Option 3', value: 'option3' },
],
},
];
const RenderField = () => { const RenderField = () => {
const fields: string[] = select( store ).getRegisteredProductFields();
const [ selectedField, setSelectedField ] = useState( const [ selectedField, setSelectedField ] = useState(
fields ? fields[ 0 ] : undefined fieldConfigs[ 0 ].name || undefined
); );
const [ value, setValue ] = useState();
const handleChange = ( event ) => { const handleChange = ( event ) => {
setSelectedField( event.target.value ); setSelectedField( event.target.value );
}; };
const selectedFieldConfig = fieldConfigs.find(
( f ) => f.name === selectedField
);
return ( return (
<div> <div>
<select value={ selectedField } onChange={ handleChange }> <select value={ selectedField } onChange={ handleChange }>
{ fields.map( ( field ) => ( { fieldConfigs.map( ( field ) => (
<option key={ field } value={ field }> <option key={ field.name } value={ field.name }>
{ field } { field.label }
</option> </option>
) ) } ) ) }
</select> </select>
{ selectedField && renderField( selectedField, { name: 'test' } ) } { selectedFieldConfig &&
renderField( selectedFieldConfig.type, {
value,
onChange: setValue,
...selectedFieldConfig,
} ) }
</div> </div>
); );
}; };
@ -65,6 +101,21 @@ export const Basic: React.FC = () => {
); );
}; };
export const ToggleWithTooltip: React.FC = () => {
const [ value, setValue ] = useState();
return (
<RegistryProvider value={ registry }>
{ renderField( 'toggle', {
value,
onChange: setValue,
name: 'toggle',
label: 'Toggle with Tooltip',
tooltip: 'This is a sample tooltip',
} ) }
</RegistryProvider>
);
};
export default { export default {
title: 'WooCommerce Admin/experimental/product-fields', title: 'WooCommerce Admin/experimental/product-fields',
component: Basic, component: Basic,

View File

@ -0,0 +1,2 @@
export * from './product-section-layout';
export * from './product-field-section';

View File

@ -0,0 +1,39 @@
/**
* External dependencies
*/
import { createElement } from '@wordpress/element';
import { Card, CardBody } from '@wordpress/components';
/**
* Internal dependencies
*/
import { ProductSectionLayout } from './product-section-layout';
import { WooProductFieldItem } from '../woo-product-field-item';
type ProductFieldSectionProps = {
id: string;
title: string;
description: string | JSX.Element;
className?: string;
};
export const ProductFieldSection: React.FC< ProductFieldSectionProps > = ( {
id,
title,
description,
className,
children,
} ) => (
<ProductSectionLayout
title={ title }
description={ description }
className={ className }
>
<Card>
<CardBody>
{ children }
<WooProductFieldItem.Slot section={ id } />
</CardBody>
</Card>
</ProductSectionLayout>
);

View File

@ -0,0 +1,35 @@
/**
* External dependencies
*/
import { Children, isValidElement, createElement } from '@wordpress/element';
/**
* Internal dependencies
*/
import { FormSection } from '../form-section';
type ProductSectionLayoutProps = {
title: string;
description: string | JSX.Element;
className?: string;
};
export const ProductSectionLayout: React.FC< ProductSectionLayoutProps > = ( {
title,
description,
className,
children,
} ) => (
<FormSection
title={ title }
description={ description }
className={ className }
>
{ Children.map( children, ( child ) => {
if ( isValidElement( child ) && child.props.onChange ) {
return <div className="product-field-layout">{ child }</div>;
}
return child;
} ) }
</FormSection>
);

View File

@ -0,0 +1,52 @@
.woocommerce-form-section {
a {
text-decoration: none;
}
&__content {
.components-card {
border: 1px solid $gray-400;
border-radius: 2px;
box-shadow: none;
&__body {
padding: $gap-large;
> .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;
}
}
}
}
.woocommerce-product-form__field:not(:first-child) {
margin-top: $gap-large;
> .components-base-control {
margin-bottom: 0;
}
}
.components-radio-control .components-v-stack {
gap: $gap-small;
}
.woocommerce-collapsible-content {
margin-top: $gap-large;
}
}
&__header {
p > span {
display: block;
margin-bottom: $gap-smaller;
}
}
&:not(:first-child) {
margin-top: $gap-largest;
}
}

View File

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

View File

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

View File

@ -55,3 +55,4 @@
@import 'tour-kit/style.scss'; @import 'tour-kit/style.scss';
@import 'collapsible-content/style.scss'; @import 'collapsible-content/style.scss';
@import 'form/style.scss'; @import 'form/style.scss';
@import 'product-section-layout/style.scss';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,17 +1,17 @@
/** /**
* External dependencies * External dependencies
*/ */
import PropTypes from 'prop-types';
import { createElement } from '@wordpress/element'; import { createElement } from '@wordpress/element';
/** /**
* A component to display summarized table data - the list of data passed in on a single line. * Internal dependencies
*
* @param {Object} props
* @param {Array} props.data
* @return {Object} -
*/ */
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 ( return (
<ul className="woocommerce-table__summary" role="complementary"> <ul className="woocommerce-table__summary" role="complementary">
{ data.map( ( { label, value }, i ) => ( { 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; export default TableSummary;
/** /**

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -23,7 +23,7 @@ type WooProductFieldSlotProps = {
export const WooProductSectionItem: React.FC< WooProductSectionItemProps > & { export const WooProductSectionItem: React.FC< WooProductSectionItemProps > & {
Slot: React.FC< Slot.Props & WooProductFieldSlotProps >; Slot: React.FC< Slot.Props & WooProductFieldSlotProps >;
} = ( { children, order = 1, location } ) => ( } = ( { children, order = 20, location } ) => (
<Fill name={ `woocommerce_product_section_${ location }` }> <Fill name={ `woocommerce_product_section_${ location }` }>
{ ( fillProps: Fill.Props ) => { { ( fillProps: Fill.Props ) => {
return createOrderedChildren< Fill.Props >( return createOrderedChildren< Fill.Props >(

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Adding Product Form data store.

View File

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

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Add ability to check CRUD dispatch action status

View File

@ -1,13 +1,16 @@
export enum TYPES { export enum TYPES {
CREATE_ITEM_ERROR = 'CREATE_ITEM_ERROR', CREATE_ITEM_ERROR = 'CREATE_ITEM_ERROR',
CREATE_ITEM_REQUEST = 'CREATE_ITEM_REQUEST',
CREATE_ITEM_SUCCESS = 'CREATE_ITEM_SUCCESS', CREATE_ITEM_SUCCESS = 'CREATE_ITEM_SUCCESS',
DELETE_ITEM_ERROR = 'DELETE_ITEM_ERROR', DELETE_ITEM_ERROR = 'DELETE_ITEM_ERROR',
DELETE_ITEM_REQUEST = 'DELETE_ITEM_REQUEST',
DELETE_ITEM_SUCCESS = 'DELETE_ITEM_SUCCESS', DELETE_ITEM_SUCCESS = 'DELETE_ITEM_SUCCESS',
GET_ITEM_ERROR = 'GET_ITEM_ERROR', GET_ITEM_ERROR = 'GET_ITEM_ERROR',
GET_ITEM_SUCCESS = 'GET_ITEM_SUCCESS', GET_ITEM_SUCCESS = 'GET_ITEM_SUCCESS',
GET_ITEMS_ERROR = 'GET_ITEMS_ERROR', GET_ITEMS_ERROR = 'GET_ITEMS_ERROR',
GET_ITEMS_SUCCESS = 'GET_ITEMS_SUCCESS', GET_ITEMS_SUCCESS = 'GET_ITEMS_SUCCESS',
UPDATE_ITEM_ERROR = 'UPDATE_ITEM_ERROR', UPDATE_ITEM_ERROR = 'UPDATE_ITEM_ERROR',
UPDATE_ITEM_REQUEST = 'UPDATE_ITEM_REQUEST',
UPDATE_ITEM_SUCCESS = 'UPDATE_ITEM_SUCCESS', UPDATE_ITEM_SUCCESS = 'UPDATE_ITEM_SUCCESS',
GET_ITEMS_TOTAL_COUNT_SUCCESS = 'GET_ITEMS_TOTAL_COUNT_SUCCESS', GET_ITEMS_TOTAL_COUNT_SUCCESS = 'GET_ITEMS_TOTAL_COUNT_SUCCESS',
GET_ITEMS_TOTAL_COUNT_ERROR = 'GET_ITEMS_TOTAL_COUNT_ERROR', GET_ITEMS_TOTAL_COUNT_ERROR = 'GET_ITEMS_TOTAL_COUNT_ERROR',

View File

@ -25,20 +25,41 @@ export function createItemError( query: Partial< ItemQuery >, error: unknown ) {
}; };
} }
export function createItemSuccess( key: IdType, item: Item ) { export function createItemRequest( query: Partial< ItemQuery > ) {
return {
type: TYPES.CREATE_ITEM_REQUEST as const,
query,
};
}
export function createItemSuccess(
key: IdType,
item: Item,
query: Partial< ItemQuery >
) {
return { return {
type: TYPES.CREATE_ITEM_SUCCESS as const, type: TYPES.CREATE_ITEM_SUCCESS as const,
key, key,
item, item,
query,
}; };
} }
export function deleteItemError( key: IdType, error: unknown ) { export function deleteItemError( key: IdType, error: unknown, force: boolean ) {
return { return {
type: TYPES.DELETE_ITEM_ERROR as const, type: TYPES.DELETE_ITEM_ERROR as const,
key, key,
error, error,
errorType: CRUD_ACTIONS.DELETE_ITEM, errorType: CRUD_ACTIONS.DELETE_ITEM,
force,
};
}
export function deleteItemRequest( key: IdType, force: boolean ) {
return {
type: TYPES.DELETE_ITEM_REQUEST as const,
key,
force,
}; };
} }
@ -116,20 +137,38 @@ export function getItemsTotalCountError(
}; };
} }
export function updateItemError( key: IdType, error: unknown ) { export function updateItemError(
key: IdType,
error: unknown,
query: Partial< ItemQuery >
) {
return { return {
type: TYPES.UPDATE_ITEM_ERROR as const, type: TYPES.UPDATE_ITEM_ERROR as const,
key, key,
error, error,
errorType: CRUD_ACTIONS.UPDATE_ITEM, errorType: CRUD_ACTIONS.UPDATE_ITEM,
query,
}; };
} }
export function updateItemSuccess( key: IdType, item: Item ) { export function updateItemRequest( key: IdType, query: Partial< ItemQuery > ) {
return {
type: TYPES.UPDATE_ITEM_REQUEST as const,
key,
query,
};
}
export function updateItemSuccess(
key: IdType,
item: Item,
query: Partial< ItemQuery >
) {
return { return {
type: TYPES.UPDATE_ITEM_SUCCESS as const, type: TYPES.UPDATE_ITEM_SUCCESS as const,
key, key,
item, item,
query,
}; };
} }
@ -138,6 +177,7 @@ export const createDispatchActions = ( {
resourceName, resourceName,
}: ResolverOptions ) => { }: ResolverOptions ) => {
const createItem = function* ( query: Partial< ItemQuery > ) { const createItem = function* ( query: Partial< ItemQuery > ) {
yield createItemRequest( query );
const urlParameters = getUrlParameters( namespace, query ); const urlParameters = getUrlParameters( namespace, query );
try { try {
@ -151,7 +191,7 @@ export const createDispatchActions = ( {
} ); } );
const { key } = parseId( item.id, urlParameters ); const { key } = parseId( item.id, urlParameters );
yield createItemSuccess( key, item ); yield createItemSuccess( key, item, query );
return item; return item;
} catch ( error ) { } catch ( error ) {
yield createItemError( query, error ); yield createItemError( query, error );
@ -162,6 +202,7 @@ export const createDispatchActions = ( {
const deleteItem = function* ( idQuery: IdQuery, force = true ) { const deleteItem = function* ( idQuery: IdQuery, force = true ) {
const urlParameters = getUrlParameters( namespace, idQuery ); const urlParameters = getUrlParameters( namespace, idQuery );
const { id, key } = parseId( idQuery, urlParameters ); const { id, key } = parseId( idQuery, urlParameters );
yield deleteItemRequest( key, force );
try { try {
const item: Item = yield apiFetch( { const item: Item = yield apiFetch( {
@ -176,7 +217,7 @@ export const createDispatchActions = ( {
yield deleteItemSuccess( key, force, item ); yield deleteItemSuccess( key, force, item );
return item; return item;
} catch ( error ) { } catch ( error ) {
yield deleteItemError( key, error ); yield deleteItemError( key, error, force );
throw error; throw error;
} }
}; };
@ -187,6 +228,7 @@ export const createDispatchActions = ( {
) { ) {
const urlParameters = getUrlParameters( namespace, idQuery ); const urlParameters = getUrlParameters( namespace, idQuery );
const { id, key } = parseId( idQuery, urlParameters ); const { id, key } = parseId( idQuery, urlParameters );
yield updateItemRequest( key, query );
try { try {
const item: Item = yield apiFetch( { const item: Item = yield apiFetch( {
@ -199,10 +241,10 @@ export const createDispatchActions = ( {
data: query, data: query,
} ); } );
yield updateItemSuccess( key, item ); yield updateItemSuccess( key, item, query );
return item; return item;
} catch ( error ) { } catch ( error ) {
yield updateItemError( key, error ); yield updateItemError( key, error, query );
throw error; throw error;
} }
}; };
@ -216,8 +258,10 @@ export const createDispatchActions = ( {
export type Actions = ReturnType< export type Actions = ReturnType<
| typeof createItemError | typeof createItemError
| typeof createItemRequest
| typeof createItemSuccess | typeof createItemSuccess
| typeof deleteItemError | typeof deleteItemError
| typeof deleteItemRequest
| typeof deleteItemSuccess | typeof deleteItemSuccess
| typeof getItemError | typeof getItemError
| typeof getItemSuccess | typeof getItemSuccess
@ -226,5 +270,6 @@ export type Actions = ReturnType<
| typeof getItemsTotalCountSuccess | typeof getItemsTotalCountSuccess
| typeof getItemsTotalCountError | typeof getItemsTotalCountError
| typeof updateItemError | typeof updateItemError
| typeof updateItemRequest
| typeof updateItemSuccess | typeof updateItemSuccess
>; >;

View File

@ -8,8 +8,8 @@ import { Reducer } from 'redux';
*/ */
import { Actions } from './actions'; import { Actions } from './actions';
import CRUD_ACTIONS from './crud-actions'; import CRUD_ACTIONS from './crud-actions';
import { getKey } from './utils'; import { getKey, getRequestIdentifier } from './utils';
import { getResourceName, getTotalCountResourceName } from '../utils'; import { getTotalCountResourceName } from '../utils';
import { IdType, Item, ItemQuery } from './types'; import { IdType, Item, ItemQuery } from './types';
import { TYPES } from './action-types'; import { TYPES } from './action-types';
@ -24,6 +24,7 @@ export type ResourceState = {
data: Data; data: Data;
itemsCount: Record< string, number >; itemsCount: Record< string, number >;
errors: Record< string, unknown >; errors: Record< string, unknown >;
requesting: Record< string, boolean >;
}; };
export const createReducer = () => { export const createReducer = () => {
@ -33,19 +34,37 @@ export const createReducer = () => {
data: {}, data: {},
itemsCount: {}, itemsCount: {},
errors: {}, errors: {},
requesting: {},
}, },
payload payload
) => { ) => {
const itemData = state.data || {};
if ( payload && 'type' in payload ) { if ( payload && 'type' in payload ) {
switch ( payload.type ) { switch ( payload.type ) {
case TYPES.CREATE_ITEM_ERROR: case TYPES.CREATE_ITEM_ERROR:
const createItemErrorRequestId = getRequestIdentifier(
payload.errorType,
payload.query || {}
);
return {
...state,
errors: {
...state.errors,
[ createItemErrorRequestId ]: payload.error,
},
requesting: {
...state.requesting,
[ createItemErrorRequestId ]: false,
},
};
case TYPES.GET_ITEMS_TOTAL_COUNT_ERROR: case TYPES.GET_ITEMS_TOTAL_COUNT_ERROR:
case TYPES.GET_ITEMS_ERROR: case TYPES.GET_ITEMS_ERROR:
return { return {
...state, ...state,
errors: { errors: {
...state.errors, ...state.errors,
[ getResourceName( [ getRequestIdentifier(
payload.errorType, payload.errorType,
( payload.query || {} ) as ItemQuery ( payload.query || {} ) as ItemQuery
) ]: payload.error, ) ]: payload.error,
@ -64,9 +83,27 @@ export const createReducer = () => {
}; };
case TYPES.CREATE_ITEM_SUCCESS: case TYPES.CREATE_ITEM_SUCCESS:
const createItemSuccessRequestId = getRequestIdentifier(
CRUD_ACTIONS.CREATE_ITEM,
payload.key,
payload.query
);
return {
...state,
data: {
...itemData,
[ payload.key ]: {
...( itemData[ payload.key ] || {} ),
...payload.item,
},
},
requesting: {
...state.requesting,
[ createItemSuccessRequestId ]: false,
},
};
case TYPES.GET_ITEM_SUCCESS: case TYPES.GET_ITEM_SUCCESS:
case TYPES.UPDATE_ITEM_SUCCESS:
const itemData = state.data || {};
return { return {
...state, ...state,
data: { data: {
@ -78,7 +115,33 @@ export const createReducer = () => {
}, },
}; };
case TYPES.UPDATE_ITEM_SUCCESS:
const updateItemSuccessRequestId = getRequestIdentifier(
CRUD_ACTIONS.UPDATE_ITEM,
payload.key,
payload.query
);
return {
...state,
data: {
...itemData,
[ payload.key ]: {
...( itemData[ payload.key ] || {} ),
...payload.item,
},
},
requesting: {
...state.requesting,
[ updateItemSuccessRequestId ]: false,
},
};
case TYPES.DELETE_ITEM_SUCCESS: case TYPES.DELETE_ITEM_SUCCESS:
const deleteItemSuccessRequestId = getRequestIdentifier(
CRUD_ACTIONS.DELETE_ITEM,
payload.key,
payload.force
);
const itemKeys = Object.keys( state.data ); const itemKeys = Object.keys( state.data );
const nextData = itemKeys.reduce< Data >( const nextData = itemKeys.reduce< Data >(
( items: Data, key: string ) => { ( items: Data, key: string ) => {
@ -98,18 +161,57 @@ export const createReducer = () => {
return { return {
...state, ...state,
data: nextData, data: nextData,
requesting: {
...state.requesting,
[ deleteItemSuccessRequestId ]: false,
},
}; };
case TYPES.DELETE_ITEM_ERROR: case TYPES.DELETE_ITEM_ERROR:
case TYPES.GET_ITEM_ERROR: const deleteItemErrorRequestId = getRequestIdentifier(
case TYPES.UPDATE_ITEM_ERROR: payload.errorType,
payload.key,
payload.force
);
return { return {
...state, ...state,
errors: { errors: {
...state.errors, ...state.errors,
[ getResourceName( payload.errorType, { [ deleteItemErrorRequestId ]: payload.error,
key: payload.key, },
} ) ]: payload.error, requesting: {
...state.requesting,
[ deleteItemErrorRequestId ]: false,
},
};
case TYPES.GET_ITEM_ERROR:
return {
...state,
errors: {
...state.errors,
[ getRequestIdentifier(
payload.errorType,
payload.key
) ]: payload.error,
},
};
case TYPES.UPDATE_ITEM_ERROR:
const upateItemErrorRequestId = getRequestIdentifier(
payload.errorType,
payload.key,
payload.query
);
return {
...state,
errors: {
...state.errors,
[ upateItemErrorRequestId ]: payload.error,
},
requesting: {
...state.requesting,
[ upateItemErrorRequestId ]: false,
}, },
}; };
@ -128,7 +230,7 @@ export const createReducer = () => {
return result; return result;
}, {} ); }, {} );
const itemQuery = getResourceName( const itemQuery = getRequestIdentifier(
CRUD_ACTIONS.GET_ITEMS, CRUD_ACTIONS.GET_ITEMS,
( payload.query || {} ) as ItemQuery ( payload.query || {} ) as ItemQuery
); );
@ -145,6 +247,44 @@ export const createReducer = () => {
}, },
}; };
case TYPES.CREATE_ITEM_REQUEST:
return {
...state,
requesting: {
...state.requesting,
[ getRequestIdentifier(
CRUD_ACTIONS.CREATE_ITEM,
payload.query
) ]: true,
},
};
case TYPES.DELETE_ITEM_REQUEST:
return {
...state,
requesting: {
...state.requesting,
[ getRequestIdentifier(
CRUD_ACTIONS.DELETE_ITEM,
payload.key,
payload.force
) ]: true,
},
};
case TYPES.UPDATE_ITEM_REQUEST:
return {
...state,
requesting: {
...state.requesting,
[ getRequestIdentifier(
CRUD_ACTIONS.UPDATE_ITEM,
payload.key,
payload.query
) ]: true,
},
};
default: default:
return state; return state;
} }

View File

@ -6,8 +6,15 @@ import createSelector from 'rememo';
/** /**
* Internal dependencies * Internal dependencies
*/ */
import { applyNamespace, getUrlParameters, parseId } from './utils'; import {
import { getResourceName, getTotalCountResourceName } from '../utils'; applyNamespace,
getGenericActionName,
getRequestIdentifier,
getUrlParameters,
maybeReplaceIdQuery,
parseId,
} from './utils';
import { getTotalCountResourceName } from '../utils';
import { IdQuery, IdType, Item, ItemQuery } from './types'; import { IdQuery, IdType, Item, ItemQuery } from './types';
import { ResourceState } from './reducer'; import { ResourceState } from './reducer';
import CRUD_ACTIONS from './crud-actions'; import CRUD_ACTIONS from './crud-actions';
@ -22,7 +29,7 @@ export const getItemCreateError = (
state: ResourceState, state: ResourceState,
query: ItemQuery query: ItemQuery
) => { ) => {
const itemQuery = getResourceName( CRUD_ACTIONS.CREATE_ITEM, query ); const itemQuery = getRequestIdentifier( CRUD_ACTIONS.CREATE_ITEM, query );
return state.errors[ itemQuery ]; return state.errors[ itemQuery ];
}; };
@ -33,7 +40,7 @@ export const getItemDeleteError = (
) => { ) => {
const urlParameters = getUrlParameters( namespace, idQuery ); const urlParameters = getUrlParameters( namespace, idQuery );
const { key } = parseId( idQuery, urlParameters ); const { key } = parseId( idQuery, urlParameters );
const itemQuery = getResourceName( CRUD_ACTIONS.DELETE_ITEM, { key } ); const itemQuery = getRequestIdentifier( CRUD_ACTIONS.DELETE_ITEM, key );
return state.errors[ itemQuery ]; return state.errors[ itemQuery ];
}; };
@ -54,13 +61,13 @@ export const getItemError = (
) => { ) => {
const urlParameters = getUrlParameters( namespace, idQuery ); const urlParameters = getUrlParameters( namespace, idQuery );
const { key } = parseId( idQuery, urlParameters ); const { key } = parseId( idQuery, urlParameters );
const itemQuery = getResourceName( CRUD_ACTIONS.GET_ITEM, { key } ); const itemQuery = getRequestIdentifier( CRUD_ACTIONS.GET_ITEM, key );
return state.errors[ itemQuery ]; return state.errors[ itemQuery ];
}; };
export const getItems = createSelector( export const getItems = createSelector(
( state: ResourceState, query?: ItemQuery ) => { ( state: ResourceState, query?: ItemQuery ) => {
const itemQuery = getResourceName( const itemQuery = getRequestIdentifier(
CRUD_ACTIONS.GET_ITEMS, CRUD_ACTIONS.GET_ITEMS,
query || {} query || {}
); );
@ -96,7 +103,7 @@ export const getItems = createSelector(
return data; return data;
}, },
( state, query ) => { ( state, query ) => {
const itemQuery = getResourceName( const itemQuery = getRequestIdentifier(
CRUD_ACTIONS.GET_ITEMS, CRUD_ACTIONS.GET_ITEMS,
query || {} query || {}
); );
@ -129,7 +136,10 @@ export const getItemsTotalCount = (
}; };
export const getItemsError = ( state: ResourceState, query?: ItemQuery ) => { export const getItemsError = ( state: ResourceState, query?: ItemQuery ) => {
const itemQuery = getResourceName( CRUD_ACTIONS.GET_ITEMS, query || {} ); const itemQuery = getRequestIdentifier(
CRUD_ACTIONS.GET_ITEMS,
query || {}
);
return state.errors[ itemQuery ]; return state.errors[ itemQuery ];
}; };
@ -138,12 +148,8 @@ export const getItemUpdateError = (
idQuery: IdQuery, idQuery: IdQuery,
urlParameters: IdType[] urlParameters: IdType[]
) => { ) => {
const params = parseId( idQuery, urlParameters ); const { key } = parseId( idQuery, urlParameters );
const { key } = params; const itemQuery = getRequestIdentifier( CRUD_ACTIONS.UPDATE_ITEM, key );
const itemQuery = getResourceName( CRUD_ACTIONS.UPDATE_ITEM, {
key,
params,
} );
return state.errors[ itemQuery ]; return state.errors[ itemQuery ];
}; };
@ -154,6 +160,32 @@ export const createSelectors = ( {
pluralResourceName, pluralResourceName,
namespace, namespace,
}: SelectorOptions ) => { }: SelectorOptions ) => {
const hasFinishedRequest = (
state: ResourceState,
action: string,
args = []
) => {
const sanitizedArgs = maybeReplaceIdQuery( args, namespace );
const actionName = getGenericActionName( action, resourceName );
const requestId = getRequestIdentifier( actionName, ...sanitizedArgs );
if ( action )
return (
state.requesting.hasOwnProperty( requestId ) &&
! state.requesting[ requestId ]
);
};
const isRequesting = (
state: ResourceState,
action: string,
args = []
) => {
const sanitizedArgs = maybeReplaceIdQuery( args, namespace );
const actionName = getGenericActionName( action, resourceName );
const requestId = getRequestIdentifier( actionName, ...sanitizedArgs );
return state.requesting[ requestId ];
};
return { return {
[ `get${ resourceName }` ]: applyNamespace( getItem, namespace ), [ `get${ resourceName }` ]: applyNamespace( getItem, namespace ),
[ `get${ resourceName }Error` ]: applyNamespace( [ `get${ resourceName }Error` ]: applyNamespace(
@ -184,5 +216,7 @@ export const createSelectors = ( {
getItemUpdateError, getItemUpdateError,
namespace namespace
), ),
hasFinishedRequest,
isRequesting,
}; };
}; };

View File

@ -5,6 +5,7 @@ import { Actions } from '../actions';
import { createReducer, ResourceState } from '../reducer'; import { createReducer, ResourceState } from '../reducer';
import { CRUD_ACTIONS } from '../crud-actions'; import { CRUD_ACTIONS } from '../crud-actions';
import { getResourceName } from '../../utils'; import { getResourceName } from '../../utils';
import { getRequestIdentifier } from '..//utils';
import { Item, ItemQuery } from '../types'; import { Item, ItemQuery } from '../types';
import TYPES from '../action-types'; import TYPES from '../action-types';
@ -13,6 +14,7 @@ const defaultState: ResourceState = {
errors: {}, errors: {},
itemsCount: {}, itemsCount: {},
data: {}, data: {},
requesting: {},
}; };
const reducer = createReducer(); const reducer = createReducer();
@ -38,6 +40,7 @@ describe( 'crud reducer', () => {
1: { id: 1, name: 'Donkey', status: 'draft' }, 1: { id: 1, name: 'Donkey', status: 'draft' },
2: { id: 2, name: 'Sauce', status: 'publish' }, 2: { id: 2, name: 'Sauce', status: 'publish' },
}, },
requesting: {},
}; };
const update: Item = { const update: Item = {
id: 2, id: 2,
@ -72,7 +75,10 @@ describe( 'crud reducer', () => {
urlParameters: [], urlParameters: [],
} ); } );
const resourceName = getResourceName( CRUD_ACTIONS.GET_ITEMS, query ); const resourceName = getRequestIdentifier(
CRUD_ACTIONS.GET_ITEMS,
query
);
expect( state.items[ resourceName ].data ).toHaveLength( 2 ); expect( state.items[ resourceName ].data ).toHaveLength( 2 );
expect( state.items[ resourceName ].data.includes( 1 ) ).toBeTruthy(); expect( state.items[ resourceName ].data.includes( 1 ) ).toBeTruthy();
@ -108,7 +114,10 @@ describe( 'crud reducer', () => {
urlParameters: [ 5 ], urlParameters: [ 5 ],
} ); } );
const resourceName = getResourceName( CRUD_ACTIONS.GET_ITEMS, query ); const resourceName = getRequestIdentifier(
CRUD_ACTIONS.GET_ITEMS,
query
);
expect( state.items[ resourceName ].data ).toHaveLength( 2 ); expect( state.items[ resourceName ].data ).toHaveLength( 2 );
expect( state.data[ '5/1' ] ).toEqual( items[ 0 ] ); expect( state.data[ '5/1' ] ).toEqual( items[ 0 ] );
@ -141,7 +150,10 @@ describe( 'crud reducer', () => {
urlParameters: [], urlParameters: [],
} ); } );
const resourceName = getResourceName( CRUD_ACTIONS.GET_ITEMS, query ); const resourceName = getRequestIdentifier(
CRUD_ACTIONS.GET_ITEMS,
query
);
expect( state.items[ resourceName ].data ).toHaveLength( 2 ); expect( state.items[ resourceName ].data ).toHaveLength( 2 );
expect( state.items[ resourceName ].data.includes( 1 ) ).toBeTruthy(); expect( state.items[ resourceName ].data.includes( 1 ) ).toBeTruthy();
@ -157,7 +169,10 @@ describe( 'crud reducer', () => {
it( 'should handle GET_ITEMS_ERROR', () => { it( 'should handle GET_ITEMS_ERROR', () => {
const query: Partial< ItemQuery > = { status: 'draft' }; const query: Partial< ItemQuery > = { status: 'draft' };
const resourceName = getResourceName( CRUD_ACTIONS.GET_ITEMS, query ); const resourceName = getRequestIdentifier(
CRUD_ACTIONS.GET_ITEMS,
query
);
const error = 'Baaam!'; const error = 'Baaam!';
const state = reducer( defaultState, { const state = reducer( defaultState, {
type: TYPES.GET_ITEMS_ERROR, type: TYPES.GET_ITEMS_ERROR,
@ -171,7 +186,7 @@ describe( 'crud reducer', () => {
it( 'should handle GET_ITEMS_TOTAL_COUNT_ERROR', () => { it( 'should handle GET_ITEMS_TOTAL_COUNT_ERROR', () => {
const query: Partial< ItemQuery > = { status: 'draft' }; const query: Partial< ItemQuery > = { status: 'draft' };
const resourceName = getResourceName( const resourceName = getRequestIdentifier(
CRUD_ACTIONS.GET_ITEMS_TOTAL_COUNT, CRUD_ACTIONS.GET_ITEMS_TOTAL_COUNT,
query query
); );
@ -188,7 +203,7 @@ describe( 'crud reducer', () => {
it( 'should handle GET_ITEM_ERROR', () => { it( 'should handle GET_ITEM_ERROR', () => {
const key = 3; const key = 3;
const resourceName = getResourceName( CRUD_ACTIONS.GET_ITEM, { key } ); const resourceName = getRequestIdentifier( CRUD_ACTIONS.GET_ITEM, key );
const error = 'Baaam!'; const error = 'Baaam!';
const state = reducer( defaultState, { const state = reducer( defaultState, {
type: TYPES.GET_ITEM_ERROR, type: TYPES.GET_ITEM_ERROR,
@ -202,7 +217,7 @@ describe( 'crud reducer', () => {
it( 'should handle GET_ITEM_ERROR', () => { it( 'should handle GET_ITEM_ERROR', () => {
const key = 3; const key = 3;
const resourceName = getResourceName( CRUD_ACTIONS.GET_ITEM, { key } ); const resourceName = getRequestIdentifier( CRUD_ACTIONS.GET_ITEM, key );
const error = 'Baaam!'; const error = 'Baaam!';
const state = reducer( defaultState, { const state = reducer( defaultState, {
type: TYPES.GET_ITEM_ERROR, type: TYPES.GET_ITEM_ERROR,
@ -216,7 +231,10 @@ describe( 'crud reducer', () => {
it( 'should handle CREATE_ITEM_ERROR', () => { it( 'should handle CREATE_ITEM_ERROR', () => {
const query = { name: 'Invalid product' }; const query = { name: 'Invalid product' };
const resourceName = getResourceName( CRUD_ACTIONS.CREATE_ITEM, query ); const resourceName = getRequestIdentifier(
CRUD_ACTIONS.CREATE_ITEM,
query
);
const error = 'Baaam!'; const error = 'Baaam!';
const state = reducer( defaultState, { const state = reducer( defaultState, {
type: TYPES.CREATE_ITEM_ERROR, type: TYPES.CREATE_ITEM_ERROR,
@ -226,22 +244,28 @@ describe( 'crud reducer', () => {
} ); } );
expect( state.errors[ resourceName ] ).toBe( error ); expect( state.errors[ resourceName ] ).toBe( error );
expect( state.requesting[ resourceName ] ).toBe( false );
} ); } );
it( 'should handle UPDATE_ITEM_ERROR', () => { it( 'should handle UPDATE_ITEM_ERROR', () => {
const key = 2; const key = 2;
const resourceName = getResourceName( CRUD_ACTIONS.UPDATE_ITEM, { const query = { property: 'value' };
const resourceName = getRequestIdentifier(
CRUD_ACTIONS.UPDATE_ITEM,
key, key,
} ); query
);
const error = 'Baaam!'; const error = 'Baaam!';
const state = reducer( defaultState, { const state = reducer( defaultState, {
type: TYPES.UPDATE_ITEM_ERROR, type: TYPES.UPDATE_ITEM_ERROR,
key, key,
error, error,
errorType: CRUD_ACTIONS.UPDATE_ITEM, errorType: CRUD_ACTIONS.UPDATE_ITEM,
query,
} ); } );
expect( state.errors[ resourceName ] ).toBe( error ); expect( state.errors[ resourceName ] ).toBe( error );
expect( state.requesting[ resourceName ] ).toBe( false );
} ); } );
it( 'should handle UPDATE_ITEM_SUCCESS', () => { it( 'should handle UPDATE_ITEM_SUCCESS', () => {
@ -258,17 +282,25 @@ describe( 'crud reducer', () => {
1: { id: 1, name: 'Donkey', status: 'draft' }, 1: { id: 1, name: 'Donkey', status: 'draft' },
2: { id: 2, name: 'Sauce', status: 'publish' }, 2: { id: 2, name: 'Sauce', status: 'publish' },
}, },
requesting: {},
}; };
const item: Item = { const item: Item = {
id: 2, id: 2,
name: 'Holy smokes!', name: 'Holy smokes!',
status: 'draft', status: 'draft',
}; };
const query = { property: 'value' };
const requestId = getRequestIdentifier(
CRUD_ACTIONS.UPDATE_ITEM,
item.id,
query
);
const state = reducer( initialState, { const state = reducer( initialState, {
type: TYPES.UPDATE_ITEM_SUCCESS, type: TYPES.UPDATE_ITEM_SUCCESS,
key: item.id, key: item.id,
item, item,
query,
} ); } );
expect( state.items ).toEqual( initialState.items ); expect( state.items ).toEqual( initialState.items );
@ -278,6 +310,7 @@ describe( 'crud reducer', () => {
expect( state.data[ 2 ].id ).toEqual( initialState.data[ 2 ].id ); expect( state.data[ 2 ].id ).toEqual( initialState.data[ 2 ].id );
expect( state.data[ 2 ].title ).toEqual( initialState.data[ 2 ].title ); expect( state.data[ 2 ].title ).toEqual( initialState.data[ 2 ].title );
expect( state.data[ 2 ].name ).toEqual( item.name ); expect( state.data[ 2 ].name ).toEqual( item.name );
expect( state.requesting[ requestId ] ).toEqual( false );
} ); } );
it( 'should handle CREATE_ITEM_SUCCESS', () => { it( 'should handle CREATE_ITEM_SUCCESS', () => {
@ -286,15 +319,26 @@ describe( 'crud reducer', () => {
name: 'Off the hook!', name: 'Off the hook!',
status: 'draft', status: 'draft',
}; };
const query = {
name: 'Off the hook!',
status: 'draft',
};
const resourceName = getRequestIdentifier(
CRUD_ACTIONS.CREATE_ITEM,
item.id,
query
);
const state = reducer( defaultState, { const state = reducer( defaultState, {
type: TYPES.CREATE_ITEM_SUCCESS, type: TYPES.CREATE_ITEM_SUCCESS,
key: item.id, key: item.id,
item, item,
query,
} ); } );
expect( state.data[ 2 ].name ).toEqual( item.name ); expect( state.data[ 2 ].name ).toEqual( item.name );
expect( state.data[ 2 ].status ).toEqual( item.status ); expect( state.data[ 2 ].status ).toEqual( item.status );
expect( state.requesting[ resourceName ] ).toEqual( false );
} ); } );
it( 'should handle DELETE_ITEM_SUCCESS', () => { it( 'should handle DELETE_ITEM_SUCCESS', () => {
@ -311,6 +355,7 @@ describe( 'crud reducer', () => {
1: { id: 1, name: 'Donkey', status: 'draft' }, 1: { id: 1, name: 'Donkey', status: 'draft' },
2: { id: 2, name: 'Sauce', status: 'publish' }, 2: { id: 2, name: 'Sauce', status: 'publish' },
}, },
requesting: {},
}; };
const item1Updated: Item = { const item1Updated: Item = {
id: 1, id: 1,
@ -333,25 +378,35 @@ describe( 'crud reducer', () => {
item: item2Updated, item: item2Updated,
force: false, force: false,
} ); } );
const resourceName = getRequestIdentifier(
CRUD_ACTIONS.DELETE_ITEM,
item1Updated.id,
true
);
expect( state.errors ).toEqual( initialState.errors ); expect( state.errors ).toEqual( initialState.errors );
expect( state.data[ 1 ] ).toEqual( undefined ); expect( state.data[ 1 ] ).toEqual( undefined );
expect( state.data[ 2 ].status ).toEqual( 'trash' ); expect( state.data[ 2 ].status ).toEqual( 'trash' );
expect( state.requesting[ resourceName ] ).toBe( false );
} ); } );
it( 'should handle DELETE_ITEM_ERROR', () => { it( 'should handle DELETE_ITEM_ERROR', () => {
const key = 2; const key = 2;
const resourceName = getResourceName( CRUD_ACTIONS.DELETE_ITEM, { const resourceName = getRequestIdentifier(
CRUD_ACTIONS.DELETE_ITEM,
key, key,
} ); false
);
const error = 'Baaam!'; const error = 'Baaam!';
const state = reducer( defaultState, { const state = reducer( defaultState, {
type: TYPES.DELETE_ITEM_ERROR, type: TYPES.DELETE_ITEM_ERROR,
key, key,
error, error,
errorType: CRUD_ACTIONS.DELETE_ITEM, errorType: CRUD_ACTIONS.DELETE_ITEM,
force: false,
} ); } );
expect( state.errors[ resourceName ] ).toBe( error ); expect( state.errors[ resourceName ] ).toBe( error );
expect( state.requesting[ resourceName ] ).toBe( false );
} ); } );
} ); } );

View File

@ -11,7 +11,7 @@ const selectors = createSelectors( {
describe( 'crud selectors', () => { describe( 'crud selectors', () => {
it( 'should return methods for the default selectors', () => { it( 'should return methods for the default selectors', () => {
expect( Object.keys( selectors ).length ).toEqual( 8 ); expect( Object.keys( selectors ).length ).toEqual( 10 );
expect( selectors ).toHaveProperty( 'getProduct' ); expect( selectors ).toHaveProperty( 'getProduct' );
expect( selectors ).toHaveProperty( 'getProducts' ); expect( selectors ).toHaveProperty( 'getProducts' );
expect( selectors ).toHaveProperty( 'getProductsTotalCount' ); expect( selectors ).toHaveProperty( 'getProductsTotalCount' );
@ -20,5 +20,7 @@ describe( 'crud selectors', () => {
expect( selectors ).toHaveProperty( 'getProductCreateError' ); expect( selectors ).toHaveProperty( 'getProductCreateError' );
expect( selectors ).toHaveProperty( 'getProductDeleteError' ); expect( selectors ).toHaveProperty( 'getProductDeleteError' );
expect( selectors ).toHaveProperty( 'getProductUpdateError' ); expect( selectors ).toHaveProperty( 'getProductUpdateError' );
expect( selectors ).toHaveProperty( 'hasFinishedRequest' );
expect( selectors ).toHaveProperty( 'isRequesting' );
} ); } );
} ); } );

View File

@ -1,13 +1,18 @@
/** /**
* Internal dependencies * Internal dependencies
*/ */
import CRUD_ACTIONS from '../crud-actions';
import { import {
applyNamespace, applyNamespace,
cleanQuery, cleanQuery,
getGenericActionName,
getKey, getKey,
getNamespaceKeys, getNamespaceKeys,
getRequestIdentifier,
getRestPath, getRestPath,
getUrlParameters, getUrlParameters,
maybeReplaceIdQuery,
isValidIdQuery,
parseId, parseId,
} from '../utils'; } from '../utils';
@ -113,4 +118,104 @@ describe( 'utils', () => {
expect( params.other_attribute ).toBe( 'a' ); expect( params.other_attribute ).toBe( 'a' );
expect( params.my_attribute ).toBeUndefined(); expect( params.my_attribute ).toBeUndefined();
} ); } );
it( 'should get the request identifier with no arguments', () => {
const key = getRequestIdentifier( 'CREATE_ITEM' );
expect( key ).toBe( 'CREATE_ITEM/[]' );
} );
it( 'should get the request identifier with a single argument', () => {
const key = getRequestIdentifier( 'CREATE_ITEM', 'string_arg' );
expect( key ).toBe( 'CREATE_ITEM/["string_arg"]' );
} );
it( 'should get the request identifier with multiple arguments', () => {
const key = getRequestIdentifier( 'CREATE_ITEM', 'string_arg', {
object_property: 'object_value',
} );
expect( key ).toBe(
'CREATE_ITEM/["string_arg","{"object_property":"object_value"}"]'
);
} );
it( 'should sort object properties in the request identifier', () => {
const key = getRequestIdentifier( 'CREATE_ITEM', 'string_arg', {
b: '2',
a: '1',
} );
expect( key ).toBe( 'CREATE_ITEM/["string_arg","{"a":"1","b":"2"}"]' );
} );
it( 'should directly return the action when the action does not match the resource name', () => {
const genercActionName = getGenericActionName(
'createNonThing',
'Thing'
);
expect( genercActionName ).toBe( 'createNonThing' );
} );
it( 'should get the generic create action name based on resource name', () => {
const genercActionName = getGenericActionName( 'createThing', 'Thing' );
expect( genercActionName ).toBe( CRUD_ACTIONS.CREATE_ITEM );
} );
it( 'should get the generic delete action name based on resource name', () => {
const genercActionName = getGenericActionName( 'deleteThing', 'Thing' );
expect( genercActionName ).toBe( CRUD_ACTIONS.DELETE_ITEM );
} );
it( 'should get the generic update action name based on resource name', () => {
const genercActionName = getGenericActionName( 'updateThing', 'Thing' );
expect( genercActionName ).toBe( CRUD_ACTIONS.UPDATE_ITEM );
} );
it( 'should return false when a valid ID query is not given', () => {
expect( isValidIdQuery( { some: 'data' }, '/my/namespace' ) ).toBe(
false
);
} );
it( 'should return true when a valid ID is passed in an object', () => {
expect( isValidIdQuery( { id: 22 }, '/my/namespace' ) ).toBe( true );
} );
it( 'should return true when a valid ID is passed directly', () => {
expect( isValidIdQuery( 22, '/my/namespace' ) ).toBe( true );
} );
it( 'should return false when additional non-ID properties are provided', () => {
expect( isValidIdQuery( { id: 22, other: 88 }, '/my/namespace' ) ).toBe(
false
);
} );
it( 'should return true when namespace ID properties are provided', () => {
expect(
isValidIdQuery(
{ id: 22, parent_id: 88 },
'/my/{parent_id}/namespace/'
)
).toBe( true );
} );
it( 'should replace the first argument when a valid ID query exists', () => {
const args = [ { id: 22, parent_id: 88 }, 'second' ];
const sanitizedArgs = maybeReplaceIdQuery(
args,
'/my/{parent_id}/namespace/'
);
expect( sanitizedArgs ).toEqual( [ '88/22', 'second' ] );
} );
it( 'should remain unchanged when the first argument is a string or number', () => {
const args = [ 'first', 'second' ];
const sanitizedArgs = maybeReplaceIdQuery( args, '/my/namespace/' );
expect( sanitizedArgs ).toEqual( args );
} );
it( 'should remain unchanged when the first argument is not a valid ID query', () => {
const args = [ { id: 22, parent_id: 88 }, 'second' ];
const sanitizedArgs = maybeReplaceIdQuery( args, '/my/namespace/' );
expect( sanitizedArgs ).toEqual( args );
} );
} ); } );

View File

@ -6,6 +6,7 @@ import { addQueryArgs } from '@wordpress/url';
/** /**
* Internal dependencies * Internal dependencies
*/ */
import CRUD_ACTIONS from './crud-actions';
import { IdQuery, IdType, ItemQuery } from './types'; import { IdQuery, IdType, ItemQuery } from './types';
/** /**
@ -148,6 +149,52 @@ export const getUrlParameters = (
return params; return params;
}; };
/**
* Check to see if an argument is a valid type of ID query.
*
* @param arg Unknow argument to check.
* @param namespace The namespace string
* @return boolean
*/
export const isValidIdQuery = ( arg: unknown, namespace: string ) => {
if ( typeof arg === 'string' || typeof arg === 'number' ) {
return true;
}
const validKeys = [ 'id', ...getNamespaceKeys( namespace ) ];
if (
arg &&
typeof arg === 'object' &&
arg.hasOwnProperty( 'id' ) &&
JSON.stringify( validKeys.sort() ) ===
JSON.stringify( Object.keys( arg ).sort() )
) {
return true;
}
return false;
};
/**
* Replace the initial argument with a key if it's a valid ID query.
*
* @param args Args to check.
* @param namespace Namespace.
* @return Sanitized arguments.
*/
export const maybeReplaceIdQuery = ( args: unknown[], namespace: string ) => {
const [ firstArgument, ...rest ] = args;
if ( ! firstArgument || ! isValidIdQuery( firstArgument, namespace ) ) {
return args;
}
const urlParameters = getUrlParameters( namespace, firstArgument );
const { key } = parseId( firstArgument as IdQuery, urlParameters );
return [ key, ...rest ];
};
/** /**
* Clean a query of all namespaced params. * Clean a query of all namespaced params.
* *
@ -168,3 +215,46 @@ export const cleanQuery = (
return cleaned; return cleaned;
}; };
/**
* Get the identifier for a request provided its arguments.
*
* @param name Name of action or selector.
* @param args Arguments for the request.
* @return Key to identify the request.
*/
export const getRequestIdentifier = ( name: string, ...args: unknown[] ) => {
const suffix = JSON.stringify(
args.map( ( arg ) => {
if ( typeof arg === 'object' && arg !== null ) {
return JSON.stringify( arg, Object.keys( arg ).sort() );
}
return arg;
} )
).replace( /\\"/g, '"' );
return name + '/' + suffix;
};
/**
* Get a generic action name from a resource action name if one exists.
*
* @param action Action name to check.
* @param resourceName Resurce name.
* @return Generic action name if one exists, otherwise the passed action name.
*/
export const getGenericActionName = (
action: string,
resourceName: string
) => {
switch ( action ) {
case `create${ resourceName }`:
return CRUD_ACTIONS.CREATE_ITEM;
case `delete${ resourceName }`:
return CRUD_ACTIONS.DELETE_ITEM;
case `update${ resourceName }`:
return CRUD_ACTIONS.UPDATE_ITEM;
}
return action;
};

View File

@ -25,6 +25,7 @@ export { EXPERIMENTAL_PRODUCT_TAGS_STORE_NAME } from './product-tags';
export { EXPERIMENTAL_PRODUCT_CATEGORIES_STORE_NAME } from './product-categories'; export { EXPERIMENTAL_PRODUCT_CATEGORIES_STORE_NAME } from './product-categories';
export { EXPERIMENTAL_PRODUCT_ATTRIBUTE_TERMS_STORE_NAME } from './product-attribute-terms'; export { EXPERIMENTAL_PRODUCT_ATTRIBUTE_TERMS_STORE_NAME } from './product-attribute-terms';
export { EXPERIMENTAL_PRODUCT_VARIATIONS_STORE_NAME } from './product-variations'; export { EXPERIMENTAL_PRODUCT_VARIATIONS_STORE_NAME } from './product-variations';
export { EXPERIMENTAL_PRODUCT_FORM_STORE_NAME } from './product-form';
export { EXPERIMENTAL_TAX_CLASSES_STORE_NAME } from './tax-classes'; export { EXPERIMENTAL_TAX_CLASSES_STORE_NAME } from './tax-classes';
export { PaymentGateway } from './payment-gateways/types'; export { PaymentGateway } from './payment-gateways/types';
@ -75,6 +76,11 @@ export {
// Export types // Export types
export * from './types'; export * from './types';
export * from './countries/types'; export * from './countries/types';
export {
ProductForm,
ProductFormField,
ProductFormSection,
} from './product-form/types';
export * from './onboarding/types'; export * from './onboarding/types';
export * from './plugins/types'; export * from './plugins/types';
export * from './products/types'; export * from './products/types';
@ -122,6 +128,7 @@ import type { EXPERIMENTAL_PRODUCT_SHIPPING_CLASSES_STORE_NAME } from './product
import type { EXPERIMENTAL_SHIPPING_ZONES_STORE_NAME } from './shipping-zones'; import type { EXPERIMENTAL_SHIPPING_ZONES_STORE_NAME } from './shipping-zones';
import type { EXPERIMENTAL_PRODUCT_TAGS_STORE_NAME } from './product-tags'; import type { EXPERIMENTAL_PRODUCT_TAGS_STORE_NAME } from './product-tags';
import type { EXPERIMENTAL_PRODUCT_CATEGORIES_STORE_NAME } from './product-categories'; import type { EXPERIMENTAL_PRODUCT_CATEGORIES_STORE_NAME } from './product-categories';
import type { EXPERIMENTAL_PRODUCT_FORM_STORE_NAME } from './product-form';
import type { EXPERIMENTAL_PRODUCT_ATTRIBUTE_TERMS_STORE_NAME } from './product-attribute-terms'; import type { EXPERIMENTAL_PRODUCT_ATTRIBUTE_TERMS_STORE_NAME } from './product-attribute-terms';
import type { EXPERIMENTAL_PRODUCT_VARIATIONS_STORE_NAME } from './product-variations'; import type { EXPERIMENTAL_PRODUCT_VARIATIONS_STORE_NAME } from './product-variations';
import type { EXPERIMENTAL_TAX_CLASSES_STORE_NAME } from './tax-classes'; import type { EXPERIMENTAL_TAX_CLASSES_STORE_NAME } from './tax-classes';
@ -148,7 +155,8 @@ export type WCDataStoreName =
| typeof EXPERIMENTAL_PRODUCT_TAGS_STORE_NAME | typeof EXPERIMENTAL_PRODUCT_TAGS_STORE_NAME
| typeof EXPERIMENTAL_PRODUCT_CATEGORIES_STORE_NAME | typeof EXPERIMENTAL_PRODUCT_CATEGORIES_STORE_NAME
| typeof EXPERIMENTAL_PRODUCT_VARIATIONS_STORE_NAME | typeof EXPERIMENTAL_PRODUCT_VARIATIONS_STORE_NAME
| typeof EXPERIMENTAL_TAX_CLASSES_STORE_NAME; | typeof EXPERIMENTAL_TAX_CLASSES_STORE_NAME
| typeof EXPERIMENTAL_PRODUCT_FORM_STORE_NAME;
/** /**
* Internal dependencies * Internal dependencies
@ -168,6 +176,7 @@ import { ProductCategorySelectors } from './product-categories/types';
import { ProductAttributeTermsSelectors } from './product-attribute-terms/types'; import { ProductAttributeTermsSelectors } from './product-attribute-terms/types';
import { ProductVariationSelectors } from './product-variations/types'; import { ProductVariationSelectors } from './product-variations/types';
import { TaxClassSelectors } from './tax-classes/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 // 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. // of the already typed selectors for an example of how you can do this.
@ -215,6 +224,8 @@ export type WCSelectorType< T > = T extends typeof REVIEWS_STORE_NAME
? ShippingZonesSelectors ? ShippingZonesSelectors
: T extends typeof EXPERIMENTAL_TAX_CLASSES_STORE_NAME : T extends typeof EXPERIMENTAL_TAX_CLASSES_STORE_NAME
? TaxClassSelectors ? TaxClassSelectors
: T extends typeof EXPERIMENTAL_PRODUCT_FORM_STORE_NAME
? ProductFormSelectors
: never; : never;
export interface WCDataSelector { export interface WCDataSelector {

View File

@ -0,0 +1,8 @@
export enum TYPES {
GET_FIELDS_ERROR = 'GET_FIELDS_ERROR',
GET_FIELDS_SUCCESS = 'GET_FIELDS_SUCCESS',
GET_PRODUCT_FORM_ERROR = 'GET_PRODUCT_FORM_ERROR',
GET_PRODUCT_FORM_SUCCESS = 'GET_PRODUCT_FORM_SUCCESS',
}
export default TYPES;

View File

@ -0,0 +1,42 @@
/**
* Internal dependencies
*/
import TYPES from './action-types';
import { ProductFormField, ProductForm } from './types';
export function getFieldsSuccess( fields: ProductFormField[] ) {
return {
type: TYPES.GET_FIELDS_SUCCESS as const,
fields,
};
}
export function getFieldsError( error: unknown ) {
return {
type: TYPES.GET_FIELDS_ERROR as const,
error,
};
}
export function getProductFormSuccess( productForm: ProductForm ) {
return {
type: TYPES.GET_PRODUCT_FORM_SUCCESS as const,
fields: productForm.fields,
sections: productForm.sections,
subsections: productForm.subsections,
};
}
export function getProductFormError( error: unknown ) {
return {
type: TYPES.GET_PRODUCT_FORM_ERROR as const,
error,
};
}
export type Action = ReturnType<
| typeof getFieldsSuccess
| typeof getFieldsError
| typeof getProductFormSuccess
| typeof getProductFormError
>;

View File

@ -0,0 +1 @@
export const STORE_NAME = 'experimental/wc/admin/product-form';

View File

@ -0,0 +1,37 @@
/**
* External dependencies
*/
import { registerStore } from '@wordpress/data';
import { controls } from '@wordpress/data-controls';
import { SelectFromMap, DispatchFromMap } from '@automattic/data-stores';
import { Reducer, AnyAction } from 'redux';
/**
* Internal dependencies
*/
import { STORE_NAME } from './constants';
import * as selectors from './selectors';
import * as actions from './actions';
import * as resolvers from './resolvers';
import reducer, { State } from './reducer';
import { WPDataSelectors } from '../types';
export * from './types';
export type { State };
registerStore< State >( STORE_NAME, {
reducer: reducer as Reducer< State, AnyAction >,
actions,
controls,
selectors,
resolvers,
} );
export const EXPERIMENTAL_PRODUCT_FORM_STORE_NAME = STORE_NAME;
declare module '@wordpress/data' {
function dispatch(
key: typeof STORE_NAME
): DispatchFromMap< typeof actions >;
function select(
key: typeof STORE_NAME
): SelectFromMap< typeof selectors > & WPDataSelectors;
}

View File

@ -0,0 +1,63 @@
/**
* External dependencies
*/
import type { Reducer } from 'redux';
/**
* Internal dependencies
*/
import TYPES from './action-types';
import { Action } from './actions';
import { ProductFormState } from './types';
const reducer: Reducer< ProductFormState, Action > = (
state = {
errors: {},
fields: [],
sections: [],
subsections: [],
},
action
) => {
switch ( action.type ) {
case TYPES.GET_FIELDS_SUCCESS:
state = {
...state,
fields: action.fields,
};
break;
case TYPES.GET_FIELDS_ERROR:
state = {
...state,
errors: {
...state.errors,
fields: action.error,
},
};
break;
case TYPES.GET_PRODUCT_FORM_SUCCESS:
state = {
...state,
fields: action.fields,
sections: action.sections,
subsections: action.subsections,
};
break;
case TYPES.GET_PRODUCT_FORM_ERROR:
state = {
...state,
errors: {
...state.errors,
fields: action.error,
sections: action.error,
subsections: action.error,
},
};
break;
}
return state;
};
export type State = ReturnType< typeof reducer >;
export default reducer;

View File

@ -0,0 +1,53 @@
/**
* External dependencies
*/
import { apiFetch, select } from '@wordpress/data-controls';
import { controls } from '@wordpress/data';
/**
* Internal dependencies
*/
import {
getFieldsSuccess,
getFieldsError,
getProductFormSuccess,
getProductFormError,
} from './actions';
import { WC_ADMIN_NAMESPACE } from '../constants';
import { ProductFormField, ProductForm } from './types';
import { STORE_NAME } from './constants';
const resolveSelect =
controls && controls.resolveSelect ? controls.resolveSelect : select;
export function* getFields() {
try {
const url = WC_ADMIN_NAMESPACE + '/product-form/fields';
const results: ProductFormField[] = yield apiFetch( {
path: url,
method: 'GET',
} );
return getFieldsSuccess( results );
} catch ( error ) {
return getFieldsError( error );
}
}
export function* getCountry() {
yield resolveSelect( STORE_NAME, 'getProductForm' );
}
export function* getProductForm() {
try {
const url = WC_ADMIN_NAMESPACE + '/product-form';
const results: ProductForm = yield apiFetch( {
path: url,
method: 'GET',
} );
return getProductFormSuccess( results );
} catch ( error ) {
return getProductFormError( error );
}
}

View File

@ -0,0 +1,24 @@
/**
* Internal dependencies
*/
import { WPDataSelector, WPDataSelectors } from '../types';
import { ProductFormState } from './types';
export const getFields = ( state: ProductFormState ) => {
return state.fields;
};
export const getField = ( state: ProductFormState, id: string ) => {
return state.fields.find( ( field ) => field.id === id );
};
export const getProductForm = ( state: ProductFormState ) => {
const { errors, ...form } = state;
return form;
};
export type ProductFormSelectors = {
getFields: WPDataSelector< typeof getFields >;
getField: WPDataSelector< typeof getField >;
getProductForm: WPDataSelector< typeof getProductForm >;
} & WPDataSelectors;

View File

@ -0,0 +1,36 @@
type BaseComponent = {
id: string;
plugin_id: string;
order: number;
};
type FieldProperties = {
name: string;
label: string;
};
export type ProductFormField = BaseComponent & {
type: string;
section: string;
properties: FieldProperties;
};
export type ProductFormSection = BaseComponent & {
title: string;
description: string;
location: string;
};
export type Subsection = BaseComponent;
export type ProductForm = {
fields: ProductFormField[];
sections: ProductFormSection[];
subsections: Subsection[];
};
export type ProductFormState = ProductForm & {
errors: {
[ key: string ]: unknown;
};
};

View File

@ -45,6 +45,7 @@ $alert-green: $valid-green;
$adminbar-height: 32px; $adminbar-height: 32px;
$adminbar-height-mobile: 46px; $adminbar-height-mobile: 46px;
$admin-menu-width: 160px; $admin-menu-width: 160px;
$admin-menu-width-collapsed: 36px;
// wp-admin colors // wp-admin colors
$wp-admin-background: #f1f1f1; $wp-admin-background: #f1f1f1;

View File

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

View File

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

View File

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

View File

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

View File

@ -2,22 +2,46 @@
* External dependencies * External dependencies
*/ */
import { recordEvent } from '@woocommerce/tracks'; import { recordEvent } from '@woocommerce/tracks';
import { useSelect } from '@wordpress/data';
import { useEffect } from '@wordpress/element'; import { useEffect } from '@wordpress/element';
import { Spinner } from '@wordpress/components';
import {
EXPERIMENTAL_PRODUCT_FORM_STORE_NAME,
WCDataSelector,
} from '@woocommerce/data';
/** /**
* Internal dependencies * Internal dependencies
*/ */
import { ProductForm } from './product-form'; import { ProductForm } from './product-form';
import { ProductTourContainer } from './tour';
import './product-page.scss'; import './product-page.scss';
import './fills';
const AddProductPage: React.FC = () => { const AddProductPage: React.FC = () => {
const { isLoading } = useSelect( ( select: WCDataSelector ) => {
const { hasFinishedResolution: hasProductFormFinishedResolution } =
select( EXPERIMENTAL_PRODUCT_FORM_STORE_NAME );
return {
isLoading: ! hasProductFormFinishedResolution( 'getProductForm' ),
};
} );
useEffect( () => { useEffect( () => {
recordEvent( 'view_new_product_management_experience' ); recordEvent( 'view_new_product_management_experience' );
}, [] ); }, [] );
return ( return (
<div className="woocommerce-add-product"> <div className="woocommerce-add-product">
{ isLoading ? (
<div className="woocommerce-edit-product__spinner">
<Spinner />
</div>
) : (
<>
<ProductForm /> <ProductForm />
<ProductTourContainer />
</>
) }
</div> </div>
); );
}; };

View File

@ -3,6 +3,7 @@
*/ */
import { __ } from '@wordpress/i18n'; import { __ } from '@wordpress/i18n';
import { import {
EXPERIMENTAL_PRODUCT_FORM_STORE_NAME,
EXPERIMENTAL_PRODUCT_VARIATIONS_STORE_NAME, EXPERIMENTAL_PRODUCT_VARIATIONS_STORE_NAME,
PartialProduct, PartialProduct,
Product, Product,
@ -21,6 +22,7 @@ import { ProductForm } from './product-form';
import { ProductFormLayout } from './layout/product-form-layout'; import { ProductFormLayout } from './layout/product-form-layout';
import { ProductVariationForm } from './product-variation-form'; import { ProductVariationForm } from './product-variation-form';
import './product-page.scss'; import './product-page.scss';
import './fills';
const EditProductPage: React.FC = () => { const EditProductPage: React.FC = () => {
const { productId, variationId } = useParams(); const { productId, variationId } = useParams();
@ -35,6 +37,8 @@ const EditProductPage: React.FC = () => {
isPending, isPending,
getPermalinkParts, getPermalinkParts,
} = select( PRODUCTS_STORE_NAME ); } = select( PRODUCTS_STORE_NAME );
const { hasFinishedResolution: hasProductFormFinishedResolution } =
select( EXPERIMENTAL_PRODUCT_FORM_STORE_NAME );
const { const {
getProductVariation, getProductVariation,
hasFinishedResolution: hasProductVariationFinishedResolution, hasFinishedResolution: hasProductVariationFinishedResolution,
@ -71,7 +75,8 @@ const EditProductPage: React.FC = () => {
'getProductVariation', 'getProductVariation',
[ parseInt( variationId, 10 ) ] [ parseInt( variationId, 10 ) ]
) )
), ) ||
! hasProductFormFinishedResolution( 'getProductForm' ),
isPendingAction: isPendingAction:
isPending( 'createProduct' ) || isPending( 'createProduct' ) ||
isPending( isPending(

View File

@ -26,7 +26,7 @@ import {
AttributeTermInputField, AttributeTermInputField,
CustomAttributeTermInputField, CustomAttributeTermInputField,
} from '../attribute-term-input-field'; } from '../attribute-term-input-field';
import { HydratedAttributeType } from '../attribute-field'; import { EnhancedProductAttribute } from '~/products/hooks/use-product-attributes';
import { getProductAttributeObject } from './utils'; import { getProductAttributeObject } from './utils';
type AddAttributeModalProps = { type AddAttributeModalProps = {
@ -46,12 +46,12 @@ type AddAttributeModalProps = {
confirmCancelLabel?: string; confirmCancelLabel?: string;
confirmConfirmLabel?: string; confirmConfirmLabel?: string;
onCancel: () => void; onCancel: () => void;
onAdd: ( newCategories: HydratedAttributeType[] ) => void; onAdd: ( newCategories: EnhancedProductAttribute[] ) => void;
selectedAttributeIds?: number[]; selectedAttributeIds?: number[];
}; };
type AttributeForm = { type AttributeForm = {
attributes: Array< HydratedAttributeType | null >; attributes: Array< EnhancedProductAttribute | null >;
}; };
export const AddAttributeModal: React.FC< AddAttributeModalProps > = ( { export const AddAttributeModal: React.FC< AddAttributeModalProps > = ( {
@ -80,6 +80,15 @@ export const AddAttributeModal: React.FC< AddAttributeModalProps > = ( {
onAdd, onAdd,
selectedAttributeIds = [], selectedAttributeIds = [],
} ) => { } ) => {
const scrollAttributeIntoView = ( index: number ) => {
setTimeout( () => {
const attributeRow = document.querySelector(
`.woocommerce-add-attribute-modal__table-row-${ index }`
);
attributeRow?.scrollIntoView( { behavior: 'smooth' } );
}, 0 );
};
const [ showConfirmClose, setShowConfirmClose ] = useState( false ); const [ showConfirmClose, setShowConfirmClose ] = useState( false );
const addAnother = ( const addAnother = (
values: AttributeForm, values: AttributeForm,
@ -89,10 +98,11 @@ export const AddAttributeModal: React.FC< AddAttributeModalProps > = ( {
) => void ) => void
) => { ) => {
setValue( 'attributes', [ ...values.attributes, null ] ); setValue( 'attributes', [ ...values.attributes, null ] );
scrollAttributeIntoView( values.attributes.length );
}; };
const onAddingAttributes = ( values: AttributeForm ) => { const onAddingAttributes = ( values: AttributeForm ) => {
const newAttributesToAdd: HydratedAttributeType[] = []; const newAttributesToAdd: EnhancedProductAttribute[] = [];
values.attributes.forEach( ( attr ) => { values.attributes.forEach( ( attr ) => {
if ( if (
attr !== null && attr !== null &&
@ -105,7 +115,7 @@ export const AddAttributeModal: React.FC< AddAttributeModalProps > = ( {
? ( attr.terms || [] ).map( ( term ) => term.name ) ? ( attr.terms || [] ).map( ( term ) => term.name )
: attr.options; : attr.options;
newAttributesToAdd.push( { newAttributesToAdd.push( {
...( attr as HydratedAttributeType ), ...( attr as EnhancedProductAttribute ),
options, options,
} ); } );
} }

View File

@ -2,13 +2,8 @@
* External dependencies * External dependencies
*/ */
import { __, sprintf } from '@wordpress/i18n'; import { __, sprintf } from '@wordpress/i18n';
import { useState, useCallback, useEffect } from '@wordpress/element'; import { useState } from '@wordpress/element';
import { import { ProductAttribute } from '@woocommerce/data';
ProductAttribute,
EXPERIMENTAL_PRODUCT_ATTRIBUTE_TERMS_STORE_NAME,
ProductAttributeTerm,
} from '@woocommerce/data';
import { resolveSelect } from '@wordpress/data';
import { import {
Sortable, Sortable,
__experimentalSelectControlMenuSlot as SelectControlMenuSlot, __experimentalSelectControlMenuSlot as SelectControlMenuSlot,
@ -24,42 +19,31 @@ import { getAdminLink } from '@woocommerce/settings';
import './attribute-field.scss'; import './attribute-field.scss';
import { AddAttributeModal } from './add-attribute-modal'; import { AddAttributeModal } from './add-attribute-modal';
import { EditAttributeModal } from './edit-attribute-modal'; import { EditAttributeModal } from './edit-attribute-modal';
import { EnhancedProductAttribute } from '~/products/hooks/use-product-attributes';
import { import {
getAttributeKey, getAttributeKey,
reorderSortableProductAttributePositions, reorderSortableProductAttributePositions,
} from './utils'; } from './utils';
import { sift } from '../../../utils';
import { AttributeEmptyState } from '../attribute-empty-state'; import { AttributeEmptyState } from '../attribute-empty-state';
import { import {
AddAttributeListItem, AddAttributeListItem,
AttributeListItem, AttributeListItem,
} from '../attribute-list-item'; } from '../attribute-list-item';
type AttributeFieldProps = { type AttributeControlProps = {
value: ProductAttribute[]; value: ProductAttribute[];
onChange: ( value: ProductAttribute[] ) => void; onChange: ( value: ProductAttribute[] ) => void;
productId?: number;
// TODO: should we support an 'any' option to show all attributes? // TODO: should we support an 'any' option to show all attributes?
attributeType?: 'regular' | 'for-variations'; attributeType?: 'regular' | 'for-variations';
}; };
export type HydratedAttributeType = Omit< ProductAttribute, 'options' > & { export const AttributeControl: React.FC< AttributeControlProps > = ( {
options?: string[];
terms?: ProductAttributeTerm[];
visible?: boolean;
};
export const AttributeField: React.FC< AttributeFieldProps > = ( {
value, value,
onChange,
productId,
attributeType = 'regular', attributeType = 'regular',
onChange,
} ) => { } ) => {
const [ showAddAttributeModal, setShowAddAttributeModal ] = const [ showAddAttributeModal, setShowAddAttributeModal ] =
useState( false ); useState( false );
const [ hydratedAttributes, setHydratedAttributes ] = useState<
HydratedAttributeType[]
>( [] );
const [ editingAttributeId, setEditingAttributeId ] = useState< const [ editingAttributeId, setEditingAttributeId ] = useState<
null | string null | string
>( null ); >( null );
@ -72,73 +56,12 @@ export const AttributeField: React.FC< AttributeFieldProps > = ( {
? 'product_add_options_modal_cancel_button_click' ? 'product_add_options_modal_cancel_button_click'
: 'product_add_attributes_modal_cancel_button_click'; : 'product_add_attributes_modal_cancel_button_click';
const fetchTerms = useCallback(
( attributeId: number ) => {
return resolveSelect(
EXPERIMENTAL_PRODUCT_ATTRIBUTE_TERMS_STORE_NAME
)
.getProductAttributeTerms< ProductAttributeTerm[] >( {
attribute_id: attributeId,
product: productId,
} )
.then(
( attributeTerms ) => {
return attributeTerms;
},
( error ) => {
return error;
}
);
},
[ productId ]
);
useEffect( () => {
// I think we'll need to move the hydration out of the individual component
// instance. To where, I do not yet know... maybe in the form context
// somewhere so that a single hydration source can be shared between multiple
// instances? Something like a simple key-value store in the form context
// would be handy.
if ( ! value || hydratedAttributes.length !== 0 ) {
return;
}
const [ customAttributes, globalAttributes ]: ProductAttribute[][] =
sift( value, ( attr: ProductAttribute ) => attr.id === 0 );
Promise.all(
globalAttributes.map( ( attr ) => fetchTerms( attr.id ) )
).then( ( allResults ) => {
setHydratedAttributes( [
...globalAttributes.map( ( attr, index ) => {
const fetchedTerms = allResults[ index ];
const newAttr = {
...attr,
// I'm not sure this is quite right for handling unpersisted terms,
// but this gets things kinda working for now
terms:
fetchedTerms.length > 0 ? fetchedTerms : undefined,
options:
fetchedTerms.length === 0
? attr.options
: undefined,
};
return newAttr;
} ),
...customAttributes,
] );
} );
}, [ fetchTerms, hydratedAttributes, value ] );
const fetchAttributeId = ( attribute: { id: number; name: string } ) => const fetchAttributeId = ( attribute: { id: number; name: string } ) =>
`${ attribute.id }-${ attribute.name }`; `${ attribute.id }-${ attribute.name }`;
const updateAttributes = ( attributes: HydratedAttributeType[] ) => { const handleChange = ( newAttributes: EnhancedProductAttribute[] ) => {
setHydratedAttributes( attributes );
onChange( onChange(
attributes.map( ( attr ) => { newAttributes.map( ( attr ) => {
return { return {
...attr, ...attr,
options: attr.terms options: attr.terms
@ -157,8 +80,8 @@ export const AttributeField: React.FC< AttributeFieldProps > = ( {
recordEvent( recordEvent(
'product_remove_attribute_confirmation_confirm_click' 'product_remove_attribute_confirmation_confirm_click'
); );
updateAttributes( handleChange(
hydratedAttributes.filter( value.filter(
( attr ) => ( attr ) =>
fetchAttributeId( attr ) !== fetchAttributeId( attr ) !==
fetchAttributeId( attribute ) fetchAttributeId( attribute )
@ -169,9 +92,11 @@ export const AttributeField: React.FC< AttributeFieldProps > = ( {
} }
}; };
const onAddNewAttributes = ( newAttributes: HydratedAttributeType[] ) => { const onAddNewAttributes = (
updateAttributes( [ newAttributes: EnhancedProductAttribute[]
...( hydratedAttributes || [] ), ) => {
handleChange( [
...( value || [] ),
...newAttributes ...newAttributes
.filter( .filter(
( newAttr ) => ( newAttr ) =>
@ -193,18 +118,7 @@ export const AttributeField: React.FC< AttributeFieldProps > = ( {
setShowAddAttributeModal( false ); setShowAddAttributeModal( false );
}; };
const filteredAttributes = value if ( ! value.length ) {
? value.filter(
( attribute: ProductAttribute ) =>
attribute.variation === isOnlyForVariations
)
: false;
if (
! filteredAttributes ||
filteredAttributes.length === 0 ||
hydratedAttributes.length === 0
) {
return ( return (
<> <>
<AttributeEmptyState <AttributeEmptyState
@ -232,9 +146,7 @@ export const AttributeField: React.FC< AttributeFieldProps > = ( {
setShowAddAttributeModal( false ); setShowAddAttributeModal( false );
} } } }
onAdd={ onAddNewAttributes } onAdd={ onAddNewAttributes }
selectedAttributeIds={ ( filteredAttributes || [] ).map( selectedAttributeIds={ [] }
( attr ) => attr.id
) }
/> />
) } ) }
<SelectControlMenuSlot /> <SelectControlMenuSlot />
@ -242,9 +154,8 @@ export const AttributeField: React.FC< AttributeFieldProps > = ( {
); );
} }
const sortedAttributes = filteredAttributes.sort( const sortedAttributes = value.sort( ( a, b ) => a.position - b.position );
( a, b ) => a.position - b.position
);
const attributeKeyValues = value.reduce( const attributeKeyValues = value.reduce(
( (
keyValue: Record< number | string, ProductAttribute >, keyValue: Record< number | string, ProductAttribute >,
@ -256,9 +167,9 @@ export const AttributeField: React.FC< AttributeFieldProps > = ( {
{} as Record< number | string, ProductAttribute > {} as Record< number | string, ProductAttribute >
); );
const attribute = hydratedAttributes.find( const editingAttribute = value.find(
( attr ) => fetchAttributeId( attr ) === editingAttributeId ( attr ) => fetchAttributeId( attr ) === editingAttributeId
) as HydratedAttributeType; ) as EnhancedProductAttribute;
const editAttributeCopy = isOnlyForVariations const editAttributeCopy = isOnlyForVariations
? __( ? __(
@ -332,15 +243,13 @@ export const AttributeField: React.FC< AttributeFieldProps > = ( {
/> />
) } ) }
<SelectControlMenuSlot /> <SelectControlMenuSlot />
{ editingAttributeId && ( { editingAttribute && (
<EditAttributeModal <EditAttributeModal
title={ title={ sprintf(
/* translators: %s is the attribute name */ /* translators: %s is the attribute name */
sprintf(
__( 'Edit %s', 'woocommerce' ), __( 'Edit %s', 'woocommerce' ),
attribute.name editingAttribute.name
) ) }
}
globalAttributeHelperMessage={ interpolateComponents( { globalAttributeHelperMessage={ interpolateComponents( {
mixedString: editAttributeCopy, mixedString: editAttributeCopy,
components: { components: {
@ -359,7 +268,7 @@ export const AttributeField: React.FC< AttributeFieldProps > = ( {
} ) } } ) }
onCancel={ () => setEditingAttributeId( null ) } onCancel={ () => setEditingAttributeId( null ) }
onEdit={ ( changedAttribute ) => { onEdit={ ( changedAttribute ) => {
const newAttributesSet = [ ...hydratedAttributes ]; const newAttributesSet = [ ...value ];
const changedAttributeIndex: number = const changedAttributeIndex: number =
newAttributesSet.findIndex( ( attr ) => newAttributesSet.findIndex( ( attr ) =>
attr.id !== 0 attr.id !== 0
@ -373,10 +282,10 @@ export const AttributeField: React.FC< AttributeFieldProps > = ( {
changedAttribute changedAttribute
); );
updateAttributes( newAttributesSet ); handleChange( newAttributesSet );
setEditingAttributeId( null ); setEditingAttributeId( null );
} } } }
attribute={ attribute } attribute={ editingAttribute }
/> />
) } ) }
</div> </div>

View File

@ -18,7 +18,7 @@ import {
AttributeTermInputField, AttributeTermInputField,
CustomAttributeTermInputField, CustomAttributeTermInputField,
} from '../attribute-term-input-field'; } from '../attribute-term-input-field';
import { HydratedAttributeType } from './attribute-field'; import { EnhancedProductAttribute } from '../../hooks/use-product-attributes';
import './edit-attribute-modal.scss'; import './edit-attribute-modal.scss';
@ -36,8 +36,8 @@ type EditAttributeModalProps = {
updateAccessibleLabel?: string; updateAccessibleLabel?: string;
updateLabel?: string; updateLabel?: string;
onCancel: () => void; onCancel: () => void;
onEdit: ( alteredAttribute: HydratedAttributeType ) => void; onEdit: ( alteredAttribute: EnhancedProductAttribute ) => void;
attribute: HydratedAttributeType; attribute: EnhancedProductAttribute;
}; };
export const EditAttributeModal: React.FC< EditAttributeModalProps > = ( { export const EditAttributeModal: React.FC< EditAttributeModalProps > = ( {
@ -64,7 +64,7 @@ export const EditAttributeModal: React.FC< EditAttributeModalProps > = ( {
attribute, attribute,
} ) => { } ) => {
const [ editableAttribute, setEditableAttribute ] = useState< const [ editableAttribute, setEditableAttribute ] = useState<
HydratedAttributeType | undefined EnhancedProductAttribute | undefined
>( { ...attribute } ); >( { ...attribute } );
const isCustomAttribute = editableAttribute?.id === 0; const isCustomAttribute = editableAttribute?.id === 0;
@ -84,7 +84,7 @@ export const EditAttributeModal: React.FC< EditAttributeModalProps > = ( {
} }
onChange={ ( val ) => onChange={ ( val ) =>
setEditableAttribute( { setEditableAttribute( {
...( editableAttribute as HydratedAttributeType ), ...( editableAttribute as EnhancedProductAttribute ),
name: val, name: val,
} ) } )
} }
@ -102,7 +102,7 @@ export const EditAttributeModal: React.FC< EditAttributeModalProps > = ( {
attributeId={ editableAttribute?.id } attributeId={ editableAttribute?.id }
onChange={ ( val ) => { onChange={ ( val ) => {
setEditableAttribute( { setEditableAttribute( {
...( editableAttribute as HydratedAttributeType ), ...( editableAttribute as EnhancedProductAttribute ),
terms: val, terms: val,
} ); } );
} } } }
@ -115,7 +115,7 @@ export const EditAttributeModal: React.FC< EditAttributeModalProps > = ( {
value={ editableAttribute?.options } value={ editableAttribute?.options }
onChange={ ( val ) => { onChange={ ( val ) => {
setEditableAttribute( { setEditableAttribute( {
...( editableAttribute as HydratedAttributeType ), ...( editableAttribute as EnhancedProductAttribute ),
options: val, options: val,
} ); } );
} } } }
@ -126,7 +126,7 @@ export const EditAttributeModal: React.FC< EditAttributeModalProps > = ( {
<CheckboxControl <CheckboxControl
onChange={ ( val ) => onChange={ ( val ) =>
setEditableAttribute( { setEditableAttribute( {
...( editableAttribute as HydratedAttributeType ), ...( editableAttribute as EnhancedProductAttribute ),
visible: val, visible: val,
} ) } )
} }
@ -148,7 +148,7 @@ export const EditAttributeModal: React.FC< EditAttributeModalProps > = ( {
isPrimary isPrimary
label={ updateAccessibleLabel } label={ updateAccessibleLabel }
onClick={ () => { onClick={ () => {
onEdit( editableAttribute as HydratedAttributeType ); onEdit( editableAttribute as EnhancedProductAttribute );
} } } }
> >
{ updateLabel } { updateLabel }

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