Merge branch 'trunk' into feature/34903-multichannel-marketing-frontend/main
Conflicts: plugins/woocommerce/src/Admin/Marketing/MarketingChannelInterface.php
This commit is contained in:
commit
4e42823b48
|
@ -118,102 +118,3 @@ jobs:
|
|||
${{ env.ALLURE_REPORT_DIR }}
|
||||
if-no-files-found: ignore
|
||||
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
|
||||
|
|
|
@ -23,7 +23,7 @@ jobs:
|
|||
uses: ./.github/actions/setup-woocommerce-monorepo
|
||||
|
||||
- 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
|
||||
run: pnpm run test --filter='woocommerce/client/admin...' --filter='!@woocommerce/e2e*' --filter='!@woocommerce/api' --color
|
||||
|
|
|
@ -21,8 +21,8 @@ jobs:
|
|||
create-changelog-prs:
|
||||
runs-on: ubuntu-20.04
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
@ -46,6 +46,9 @@ jobs:
|
|||
- name: 'Generate the changelog file'
|
||||
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'
|
||||
run: git rm $(git ls-files --deleted)
|
||||
|
||||
|
|
|
@ -60,7 +60,8 @@ jobs:
|
|||
name: 'Maybe create next milestone and release branch'
|
||||
runs-on: ubuntu-20.04
|
||||
permissions:
|
||||
contents: read
|
||||
contents: write
|
||||
issues: write
|
||||
needs: verify-code-freeze
|
||||
if: needs.verify-code-freeze.outputs.freeze == 0
|
||||
outputs:
|
||||
|
@ -89,8 +90,8 @@ jobs:
|
|||
name: Preps trunk for next development cycle
|
||||
runs-on: ubuntu-20.04
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
contents: write
|
||||
pull-requests: write
|
||||
needs: maybe-create-next-milestone-and-release-branch
|
||||
steps:
|
||||
- name: Checkout code
|
||||
|
@ -159,7 +160,7 @@ jobs:
|
|||
name: 'Trigger changelog action'
|
||||
runs-on: ubuntu-20.04
|
||||
permissions:
|
||||
actions: write
|
||||
actions: write
|
||||
needs: maybe-create-next-milestone-and-release-branch
|
||||
steps:
|
||||
- name: 'Trigger changelog action'
|
||||
|
|
|
@ -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
|
|
@ -0,0 +1,4 @@
|
|||
Significance: minor
|
||||
Type: dev
|
||||
|
||||
Create tree-control component
|
|
@ -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.
|
|
@ -0,0 +1,4 @@
|
|||
Significance: patch
|
||||
Type: add
|
||||
|
||||
Add an optional "InputProps" to experimental SelectControl component
|
|
@ -0,0 +1,4 @@
|
|||
Significance: patch
|
||||
Type: dev
|
||||
|
||||
Migrate Table component to TS
|
|
@ -0,0 +1,4 @@
|
|||
Significance: minor
|
||||
Type: add
|
||||
|
||||
Adding experimental component SlotContext
|
|
@ -0,0 +1,4 @@
|
|||
Significance: minor
|
||||
Type: add
|
||||
|
||||
Adding ProductSectionLayout component and changing default order for WooProductSectionItem component.
|
|
@ -7,6 +7,7 @@ import {
|
|||
UseComboboxState,
|
||||
UseComboboxStateChangeOptions,
|
||||
useMultipleSelection,
|
||||
GetInputPropsOptions,
|
||||
} from 'downshift';
|
||||
import {
|
||||
useState,
|
||||
|
@ -65,6 +66,7 @@ export type SelectControlProps< ItemType > = {
|
|||
selected: ItemType | ItemType[] | null;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
inputProps?: GetInputPropsOptions;
|
||||
suffix?: JSX.Element | null;
|
||||
/**
|
||||
* This is a feature already implemented in downshift@7.0.0 through the
|
||||
|
@ -119,6 +121,7 @@ function SelectControl< ItemType = DefaultItemType >( {
|
|||
selected,
|
||||
className,
|
||||
disabled,
|
||||
inputProps = {},
|
||||
suffix = <SuffixIcon icon={ search } />,
|
||||
__experimentalOpenMenuOnFocus = false,
|
||||
}: SelectControlProps< ItemType > ) {
|
||||
|
@ -268,6 +271,7 @@ function SelectControl< ItemType = DefaultItemType >( {
|
|||
onBlur: () => setIsFocused( false ),
|
||||
placeholder,
|
||||
disabled,
|
||||
...inputProps,
|
||||
} ) }
|
||||
suffix={ suffix }
|
||||
>
|
||||
|
|
|
@ -0,0 +1,50 @@
|
|||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { useMemo } from 'react';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { Item, LinkedTree } from '../types';
|
||||
|
||||
type MemoItems = {
|
||||
[ value: Item[ 'value' ] ]: LinkedTree;
|
||||
};
|
||||
|
||||
function findChildren(
|
||||
items: Item[],
|
||||
parent?: Item[ 'parent' ],
|
||||
memo: MemoItems = {}
|
||||
): LinkedTree[] {
|
||||
const children: Item[] = [];
|
||||
const others: Item[] = [];
|
||||
|
||||
items.forEach( ( item ) => {
|
||||
if ( item.parent === parent ) {
|
||||
children.push( item );
|
||||
} else {
|
||||
others.push( item );
|
||||
}
|
||||
memo[ item.value ] = {
|
||||
parent: undefined,
|
||||
data: item,
|
||||
children: [],
|
||||
};
|
||||
} );
|
||||
|
||||
return children.map( ( child ) => {
|
||||
const linkedTree = memo[ child.value ];
|
||||
linkedTree.parent = child.parent ? memo[ child.parent ] : undefined;
|
||||
linkedTree.children = findChildren( others, child.value, memo );
|
||||
return linkedTree;
|
||||
} );
|
||||
}
|
||||
|
||||
export function useLinkedTree( items: Item[] ): LinkedTree[] {
|
||||
const linkedTree = useMemo( () => {
|
||||
return findChildren( items, undefined, {} );
|
||||
}, [ items ] );
|
||||
|
||||
return linkedTree;
|
||||
}
|
|
@ -0,0 +1,30 @@
|
|||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
|
||||
/**
|
||||
* 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,
|
||||
},
|
||||
};
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { TreeProps } from '../types';
|
||||
|
||||
export function useTree( { ref, items, level = 1, ...props }: TreeProps ) {
|
||||
return {
|
||||
level,
|
||||
items,
|
||||
treeProps: {
|
||||
...props,
|
||||
},
|
||||
treeItemProps: {
|
||||
level,
|
||||
},
|
||||
};
|
||||
}
|
|
@ -0,0 +1,4 @@
|
|||
export * from './tree';
|
||||
export * from './tree-control';
|
||||
export * from './tree-item';
|
||||
export * from './types';
|
|
@ -0,0 +1,42 @@
|
|||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { BaseControl } from '@wordpress/components';
|
||||
import React, { createElement } from 'react';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { TreeControl } from '../tree-control';
|
||||
import { Item } from '../types';
|
||||
|
||||
const listItems: Item[] = [
|
||||
{ value: '1', label: 'Technology' },
|
||||
{ value: '1.1', label: 'Notebooks', parent: '1' },
|
||||
{ value: '1.2', label: 'Phones', parent: '1' },
|
||||
{ value: '1.2.1', label: 'iPhone', parent: '1.2' },
|
||||
{ value: '1.2.1.1', label: 'iPhone 14 Pro', parent: '1.2.1' },
|
||||
{ value: '1.2.1.2', label: 'iPhone 14 Pro Max', parent: '1.2.1' },
|
||||
{ value: '1.2.2', label: 'Samsung', parent: '1.2' },
|
||||
{ value: '1.2.2.1', label: 'Samsung Galaxy 22 Plus', parent: '1.2.2' },
|
||||
{ value: '1.2.2.2', label: 'Samsung Galaxy 22 Ultra', parent: '1.2.2' },
|
||||
{ value: '1.3', label: 'Wearables', parent: '1' },
|
||||
{ value: '2', label: 'Hardware' },
|
||||
{ value: '2.1', label: 'CPU', parent: '2' },
|
||||
{ value: '2.2', label: 'GPU', parent: '2' },
|
||||
{ value: '2.3', label: 'Memory RAM', parent: '2' },
|
||||
{ value: '3', label: 'Other' },
|
||||
];
|
||||
|
||||
export const SimpleTree: React.FC = () => {
|
||||
return (
|
||||
<BaseControl label="Simple tree" id="simple-tree">
|
||||
<TreeControl id="simple-tree" items={ listItems } />
|
||||
</BaseControl>
|
||||
);
|
||||
};
|
||||
|
||||
export default {
|
||||
title: 'WooCommerce Admin/experimental/TreeControl',
|
||||
component: TreeControl,
|
||||
};
|
|
@ -0,0 +1,20 @@
|
|||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { createElement, forwardRef } from 'react';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { useLinkedTree } from './hooks/use-linked-tree';
|
||||
import { Tree } from './tree';
|
||||
import { TreeControlProps } from './types';
|
||||
|
||||
export const TreeControl = forwardRef( function ForwardedTree(
|
||||
{ items, ...props }: TreeControlProps,
|
||||
ref: React.ForwardedRef< HTMLOListElement >
|
||||
) {
|
||||
const linkedTree = useLinkedTree( items );
|
||||
|
||||
return <Tree { ...props } ref={ ref } items={ linkedTree } />;
|
||||
} );
|
|
@ -0,0 +1,34 @@
|
|||
.experimental-woocommerce-tree-item {
|
||||
margin: 0;
|
||||
|
||||
&__heading {
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
gap: $gap-smaller;
|
||||
min-height: $gap-largest;
|
||||
padding: 0 $gap-small;
|
||||
border-radius: 2px;
|
||||
|
||||
&:hover,
|
||||
&:focus-within {
|
||||
outline: 1.5px solid var( --wp-admin-theme-color );
|
||||
outline-offset: -1.5px;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus-within {
|
||||
background-color: $gray-0;
|
||||
}
|
||||
}
|
||||
&__label {
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
align-items: center;
|
||||
padding: $gap-smaller $gap-small $gap-smaller 0;
|
||||
position: relative;
|
||||
|
||||
> span {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,44 @@
|
|||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import classNames from 'classnames';
|
||||
import { createElement, forwardRef } from 'react';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { useTreeItem } from './hooks/use-tree-item';
|
||||
import { Tree } from './tree';
|
||||
import { TreeItemProps } from './types';
|
||||
|
||||
export const TreeItem = forwardRef( function ForwardedTreeItem(
|
||||
props: TreeItemProps,
|
||||
ref: React.ForwardedRef< HTMLLIElement >
|
||||
) {
|
||||
const { item, treeItemProps, headingProps, treeProps } = useTreeItem( {
|
||||
...props,
|
||||
ref,
|
||||
} );
|
||||
|
||||
return (
|
||||
<li
|
||||
{ ...treeItemProps }
|
||||
className={ classNames(
|
||||
treeItemProps.className,
|
||||
'experimental-woocommerce-tree-item'
|
||||
) }
|
||||
>
|
||||
<div
|
||||
{ ...headingProps }
|
||||
className="experimental-woocommerce-tree-item__heading"
|
||||
>
|
||||
<div className="experimental-woocommerce-tree-item__label">
|
||||
<span>{ item.data.label }</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{ Boolean( item.children.length ) && <Tree { ...treeProps } /> }
|
||||
</li>
|
||||
);
|
||||
} );
|
|
@ -0,0 +1,15 @@
|
|||
@import './tree-item.scss';
|
||||
|
||||
.experimental-woocommerce-tree {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
|
||||
&--level-1 {
|
||||
max-height: 280px;
|
||||
overflow-y: auto;
|
||||
background-color: $white;
|
||||
border: 1px solid $gray-400;
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,42 @@
|
|||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import classNames from 'classnames';
|
||||
import { createElement, forwardRef } from 'react';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { useTree } from './hooks/use-tree';
|
||||
import { TreeItem } from './tree-item';
|
||||
import { TreeProps } from './types';
|
||||
|
||||
export const Tree = forwardRef( function ForwardedTree(
|
||||
props: TreeProps,
|
||||
ref: React.ForwardedRef< HTMLOListElement >
|
||||
) {
|
||||
const { level, items, treeProps, treeItemProps } = useTree( {
|
||||
...props,
|
||||
ref,
|
||||
} );
|
||||
|
||||
if ( ! items.length ) return null;
|
||||
return (
|
||||
<ol
|
||||
{ ...treeProps }
|
||||
className={ classNames(
|
||||
treeProps.className,
|
||||
'experimental-woocommerce-tree',
|
||||
`experimental-woocommerce-tree--level-${ level }`
|
||||
) }
|
||||
>
|
||||
{ items.map( ( child ) => (
|
||||
<TreeItem
|
||||
{ ...treeItemProps }
|
||||
key={ child.data.value }
|
||||
item={ child }
|
||||
/>
|
||||
) ) }
|
||||
</ol>
|
||||
);
|
||||
} );
|
|
@ -0,0 +1,31 @@
|
|||
export interface Item {
|
||||
parent?: string;
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface LinkedTree {
|
||||
parent?: LinkedTree;
|
||||
data: Item;
|
||||
children: LinkedTree[];
|
||||
}
|
||||
|
||||
export type TreeProps = React.DetailedHTMLProps<
|
||||
React.OlHTMLAttributes< HTMLOListElement >,
|
||||
HTMLOListElement
|
||||
> & {
|
||||
level?: number;
|
||||
items: LinkedTree[];
|
||||
};
|
||||
|
||||
export type TreeItemProps = React.DetailedHTMLProps<
|
||||
React.LiHTMLAttributes< HTMLLIElement >,
|
||||
HTMLLIElement
|
||||
> & {
|
||||
level: number;
|
||||
item: LinkedTree;
|
||||
};
|
||||
|
||||
export type TreeControlProps = Omit< TreeProps, 'items' | 'level' > & {
|
||||
items: Item[];
|
||||
};
|
|
@ -87,3 +87,14 @@ export { CollapsibleContent } from './collapsible-content';
|
|||
export { createOrderedChildren, sortFillsByOrder } from './utils';
|
||||
export { WooProductFieldItem as __experimentalWooProductFieldItem } from './woo-product-field-item';
|
||||
export { WooProductSectionItem as __experimentalWooProductSectionItem } from './woo-product-section-item';
|
||||
export {
|
||||
ProductSectionLayout as __experimentalProductSectionLayout,
|
||||
ProductFieldSection as __experimentalProductFieldSection,
|
||||
} from './product-section-layout';
|
||||
export * from './product-fields';
|
||||
export {
|
||||
SlotContextProvider,
|
||||
useSlotContext,
|
||||
SlotContextType,
|
||||
SlotContextHelpersType,
|
||||
} from './slot-context';
|
||||
|
|
|
@ -3,6 +3,10 @@
|
|||
*/
|
||||
import { select } from '@wordpress/data';
|
||||
import { createElement } from '@wordpress/element';
|
||||
import {
|
||||
// @ts-expect-error `__experimentalInputControl` does exist.
|
||||
__experimentalInputControl as InputControl,
|
||||
} from '@wordpress/components';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
|
@ -19,10 +23,7 @@ export function renderField( name: string, props: Record< string, any > ) {
|
|||
return <fieldConfig.render { ...props } />;
|
||||
}
|
||||
if ( fieldConfig.type ) {
|
||||
return createElement( 'input', {
|
||||
type: fieldConfig.type,
|
||||
...props,
|
||||
} );
|
||||
return <InputControl type={ fieldConfig.type } { ...props } />;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
};
|
|
@ -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;
|
|
@ -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,
|
||||
};
|
|
@ -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;
|
|
@ -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 );
|
||||
} );
|
||||
};
|
|
@ -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,
|
||||
};
|
|
@ -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;
|
|
@ -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,
|
||||
};
|
|
@ -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;
|
|
@ -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,
|
||||
};
|
|
@ -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;
|
|
@ -0,0 +1,6 @@
|
|||
export type BaseProductFieldProps< T > = {
|
||||
value: T;
|
||||
onChange: ( value: T ) => void;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
};
|
|
@ -1,2 +1,3 @@
|
|||
export { store } from './store';
|
||||
export * from './api';
|
||||
export * from './fields';
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { ComponentType } from 'react';
|
||||
import { ComponentType, HTMLInputTypeAttribute } from 'react';
|
||||
|
||||
export type ProductFieldDefinition = {
|
||||
name: string;
|
||||
type?: string;
|
||||
type?: HTMLInputTypeAttribute;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
render?: ComponentType;
|
||||
};
|
||||
|
|
|
@ -3,56 +3,92 @@
|
|||
*/
|
||||
import React from 'react';
|
||||
import { useState, createElement } from '@wordpress/element';
|
||||
import { createRegistry, RegistryProvider, select } from '@wordpress/data';
|
||||
import {
|
||||
// @ts-expect-error `__experimentalInputControl` does exist.
|
||||
__experimentalInputControl as InputControl,
|
||||
} from '@wordpress/components';
|
||||
import { createRegistry, RegistryProvider } from '@wordpress/data';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { store } from '../store';
|
||||
import { registerProductField, renderField } from '../api';
|
||||
import { renderField } from '../api';
|
||||
import { registerCoreProductFields } from '../fields';
|
||||
|
||||
const registry = createRegistry();
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore No types for this exist yet.
|
||||
registry.register( store );
|
||||
|
||||
registerProductField( 'text', {
|
||||
name: 'text',
|
||||
render: ( props ) => {
|
||||
return <InputControl type="text" { ...props } />;
|
||||
},
|
||||
} );
|
||||
registerCoreProductFields();
|
||||
|
||||
registerProductField( 'number', {
|
||||
name: 'number',
|
||||
render: () => {
|
||||
return <InputControl type="number" />;
|
||||
const fieldConfigs = [
|
||||
{
|
||||
name: 'text-field',
|
||||
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 fields: string[] = select( store ).getRegisteredProductFields();
|
||||
const [ selectedField, setSelectedField ] = useState(
|
||||
fields ? fields[ 0 ] : undefined
|
||||
fieldConfigs[ 0 ].name || undefined
|
||||
);
|
||||
const [ value, setValue ] = useState();
|
||||
|
||||
const handleChange = ( event ) => {
|
||||
setSelectedField( event.target.value );
|
||||
};
|
||||
const selectedFieldConfig = fieldConfigs.find(
|
||||
( f ) => f.name === selectedField
|
||||
);
|
||||
return (
|
||||
<div>
|
||||
<select value={ selectedField } onChange={ handleChange }>
|
||||
{ fields.map( ( field ) => (
|
||||
<option key={ field } value={ field }>
|
||||
{ field }
|
||||
{ fieldConfigs.map( ( field ) => (
|
||||
<option key={ field.name } value={ field.name }>
|
||||
{ field.label }
|
||||
</option>
|
||||
) ) }
|
||||
</select>
|
||||
{ selectedField && renderField( selectedField, { name: 'test' } ) }
|
||||
{ selectedFieldConfig &&
|
||||
renderField( selectedFieldConfig.type, {
|
||||
value,
|
||||
onChange: setValue,
|
||||
...selectedFieldConfig,
|
||||
} ) }
|
||||
</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 {
|
||||
title: 'WooCommerce Admin/experimental/product-fields',
|
||||
component: Basic,
|
||||
|
|
|
@ -0,0 +1,2 @@
|
|||
export * from './product-section-layout';
|
||||
export * from './product-field-section';
|
|
@ -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>
|
||||
);
|
|
@ -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>
|
||||
);
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export * from './slot-context';
|
|
@ -0,0 +1,104 @@
|
|||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import {
|
||||
createElement,
|
||||
createContext,
|
||||
useContext,
|
||||
useCallback,
|
||||
useReducer,
|
||||
} from '@wordpress/element';
|
||||
|
||||
type FillConfigType = {
|
||||
visible: boolean;
|
||||
};
|
||||
|
||||
type FillType = Record< string, FillConfigType >;
|
||||
|
||||
type FillCollection = readonly ( readonly JSX.Element[] )[];
|
||||
|
||||
export type SlotContextHelpersType = {
|
||||
hideFill: ( id: string ) => void;
|
||||
showFill: ( id: string ) => void;
|
||||
getFills: () => FillType;
|
||||
};
|
||||
|
||||
export type SlotContextType = {
|
||||
fills: FillType;
|
||||
getFillHelpers: () => SlotContextHelpersType;
|
||||
registerFill: ( id: string ) => void;
|
||||
filterRegisteredFills: ( fillsArrays: FillCollection ) => FillCollection;
|
||||
};
|
||||
|
||||
const SlotContext = createContext< SlotContextType | undefined >( undefined );
|
||||
|
||||
export const SlotContextProvider: React.FC = ( { children } ) => {
|
||||
const [ fills, updateFills ] = useReducer(
|
||||
( data: FillType, updates: FillType ) => ( { ...data, ...updates } ),
|
||||
{}
|
||||
);
|
||||
|
||||
const updateFillConfig = (
|
||||
id: string,
|
||||
update: Partial< FillConfigType >
|
||||
) => {
|
||||
if ( ! fills[ id ] ) {
|
||||
throw new Error( `No fill found with ID: ${ id }` );
|
||||
}
|
||||
updateFills( { [ id ]: { ...fills[ id ], ...update } } );
|
||||
};
|
||||
|
||||
const registerFill = useCallback(
|
||||
( id: string ) => {
|
||||
if ( fills[ id ] ) {
|
||||
return;
|
||||
}
|
||||
updateFills( { [ id ]: { visible: true } } );
|
||||
},
|
||||
[ fills ]
|
||||
);
|
||||
|
||||
const hideFill = useCallback(
|
||||
( id: string ) => updateFillConfig( id, { visible: false } ),
|
||||
[ fills ]
|
||||
);
|
||||
|
||||
const showFill = useCallback(
|
||||
( id: string ) => updateFillConfig( id, { visible: true } ),
|
||||
[ fills ]
|
||||
);
|
||||
|
||||
const getFills = useCallback( () => ( { ...fills } ), [ fills ] );
|
||||
|
||||
return (
|
||||
<SlotContext.Provider
|
||||
value={ {
|
||||
registerFill,
|
||||
getFillHelpers() {
|
||||
return { hideFill, showFill, getFills };
|
||||
},
|
||||
filterRegisteredFills( fillsArrays: FillCollection ) {
|
||||
return fillsArrays.filter(
|
||||
( arr ) =>
|
||||
fills[ arr[ 0 ].props._id ]?.visible !== false
|
||||
);
|
||||
},
|
||||
fills,
|
||||
} }
|
||||
>
|
||||
{ children }
|
||||
</SlotContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useSlotContext = () => {
|
||||
const slotContext = useContext( SlotContext );
|
||||
|
||||
if ( slotContext === undefined ) {
|
||||
throw new Error(
|
||||
'useSlotContext must be used within a SlotContextProvider'
|
||||
);
|
||||
}
|
||||
|
||||
return slotContext;
|
||||
};
|
|
@ -55,3 +55,4 @@
|
|||
@import 'tour-kit/style.scss';
|
||||
@import 'collapsible-content/style.scss';
|
||||
@import 'form/style.scss';
|
||||
@import 'product-section-layout/style.scss';
|
||||
|
|
|
@ -1,39 +1,32 @@
|
|||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import PropTypes from 'prop-types';
|
||||
import { createElement } from '@wordpress/element';
|
||||
import React from 'react';
|
||||
|
||||
type EmptyTableProps = {
|
||||
children: React.ReactNode;
|
||||
|
||||
/** An integer with the number of rows the box should occupy. */
|
||||
numberOfRows?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* `EmptyTable` displays a blank space with an optional message passed as a children node
|
||||
* with the purpose of replacing a table with no rows.
|
||||
* It mimics the same height a table would have according to the `numberOfRows` prop.
|
||||
*
|
||||
* @param {Object} props
|
||||
* @param {Node} props.children
|
||||
* @param {number} props.numberOfRows
|
||||
* @return {Object} -
|
||||
*/
|
||||
const EmptyTable = ( { children, numberOfRows } ) => {
|
||||
const EmptyTable = ( { children, numberOfRows = 5 }: EmptyTableProps ) => {
|
||||
return (
|
||||
<div
|
||||
className="woocommerce-table is-empty"
|
||||
style={ { '--number-of-rows': numberOfRows } }
|
||||
style={
|
||||
{ '--number-of-rows': numberOfRows } as React.CSSProperties
|
||||
}
|
||||
>
|
||||
{ children }
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
EmptyTable.propTypes = {
|
||||
/**
|
||||
* An integer with the number of rows the box should occupy.
|
||||
*/
|
||||
numberOfRows: PropTypes.number,
|
||||
};
|
||||
|
||||
EmptyTable.defaultProps = {
|
||||
numberOfRows: 5,
|
||||
};
|
||||
|
||||
export default EmptyTable;
|
|
@ -1,384 +0,0 @@
|
|||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import classnames from 'classnames';
|
||||
import {
|
||||
Card,
|
||||
CardBody,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
__experimentalText as Text,
|
||||
} from '@wordpress/components';
|
||||
import { createElement, Component, Fragment } from '@wordpress/element';
|
||||
import { find, first, isEqual, without } from 'lodash';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import EllipsisMenu from '../ellipsis-menu';
|
||||
import MenuItem from '../ellipsis-menu/menu-item';
|
||||
import MenuTitle from '../ellipsis-menu/menu-title';
|
||||
import Pagination from '../pagination';
|
||||
import Table from './table';
|
||||
import TablePlaceholder from './placeholder';
|
||||
import TableSummary, { TableSummaryPlaceholder } from './summary';
|
||||
|
||||
/**
|
||||
* This is an accessible, sortable, and scrollable table for displaying tabular data (like revenue and other analytics data).
|
||||
* It accepts `headers` for column headers, and `rows` for the table content.
|
||||
* `rowHeader` can be used to define the index of the row header (or false if no header).
|
||||
*
|
||||
* `TableCard` serves as Card wrapper & contains a card header, `<Table />`, `<TableSummary />`, and `<Pagination />`.
|
||||
* This includes filtering and comparison functionality for report pages.
|
||||
*/
|
||||
class TableCard extends Component {
|
||||
constructor( props ) {
|
||||
super( props );
|
||||
const showCols = this.getShowCols( props.headers );
|
||||
|
||||
this.state = { showCols };
|
||||
this.onColumnToggle = this.onColumnToggle.bind( this );
|
||||
this.onPageChange = this.onPageChange.bind( this );
|
||||
}
|
||||
|
||||
componentDidUpdate( { headers: prevHeaders, query: prevQuery } ) {
|
||||
const { headers, onColumnsChange, query } = this.props;
|
||||
const { showCols } = this.state;
|
||||
|
||||
if ( ! isEqual( headers, prevHeaders ) ) {
|
||||
/* eslint-disable react/no-did-update-set-state */
|
||||
this.setState( {
|
||||
showCols: this.getShowCols( headers ),
|
||||
} );
|
||||
/* eslint-enable react/no-did-update-set-state */
|
||||
}
|
||||
if (
|
||||
query.orderby !== prevQuery.orderby &&
|
||||
! showCols.includes( query.orderby )
|
||||
) {
|
||||
const newShowCols = showCols.concat( query.orderby );
|
||||
/* eslint-disable react/no-did-update-set-state */
|
||||
this.setState( {
|
||||
showCols: newShowCols,
|
||||
} );
|
||||
/* eslint-enable react/no-did-update-set-state */
|
||||
onColumnsChange( newShowCols );
|
||||
}
|
||||
}
|
||||
|
||||
getShowCols( headers ) {
|
||||
return headers
|
||||
.map( ( { key, visible } ) => {
|
||||
if ( typeof visible === 'undefined' || visible ) {
|
||||
return key;
|
||||
}
|
||||
return false;
|
||||
} )
|
||||
.filter( Boolean );
|
||||
}
|
||||
|
||||
getVisibleHeaders() {
|
||||
const { headers } = this.props;
|
||||
const { showCols } = this.state;
|
||||
return headers.filter( ( { key } ) => showCols.includes( key ) );
|
||||
}
|
||||
|
||||
getVisibleRows() {
|
||||
const { headers, rows } = this.props;
|
||||
const { showCols } = this.state;
|
||||
|
||||
return rows.map( ( row ) => {
|
||||
return headers
|
||||
.map( ( { key }, i ) => {
|
||||
return showCols.includes( key ) && row[ i ];
|
||||
} )
|
||||
.filter( Boolean );
|
||||
} );
|
||||
}
|
||||
|
||||
onColumnToggle( key ) {
|
||||
const { headers, query, onQueryChange, onColumnsChange } = this.props;
|
||||
|
||||
return () => {
|
||||
this.setState( ( prevState ) => {
|
||||
const hasKey = prevState.showCols.includes( key );
|
||||
|
||||
if ( hasKey ) {
|
||||
// Handle hiding a sorted column
|
||||
if ( query.orderby === key ) {
|
||||
const defaultSort =
|
||||
find( headers, { defaultSort: true } ) ||
|
||||
first( headers ) ||
|
||||
{};
|
||||
onQueryChange( 'sort' )( defaultSort.key, 'desc' );
|
||||
}
|
||||
|
||||
const showCols = without( prevState.showCols, key );
|
||||
onColumnsChange( showCols, key );
|
||||
return { showCols };
|
||||
}
|
||||
|
||||
const showCols = [ ...prevState.showCols, key ];
|
||||
onColumnsChange( showCols, key );
|
||||
return { showCols };
|
||||
} );
|
||||
};
|
||||
}
|
||||
|
||||
onPageChange( ...params ) {
|
||||
const { onPageChange, onQueryChange } = this.props;
|
||||
if ( onPageChange ) {
|
||||
onPageChange( ...params );
|
||||
}
|
||||
if ( onQueryChange ) {
|
||||
onQueryChange( 'paged' )( ...params );
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
actions,
|
||||
className,
|
||||
hasSearch,
|
||||
isLoading,
|
||||
onQueryChange,
|
||||
onSort,
|
||||
query,
|
||||
rowHeader,
|
||||
rowsPerPage,
|
||||
showMenu,
|
||||
summary,
|
||||
title,
|
||||
totalRows,
|
||||
rowKey,
|
||||
emptyMessage,
|
||||
} = this.props;
|
||||
const { showCols } = this.state;
|
||||
const allHeaders = this.props.headers;
|
||||
const headers = this.getVisibleHeaders();
|
||||
const rows = this.getVisibleRows();
|
||||
const classes = classnames( 'woocommerce-table', className, {
|
||||
'has-actions': !! actions,
|
||||
'has-menu': showMenu,
|
||||
'has-search': hasSearch,
|
||||
} );
|
||||
|
||||
return (
|
||||
<Card className={ classes }>
|
||||
<CardHeader>
|
||||
<Text size={ 16 } weight={ 600 } as="h2" color="#23282d">
|
||||
{ title }
|
||||
</Text>
|
||||
<div className="woocommerce-table__actions">
|
||||
{ actions }
|
||||
</div>
|
||||
{ showMenu && (
|
||||
<EllipsisMenu
|
||||
label={ __(
|
||||
'Choose which values to display',
|
||||
'woocommerce'
|
||||
) }
|
||||
renderContent={ () => (
|
||||
<Fragment>
|
||||
<MenuTitle>
|
||||
{ __( 'Columns:', 'woocommerce' ) }
|
||||
</MenuTitle>
|
||||
{ allHeaders.map(
|
||||
( { key, label, required } ) => {
|
||||
if ( required ) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<MenuItem
|
||||
checked={ showCols.includes(
|
||||
key
|
||||
) }
|
||||
isCheckbox
|
||||
isClickable
|
||||
key={ key }
|
||||
onInvoke={ this.onColumnToggle(
|
||||
key
|
||||
) }
|
||||
>
|
||||
{ label }
|
||||
</MenuItem>
|
||||
);
|
||||
}
|
||||
) }
|
||||
</Fragment>
|
||||
) }
|
||||
/>
|
||||
) }
|
||||
</CardHeader>
|
||||
<CardBody size={ null }>
|
||||
{ isLoading ? (
|
||||
<Fragment>
|
||||
<span className="screen-reader-text">
|
||||
{ __(
|
||||
'Your requested data is loading',
|
||||
'woocommerce'
|
||||
) }
|
||||
</span>
|
||||
<TablePlaceholder
|
||||
numberOfRows={ rowsPerPage }
|
||||
headers={ headers }
|
||||
rowHeader={ rowHeader }
|
||||
caption={ title }
|
||||
query={ query }
|
||||
/>
|
||||
</Fragment>
|
||||
) : (
|
||||
<Table
|
||||
rows={ rows }
|
||||
headers={ headers }
|
||||
rowHeader={ rowHeader }
|
||||
caption={ title }
|
||||
query={ query }
|
||||
onSort={ onSort || onQueryChange( 'sort' ) }
|
||||
rowKey={ rowKey }
|
||||
emptyMessage={ emptyMessage }
|
||||
/>
|
||||
) }
|
||||
</CardBody>
|
||||
|
||||
<CardFooter justify="center">
|
||||
{ isLoading ? (
|
||||
<TableSummaryPlaceholder />
|
||||
) : (
|
||||
<Fragment>
|
||||
<Pagination
|
||||
key={ parseInt( query.paged, 10 ) || 1 }
|
||||
page={ parseInt( query.paged, 10 ) || 1 }
|
||||
perPage={ rowsPerPage }
|
||||
total={ totalRows }
|
||||
onPageChange={ this.onPageChange }
|
||||
onPerPageChange={ onQueryChange( 'per_page' ) }
|
||||
/>
|
||||
|
||||
{ summary && <TableSummary data={ summary } /> }
|
||||
</Fragment>
|
||||
) }
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
TableCard.propTypes = {
|
||||
/**
|
||||
* If a search is provided in actions and should reorder actions on mobile.
|
||||
*/
|
||||
hasSearch: PropTypes.bool,
|
||||
/**
|
||||
* An array of column headers (see `Table` props).
|
||||
*/
|
||||
headers: PropTypes.arrayOf(
|
||||
PropTypes.shape( {
|
||||
hiddenByDefault: PropTypes.bool,
|
||||
defaultSort: PropTypes.bool,
|
||||
isSortable: PropTypes.bool,
|
||||
key: PropTypes.string,
|
||||
label: PropTypes.oneOfType( [ PropTypes.string, PropTypes.node ] ),
|
||||
required: PropTypes.bool,
|
||||
} )
|
||||
),
|
||||
/**
|
||||
* A list of IDs, matching to the row list so that ids[ 0 ] contains the object ID for the object displayed in row[ 0 ].
|
||||
*/
|
||||
ids: PropTypes.arrayOf( PropTypes.number ),
|
||||
/**
|
||||
* Defines if the table contents are loading.
|
||||
* It will display `TablePlaceholder` component instead of `Table` if that's the case.
|
||||
*/
|
||||
isLoading: PropTypes.bool,
|
||||
/**
|
||||
* A function which returns a callback function to update the query string for a given `param`.
|
||||
*/
|
||||
onQueryChange: PropTypes.func,
|
||||
/**
|
||||
* A function which returns a callback function which is called upon the user changing the visiblity of columns.
|
||||
*/
|
||||
onColumnsChange: PropTypes.func,
|
||||
/**
|
||||
* A function which is called upon the user changing the sorting of the table.
|
||||
*/
|
||||
onSort: PropTypes.func,
|
||||
/**
|
||||
* An object of the query parameters passed to the page, ex `{ page: 2, per_page: 5 }`.
|
||||
*/
|
||||
query: PropTypes.object,
|
||||
/**
|
||||
* Which column should be the row header, defaults to the first item (`0`) (but could be set to `1`, if the first col
|
||||
* is checkboxes, for example). Set to false to disable row headers.
|
||||
*/
|
||||
rowHeader: PropTypes.oneOfType( [ PropTypes.number, PropTypes.bool ] ),
|
||||
/**
|
||||
* An array of arrays of display/value object pairs (see `Table` props).
|
||||
*/
|
||||
rows: PropTypes.arrayOf(
|
||||
PropTypes.arrayOf(
|
||||
PropTypes.shape( {
|
||||
display: PropTypes.node,
|
||||
value: PropTypes.oneOfType( [
|
||||
PropTypes.string,
|
||||
PropTypes.number,
|
||||
PropTypes.bool,
|
||||
] ),
|
||||
} )
|
||||
)
|
||||
).isRequired,
|
||||
/**
|
||||
* The total number of rows to display per page.
|
||||
*/
|
||||
rowsPerPage: PropTypes.number.isRequired,
|
||||
/**
|
||||
* Boolean to determine whether or not ellipsis menu is shown.
|
||||
*/
|
||||
showMenu: PropTypes.bool,
|
||||
/**
|
||||
* An array of objects with `label` & `value` properties, which display in a line under the table.
|
||||
* Optional, can be left off to show no summary.
|
||||
*/
|
||||
summary: PropTypes.arrayOf(
|
||||
PropTypes.shape( {
|
||||
label: PropTypes.node,
|
||||
value: PropTypes.oneOfType( [
|
||||
PropTypes.string,
|
||||
PropTypes.number,
|
||||
] ),
|
||||
} )
|
||||
),
|
||||
/**
|
||||
* The title used in the card header, also used as the caption for the content in this table.
|
||||
*/
|
||||
title: PropTypes.string.isRequired,
|
||||
/**
|
||||
* The total number of rows (across all pages).
|
||||
*/
|
||||
totalRows: PropTypes.number.isRequired,
|
||||
/**
|
||||
* The rowKey used for the key value on each row, this can be a string of the key or a function that returns the value.
|
||||
* This uses the index if not defined.
|
||||
*/
|
||||
rowKey: PropTypes.func,
|
||||
/**
|
||||
* Customize the message to show when there are no rows in the table.
|
||||
*/
|
||||
emptyMessage: PropTypes.string,
|
||||
};
|
||||
|
||||
TableCard.defaultProps = {
|
||||
isLoading: false,
|
||||
onQueryChange: () => () => {},
|
||||
onColumnsChange: () => {},
|
||||
onSort: undefined,
|
||||
query: {},
|
||||
rowHeader: 0,
|
||||
rows: [],
|
||||
showMenu: true,
|
||||
emptyMessage: undefined,
|
||||
};
|
||||
|
||||
export default TableCard;
|
|
@ -0,0 +1,248 @@
|
|||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import classnames from 'classnames';
|
||||
import { createElement, Fragment, useState } from '@wordpress/element';
|
||||
import { find, first, without } from 'lodash';
|
||||
import React from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardBody,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
// @ts-expect-error: Suppressing Module '"@wordpress/components"' has no exported member '__experimentalText'
|
||||
__experimentalText as Text,
|
||||
} from '@wordpress/components';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import EllipsisMenu from '../ellipsis-menu';
|
||||
import MenuItem from '../ellipsis-menu/menu-item';
|
||||
import MenuTitle from '../ellipsis-menu/menu-title';
|
||||
import Pagination from '../pagination';
|
||||
import Table from './table';
|
||||
import TablePlaceholder from './placeholder';
|
||||
import TableSummary, { TableSummaryPlaceholder } from './summary';
|
||||
import { TableCardProps } from './types';
|
||||
|
||||
const defaultOnQueryChange =
|
||||
( param: string ) => ( path?: string, direction?: string ) => {};
|
||||
|
||||
const defaultOnColumnsChange = (
|
||||
showCols: Array< string >,
|
||||
key?: string
|
||||
) => {};
|
||||
/**
|
||||
* This is an accessible, sortable, and scrollable table for displaying tabular data (like revenue and other analytics data).
|
||||
* It accepts `headers` for column headers, and `rows` for the table content.
|
||||
* `rowHeader` can be used to define the index of the row header (or false if no header).
|
||||
*
|
||||
* `TableCard` serves as Card wrapper & contains a card header, `<Table />`, `<TableSummary />`, and `<Pagination />`.
|
||||
* This includes filtering and comparison functionality for report pages.
|
||||
*/
|
||||
const TableCard: React.VFC< TableCardProps > = ( {
|
||||
actions,
|
||||
className,
|
||||
hasSearch,
|
||||
headers = [],
|
||||
ids,
|
||||
isLoading = false,
|
||||
onQueryChange = defaultOnQueryChange,
|
||||
onColumnsChange = defaultOnColumnsChange,
|
||||
onSort,
|
||||
query = {},
|
||||
rowHeader = 0,
|
||||
rows = [],
|
||||
rowsPerPage,
|
||||
showMenu = true,
|
||||
summary,
|
||||
title,
|
||||
totalRows,
|
||||
rowKey,
|
||||
emptyMessage = undefined,
|
||||
...props
|
||||
} ) => {
|
||||
// eslint-disable-next-line no-console
|
||||
const getShowCols = ( _headers: TableCardProps[ 'headers' ] = [] ) => {
|
||||
return _headers
|
||||
.map( ( { key, visible } ) => {
|
||||
if ( typeof visible === 'undefined' || visible ) {
|
||||
return key;
|
||||
}
|
||||
return false;
|
||||
} )
|
||||
.filter( Boolean ) as string[];
|
||||
};
|
||||
|
||||
const [ showCols, setShowCols ] = useState( getShowCols( headers ) );
|
||||
|
||||
const onColumnToggle = ( key: string ) => {
|
||||
return () => {
|
||||
const hasKey = showCols.includes( key );
|
||||
|
||||
if ( hasKey ) {
|
||||
// Handle hiding a sorted column
|
||||
if ( query.orderby === key ) {
|
||||
const defaultSort = find( headers, {
|
||||
defaultSort: true,
|
||||
} ) ||
|
||||
first( headers ) || { key: undefined };
|
||||
onQueryChange( 'sort' )( defaultSort.key, 'desc' );
|
||||
}
|
||||
|
||||
const newShowCols = without( showCols, key );
|
||||
onColumnsChange( newShowCols, key );
|
||||
setShowCols( newShowCols );
|
||||
} else {
|
||||
const newShowCols = [ ...showCols, key ] as string[];
|
||||
onColumnsChange( newShowCols, key );
|
||||
setShowCols( newShowCols );
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const onPageChange = (
|
||||
newPage: string,
|
||||
direction?: 'previous' | 'next'
|
||||
) => {
|
||||
if ( props.onPageChange ) {
|
||||
props.onPageChange( parseInt( newPage, 10 ), direction );
|
||||
}
|
||||
if ( onQueryChange ) {
|
||||
onQueryChange( 'paged' )( newPage, direction );
|
||||
}
|
||||
};
|
||||
|
||||
const allHeaders = headers;
|
||||
const visibleHeaders = headers.filter( ( { key } ) =>
|
||||
showCols.includes( key )
|
||||
);
|
||||
const visibleRows = rows.map( ( row ) => {
|
||||
return headers
|
||||
.map( ( { key }, i ) => {
|
||||
return showCols.includes( key ) && row[ i ];
|
||||
} )
|
||||
.filter( Boolean );
|
||||
} );
|
||||
const classes = classnames( 'woocommerce-table', className, {
|
||||
'has-actions': !! actions,
|
||||
'has-menu': showMenu,
|
||||
'has-search': hasSearch,
|
||||
} );
|
||||
|
||||
return (
|
||||
<Card className={ classes }>
|
||||
<CardHeader>
|
||||
<Text size={ 16 } weight={ 600 } as="h2" color="#23282d">
|
||||
{ title }
|
||||
</Text>
|
||||
<div className="woocommerce-table__actions">{ actions }</div>
|
||||
{ showMenu && (
|
||||
<EllipsisMenu
|
||||
label={ __(
|
||||
'Choose which values to display',
|
||||
'woocommerce'
|
||||
) }
|
||||
renderContent={ () => (
|
||||
<Fragment>
|
||||
{ /* @ts-expect-error: Ignoring the error until we migrate ellipsis-menu to TS*/ }
|
||||
<MenuTitle>
|
||||
{ /* @ts-expect-error: Allow string */ }
|
||||
{ __( 'Columns:', 'woocommerce' ) }
|
||||
</MenuTitle>
|
||||
{ allHeaders.map(
|
||||
( { key, label, required } ) => {
|
||||
if ( required ) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<MenuItem
|
||||
checked={ showCols.includes(
|
||||
key
|
||||
) }
|
||||
isCheckbox
|
||||
isClickable
|
||||
key={ key }
|
||||
onInvoke={
|
||||
key !== undefined
|
||||
? onColumnToggle( key )
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{ label }
|
||||
</MenuItem>
|
||||
);
|
||||
}
|
||||
) }
|
||||
</Fragment>
|
||||
) }
|
||||
/>
|
||||
) }
|
||||
</CardHeader>
|
||||
{ /* Ignoring the error to make it backward compatible for now. */ }
|
||||
{ /* @ts-expect-error: size must be one of small, medium, largel, xSmall, extraSmall. */ }
|
||||
<CardBody size={ null }>
|
||||
{ isLoading ? (
|
||||
<Fragment>
|
||||
<span className="screen-reader-text">
|
||||
{ __(
|
||||
'Your requested data is loading',
|
||||
'woocommerce'
|
||||
) }
|
||||
</span>
|
||||
<TablePlaceholder
|
||||
numberOfRows={ rowsPerPage }
|
||||
headers={ visibleHeaders }
|
||||
rowHeader={ rowHeader }
|
||||
caption={ title }
|
||||
query={ query }
|
||||
/>
|
||||
</Fragment>
|
||||
) : (
|
||||
<Table
|
||||
rows={ visibleRows as TableCardProps[ 'rows' ] }
|
||||
headers={
|
||||
visibleHeaders as TableCardProps[ 'headers' ]
|
||||
}
|
||||
rowHeader={ rowHeader }
|
||||
caption={ title }
|
||||
query={ query }
|
||||
onSort={
|
||||
onSort ||
|
||||
( onQueryChange( 'sort' ) as (
|
||||
key: string,
|
||||
direction: string
|
||||
) => void )
|
||||
}
|
||||
rowKey={ rowKey }
|
||||
emptyMessage={ emptyMessage }
|
||||
/>
|
||||
) }
|
||||
</CardBody>
|
||||
|
||||
{ /* @ts-expect-error: justify is missing from the latest @types/wordpress__components */ }
|
||||
<CardFooter justify="center">
|
||||
{ isLoading ? (
|
||||
<TableSummaryPlaceholder />
|
||||
) : (
|
||||
<Fragment>
|
||||
<Pagination
|
||||
key={ parseInt( query.paged as string, 10 ) || 1 }
|
||||
page={ parseInt( query.paged as string, 10 ) || 1 }
|
||||
perPage={ rowsPerPage }
|
||||
total={ totalRows }
|
||||
onPageChange={ onPageChange }
|
||||
onPerPageChange={ onQueryChange( 'per_page' ) }
|
||||
/>
|
||||
|
||||
{ summary && <TableSummary data={ summary } /> }
|
||||
</Fragment>
|
||||
) }
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default TableCard;
|
|
@ -1,68 +0,0 @@
|
|||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { createElement, Component } from '@wordpress/element';
|
||||
import { range } from 'lodash';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import Table from './table';
|
||||
|
||||
/**
|
||||
* `TablePlaceholder` behaves like `Table` but displays placeholder boxes instead of data. This can be used while loading.
|
||||
*/
|
||||
class TablePlaceholder extends Component {
|
||||
render() {
|
||||
const { numberOfRows, ...tableProps } = this.props;
|
||||
const rows = range( numberOfRows ).map( () =>
|
||||
this.props.headers.map( () => ( {
|
||||
display: <span className="is-placeholder" />,
|
||||
} ) )
|
||||
);
|
||||
|
||||
return (
|
||||
<Table
|
||||
ariaHidden={ true }
|
||||
className="is-loading"
|
||||
rows={ rows }
|
||||
{ ...tableProps }
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
TablePlaceholder.propTypes = {
|
||||
/**
|
||||
* An object of the query parameters passed to the page, ex `{ page: 2, per_page: 5 }`.
|
||||
*/
|
||||
query: PropTypes.object,
|
||||
/**
|
||||
* A label for the content in this table.
|
||||
*/
|
||||
caption: PropTypes.string.isRequired,
|
||||
/**
|
||||
* An array of column headers (see `Table` props).
|
||||
*/
|
||||
headers: PropTypes.arrayOf(
|
||||
PropTypes.shape( {
|
||||
hiddenByDefault: PropTypes.bool,
|
||||
defaultSort: PropTypes.bool,
|
||||
isSortable: PropTypes.bool,
|
||||
key: PropTypes.string,
|
||||
label: PropTypes.node,
|
||||
required: PropTypes.bool,
|
||||
} )
|
||||
),
|
||||
/**
|
||||
* An integer with the number of rows to display.
|
||||
*/
|
||||
numberOfRows: PropTypes.number,
|
||||
};
|
||||
|
||||
TablePlaceholder.defaultProps = {
|
||||
numberOfRows: 5,
|
||||
};
|
||||
|
||||
export default TablePlaceholder;
|
|
@ -0,0 +1,55 @@
|
|||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { createElement } from '@wordpress/element';
|
||||
import { range } from 'lodash';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import Table from './table';
|
||||
import { QueryProps, TableHeader } from './types';
|
||||
|
||||
type TablePlaceholderProps = {
|
||||
/** An object of the query parameters passed to the page */
|
||||
query?: QueryProps;
|
||||
/** A label for the content in this table. */
|
||||
caption: string;
|
||||
/** An integer with the number of rows to display. */
|
||||
numberOfRows?: number;
|
||||
/**
|
||||
* Which column should be the row header, defaults to the first item (`0`) (but could be set to `1`, if the first col
|
||||
* is checkboxes, for example). Set to false to disable row headers.
|
||||
*/
|
||||
rowHeader?: number | false;
|
||||
/** An array of column headers (see `Table` props). */
|
||||
headers: Array< TableHeader >;
|
||||
};
|
||||
|
||||
/**
|
||||
* `TablePlaceholder` behaves like `Table` but displays placeholder boxes instead of data. This can be used while loading.
|
||||
*/
|
||||
const TablePlaceholder: React.VFC< TablePlaceholderProps > = ( {
|
||||
query,
|
||||
caption,
|
||||
headers,
|
||||
numberOfRows = 5,
|
||||
...props
|
||||
} ) => {
|
||||
const rows = range( numberOfRows ).map( () =>
|
||||
headers.map( () => ( {
|
||||
display: <span className="is-placeholder" />,
|
||||
} ) )
|
||||
);
|
||||
const tableProps = { query, caption, headers, numberOfRows, ...props };
|
||||
return (
|
||||
<Table
|
||||
ariaHidden={ true }
|
||||
className="is-loading"
|
||||
rows={ rows }
|
||||
{ ...tableProps }
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default TablePlaceholder;
|
|
@ -2,6 +2,7 @@
|
|||
* External dependencies
|
||||
*/
|
||||
import { EmptyTable } from '@woocommerce/components';
|
||||
import { createElement } from '@wordpress/element';
|
||||
|
||||
export const Basic = () => <EmptyTable>There are no entries.</EmptyTable>;
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { TableCard } from '@woocommerce/components';
|
||||
import { useState } from '@wordpress/element';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { headers, rows, summary } from './index';
|
||||
|
||||
const TableCardExample = () => {
|
||||
const [ { query }, setState ] = useState( {
|
||||
query: {
|
||||
paged: 1,
|
||||
},
|
||||
} );
|
||||
return (
|
||||
<TableCard
|
||||
title="Revenue last week"
|
||||
rows={ rows }
|
||||
headers={ headers }
|
||||
onQueryChange={ ( param ) => ( value ) =>
|
||||
setState( {
|
||||
query: {
|
||||
[ param ]: value,
|
||||
},
|
||||
} ) }
|
||||
query={ query }
|
||||
rowsPerPage={ 7 }
|
||||
totalRows={ 10 }
|
||||
summary={ summary }
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const Basic = () => <TableCardExample />;
|
||||
|
||||
export default {
|
||||
title: 'WooCommerce Admin/components/TableCard',
|
||||
component: TableCard,
|
||||
};
|
|
@ -0,0 +1,93 @@
|
|||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { TableCard } from '@woocommerce/components';
|
||||
import { useState, createElement } from '@wordpress/element';
|
||||
import { Button } from '@wordpress/components';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { headers, rows, summary } from './index';
|
||||
|
||||
const TableCardExample = () => {
|
||||
const [ { query }, setState ] = useState( {
|
||||
query: {
|
||||
paged: 1,
|
||||
},
|
||||
} );
|
||||
return (
|
||||
<TableCard
|
||||
title="Revenue last week"
|
||||
rows={ rows }
|
||||
headers={ headers }
|
||||
onQueryChange={ ( param ) => ( value ) =>
|
||||
setState( {
|
||||
// @ts-expect-error: ignore for storybook
|
||||
query: {
|
||||
[ param ]: value,
|
||||
},
|
||||
} ) }
|
||||
query={ query }
|
||||
rowsPerPage={ 7 }
|
||||
totalRows={ 10 }
|
||||
summary={ summary }
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const TableCardWithActionsExample = () => {
|
||||
const [ { query }, setState ] = useState( {
|
||||
query: {
|
||||
paged: 1,
|
||||
},
|
||||
} );
|
||||
|
||||
const [ action1Text, setAction1Text ] = useState( 'Action 1' );
|
||||
const [ action2Text, setAction2Text ] = useState( 'Action 2' );
|
||||
|
||||
return (
|
||||
<TableCard
|
||||
actions={ [
|
||||
<Button
|
||||
key={ 0 }
|
||||
onClick={ () => {
|
||||
setAction1Text( 'Action 1 Clicked' );
|
||||
} }
|
||||
>
|
||||
{ action1Text }
|
||||
</Button>,
|
||||
<Button
|
||||
key={ 0 }
|
||||
onClick={ () => {
|
||||
setAction2Text( 'Action 2 Clicked' );
|
||||
} }
|
||||
>
|
||||
{ action2Text }
|
||||
</Button>,
|
||||
] }
|
||||
title="Revenue last week"
|
||||
rows={ rows }
|
||||
headers={ headers }
|
||||
onQueryChange={ ( param ) => ( value ) =>
|
||||
setState( {
|
||||
// @ts-expect-error: ignore for storybook
|
||||
query: {
|
||||
[ param ]: value,
|
||||
},
|
||||
} ) }
|
||||
query={ query }
|
||||
rowsPerPage={ 7 }
|
||||
totalRows={ 10 }
|
||||
summary={ summary }
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const Basic = () => <TableCardExample />;
|
||||
export const Actions = () => <TableCardWithActionsExample />;
|
||||
|
||||
export default {
|
||||
title: 'WooCommerce Admin/components/TableCard',
|
||||
component: TableCard,
|
||||
};
|
|
@ -3,17 +3,21 @@
|
|||
*/
|
||||
import { Card } from '@wordpress/components';
|
||||
import { TablePlaceholder } from '@woocommerce/components';
|
||||
import { createElement } from '@wordpress/element';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { headers } from './index';
|
||||
|
||||
export const Basic = () => (
|
||||
<Card size={ null }>
|
||||
<TablePlaceholder caption="Revenue last week" headers={ headers } />
|
||||
</Card>
|
||||
);
|
||||
export const Basic = () => {
|
||||
return (
|
||||
/* @ts-expect-error: size must be one of small, medium, largel, xSmall, extraSmall. */
|
||||
<Card size={ null }>
|
||||
<TablePlaceholder caption="Revenue last week" headers={ headers } />
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default {
|
||||
title: 'WooCommerce Admin/components/TablePlaceholder',
|
|
@ -3,14 +3,18 @@
|
|||
*/
|
||||
import { Card, CardFooter } from '@wordpress/components';
|
||||
import { TableSummaryPlaceholder } from '@woocommerce/components';
|
||||
import { createElement } from '@wordpress/element';
|
||||
|
||||
export const Basic = () => (
|
||||
<Card>
|
||||
<CardFooter justify="center">
|
||||
<TableSummaryPlaceholder />
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
export const Basic = () => {
|
||||
return (
|
||||
<Card>
|
||||
{ /* @ts-expect-error: justify is missing from the latest type def. */ }
|
||||
<CardFooter justify="center">
|
||||
<TableSummaryPlaceholder />
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default {
|
||||
title: 'WooCommerce Admin/components/TableSummaryPlaceholder',
|
|
@ -3,6 +3,7 @@
|
|||
*/
|
||||
import { Card } from '@wordpress/components';
|
||||
import { Table } from '@woocommerce/components';
|
||||
import { createElement } from '@wordpress/element';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
|
@ -20,17 +21,20 @@ export const Basic = () => (
|
|||
</Card>
|
||||
);
|
||||
|
||||
export const NoDataCustomMessage = () => (
|
||||
<Card size={ null }>
|
||||
<Table
|
||||
caption="Revenue last week"
|
||||
rows={ [] }
|
||||
headers={ headers }
|
||||
rowKey={ ( row ) => row[ 0 ].value }
|
||||
emptyMessage="Custom empty message"
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
export const NoDataCustomMessage = () => {
|
||||
return (
|
||||
/* @ts-expect-error: size must be one of small, medium, largel, xSmall, extraSmall. */
|
||||
<Card size={ null }>
|
||||
<Table
|
||||
caption="Revenue last week"
|
||||
rows={ [] }
|
||||
headers={ headers }
|
||||
rowKey={ ( row ) => row[ 0 ].value }
|
||||
emptyMessage="Custom empty message"
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default {
|
||||
title: 'WooCommerce Admin/components/Table',
|
|
@ -1,17 +1,17 @@
|
|||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import PropTypes from 'prop-types';
|
||||
import { createElement } from '@wordpress/element';
|
||||
|
||||
/**
|
||||
* A component to display summarized table data - the list of data passed in on a single line.
|
||||
*
|
||||
* @param {Object} props
|
||||
* @param {Array} props.data
|
||||
* @return {Object} -
|
||||
* Internal dependencies
|
||||
*/
|
||||
const TableSummary = ( { data } ) => {
|
||||
import { TableSummaryProps } from './types';
|
||||
|
||||
/**
|
||||
* A component to display summarized table data - the list of data passed in on a single line.
|
||||
*/
|
||||
const TableSummary = ( { data }: TableSummaryProps ) => {
|
||||
return (
|
||||
<ul className="woocommerce-table__summary" role="complementary">
|
||||
{ data.map( ( { label, value }, i ) => (
|
||||
|
@ -28,13 +28,6 @@ const TableSummary = ( { data } ) => {
|
|||
);
|
||||
};
|
||||
|
||||
TableSummary.propTypes = {
|
||||
/**
|
||||
* An array of objects with `label` & `value` properties, which display on a single line.
|
||||
*/
|
||||
data: PropTypes.array,
|
||||
};
|
||||
|
||||
export default TableSummary;
|
||||
|
||||
/**
|
|
@ -1,491 +0,0 @@
|
|||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __, sprintf } from '@wordpress/i18n';
|
||||
import {
|
||||
createElement,
|
||||
Component,
|
||||
createRef,
|
||||
Fragment,
|
||||
} from '@wordpress/element';
|
||||
import classnames from 'classnames';
|
||||
import { Button } from '@wordpress/components';
|
||||
import { find, get, noop } from 'lodash';
|
||||
import PropTypes from 'prop-types';
|
||||
import { withInstanceId } from '@wordpress/compose';
|
||||
import { Icon, chevronUp, chevronDown } from '@wordpress/icons';
|
||||
import deprecated from '@wordpress/deprecated';
|
||||
|
||||
const ASC = 'asc';
|
||||
const DESC = 'desc';
|
||||
|
||||
const getDisplay = ( cell ) => cell.display || null;
|
||||
|
||||
/**
|
||||
* A table component, without the Card wrapper. This is a basic table display, sortable, but no default filtering.
|
||||
*
|
||||
* Row data should be passed to the component as a list of arrays, where each array is a row in the table.
|
||||
* Headers are passed in separately as an array of objects with column-related properties. For example,
|
||||
* this data would render the following table.
|
||||
*
|
||||
* ```js
|
||||
* const headers = [ { label: 'Month' }, { label: 'Orders' }, { label: 'Revenue' } ];
|
||||
* const rows = [
|
||||
* [
|
||||
* { display: 'January', value: 1 },
|
||||
* { display: 10, value: 10 },
|
||||
* { display: '$530.00', value: 530 },
|
||||
* ],
|
||||
* [
|
||||
* { display: 'February', value: 2 },
|
||||
* { display: 13, value: 13 },
|
||||
* { display: '$675.00', value: 675 },
|
||||
* ],
|
||||
* [
|
||||
* { display: 'March', value: 3 },
|
||||
* { display: 9, value: 9 },
|
||||
* { display: '$460.00', value: 460 },
|
||||
* ],
|
||||
* ]
|
||||
* ```
|
||||
*
|
||||
* | Month | Orders | Revenue |
|
||||
* | ---------|--------|---------|
|
||||
* | January | 10 | $530.00 |
|
||||
* | February | 13 | $675.00 |
|
||||
* | March | 9 | $460.00 |
|
||||
*/
|
||||
class Table extends Component {
|
||||
constructor( props ) {
|
||||
super( props );
|
||||
this.state = {
|
||||
tabIndex: null,
|
||||
isScrollableRight: false,
|
||||
isScrollableLeft: false,
|
||||
};
|
||||
this.container = createRef();
|
||||
this.sortBy = this.sortBy.bind( this );
|
||||
this.updateTableShadow = this.updateTableShadow.bind( this );
|
||||
this.getRowKey = this.getRowKey.bind( this );
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const { scrollWidth, clientWidth } = this.container.current;
|
||||
const scrollable = scrollWidth > clientWidth;
|
||||
/* eslint-disable react/no-did-mount-set-state */
|
||||
this.setState( {
|
||||
tabIndex: scrollable ? '0' : null,
|
||||
} );
|
||||
/* eslint-enable react/no-did-mount-set-state */
|
||||
this.updateTableShadow();
|
||||
window.addEventListener( 'resize', this.updateTableShadow );
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
this.updateTableShadow();
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
window.removeEventListener( 'resize', this.updateTableShadow );
|
||||
}
|
||||
|
||||
sortBy( key ) {
|
||||
const { headers, query } = this.props;
|
||||
return () => {
|
||||
const currentKey =
|
||||
query.orderby ||
|
||||
get( find( headers, { defaultSort: true } ), 'key', false );
|
||||
const currentDir =
|
||||
query.order ||
|
||||
get(
|
||||
find( headers, { key: currentKey } ),
|
||||
'defaultOrder',
|
||||
DESC
|
||||
);
|
||||
let dir = DESC;
|
||||
if ( key === currentKey ) {
|
||||
dir = DESC === currentDir ? ASC : DESC;
|
||||
}
|
||||
this.props.onSort( key, dir );
|
||||
};
|
||||
}
|
||||
|
||||
updateTableShadow() {
|
||||
const table = this.container.current;
|
||||
const { isScrollableRight, isScrollableLeft } = this.state;
|
||||
|
||||
const scrolledToEnd =
|
||||
table.scrollWidth - table.scrollLeft <= table.offsetWidth;
|
||||
if ( scrolledToEnd && isScrollableRight ) {
|
||||
this.setState( { isScrollableRight: false } );
|
||||
} else if ( ! scrolledToEnd && ! this.state.isScrollableRight ) {
|
||||
this.setState( { isScrollableRight: true } );
|
||||
}
|
||||
|
||||
const scrolledToStart = table.scrollLeft <= 0;
|
||||
if ( scrolledToStart && isScrollableLeft ) {
|
||||
this.setState( { isScrollableLeft: false } );
|
||||
} else if ( ! scrolledToStart && ! isScrollableLeft ) {
|
||||
this.setState( { isScrollableLeft: true } );
|
||||
}
|
||||
}
|
||||
|
||||
getRowKey( row, index ) {
|
||||
if ( this.props.rowKey && typeof this.props.rowKey === 'function' ) {
|
||||
return this.props.rowKey( row, index );
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
ariaHidden,
|
||||
caption,
|
||||
className,
|
||||
classNames,
|
||||
headers,
|
||||
instanceId,
|
||||
query,
|
||||
rowHeader,
|
||||
rows,
|
||||
emptyMessage,
|
||||
} = this.props;
|
||||
const { isScrollableRight, isScrollableLeft, tabIndex } = this.state;
|
||||
|
||||
if ( classNames ) {
|
||||
deprecated( `Table component's classNames prop`, {
|
||||
since: '11.1.0',
|
||||
version: '12.0.0',
|
||||
alternative: 'className',
|
||||
plugin: '@woocommerce/components',
|
||||
} );
|
||||
}
|
||||
|
||||
const classes = classnames(
|
||||
'woocommerce-table__table',
|
||||
classNames,
|
||||
className,
|
||||
{
|
||||
'is-scrollable-right': isScrollableRight,
|
||||
'is-scrollable-left': isScrollableLeft,
|
||||
}
|
||||
);
|
||||
const sortedBy =
|
||||
query.orderby ||
|
||||
get( find( headers, { defaultSort: true } ), 'key', false );
|
||||
const sortDir =
|
||||
query.order ||
|
||||
get( find( headers, { key: sortedBy } ), 'defaultOrder', DESC );
|
||||
const hasData = !! rows.length;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={ classes }
|
||||
ref={ this.container }
|
||||
tabIndex={ tabIndex }
|
||||
aria-hidden={ ariaHidden }
|
||||
aria-labelledby={ `caption-${ instanceId }` }
|
||||
role="group"
|
||||
onScroll={ this.updateTableShadow }
|
||||
>
|
||||
<table>
|
||||
<caption
|
||||
id={ `caption-${ instanceId }` }
|
||||
className="woocommerce-table__caption screen-reader-text"
|
||||
>
|
||||
{ caption }
|
||||
{ tabIndex === '0' && (
|
||||
<small>
|
||||
{ __( '(scroll to see more)', 'woocommerce' ) }
|
||||
</small>
|
||||
) }
|
||||
</caption>
|
||||
<tbody>
|
||||
<tr>
|
||||
{ headers.map( ( header, i ) => {
|
||||
const {
|
||||
cellClassName,
|
||||
isLeftAligned,
|
||||
isSortable,
|
||||
isNumeric,
|
||||
key,
|
||||
label,
|
||||
screenReaderLabel,
|
||||
} = header;
|
||||
const labelId = `header-${ instanceId }-${ i }`;
|
||||
const thProps = {
|
||||
className: classnames(
|
||||
'woocommerce-table__header',
|
||||
cellClassName,
|
||||
{
|
||||
'is-left-aligned':
|
||||
isLeftAligned || ! isNumeric,
|
||||
'is-sortable': isSortable,
|
||||
'is-sorted': sortedBy === key,
|
||||
'is-numeric': isNumeric,
|
||||
}
|
||||
),
|
||||
};
|
||||
if ( isSortable ) {
|
||||
thProps[ 'aria-sort' ] = 'none';
|
||||
if ( sortedBy === key ) {
|
||||
thProps[ 'aria-sort' ] =
|
||||
sortDir === ASC
|
||||
? 'ascending'
|
||||
: 'descending';
|
||||
}
|
||||
}
|
||||
// We only sort by ascending if the col is already sorted descending
|
||||
const iconLabel =
|
||||
sortedBy === key && sortDir !== ASC
|
||||
? sprintf(
|
||||
__(
|
||||
'Sort by %s in ascending order',
|
||||
'woocommerce'
|
||||
),
|
||||
screenReaderLabel || label
|
||||
)
|
||||
: sprintf(
|
||||
__(
|
||||
'Sort by %s in descending order',
|
||||
'woocommerce'
|
||||
),
|
||||
screenReaderLabel || label
|
||||
);
|
||||
|
||||
const textLabel = (
|
||||
<Fragment>
|
||||
<span
|
||||
aria-hidden={ Boolean(
|
||||
screenReaderLabel
|
||||
) }
|
||||
>
|
||||
{ label }
|
||||
</span>
|
||||
{ screenReaderLabel && (
|
||||
<span className="screen-reader-text">
|
||||
{ screenReaderLabel }
|
||||
</span>
|
||||
) }
|
||||
</Fragment>
|
||||
);
|
||||
|
||||
return (
|
||||
<th
|
||||
role="columnheader"
|
||||
scope="col"
|
||||
key={ header.key || i }
|
||||
{ ...thProps }
|
||||
>
|
||||
{ isSortable ? (
|
||||
<Fragment>
|
||||
<Button
|
||||
aria-describedby={ labelId }
|
||||
onClick={
|
||||
hasData
|
||||
? this.sortBy( key )
|
||||
: noop
|
||||
}
|
||||
>
|
||||
{ sortedBy === key &&
|
||||
sortDir === ASC ? (
|
||||
<Icon
|
||||
icon={ chevronUp }
|
||||
/>
|
||||
) : (
|
||||
<Icon
|
||||
icon={ chevronDown }
|
||||
/>
|
||||
) }
|
||||
{ textLabel }
|
||||
</Button>
|
||||
<span
|
||||
className="screen-reader-text"
|
||||
id={ labelId }
|
||||
>
|
||||
{ iconLabel }
|
||||
</span>
|
||||
</Fragment>
|
||||
) : (
|
||||
textLabel
|
||||
) }
|
||||
</th>
|
||||
);
|
||||
} ) }
|
||||
</tr>
|
||||
{ hasData ? (
|
||||
rows.map( ( row, i ) => (
|
||||
<tr key={ this.getRowKey( row, i ) }>
|
||||
{ row.map( ( cell, j ) => {
|
||||
const {
|
||||
cellClassName,
|
||||
isLeftAligned,
|
||||
isNumeric,
|
||||
} = headers[ j ];
|
||||
const isHeader = rowHeader === j;
|
||||
const Cell = isHeader ? 'th' : 'td';
|
||||
const cellClasses = classnames(
|
||||
'woocommerce-table__item',
|
||||
cellClassName,
|
||||
{
|
||||
'is-left-aligned':
|
||||
isLeftAligned ||
|
||||
! isNumeric,
|
||||
'is-numeric': isNumeric,
|
||||
'is-sorted':
|
||||
sortedBy ===
|
||||
headers[ j ].key,
|
||||
}
|
||||
);
|
||||
const cellKey =
|
||||
this.getRowKey(
|
||||
row,
|
||||
i
|
||||
).toString() + j;
|
||||
return (
|
||||
<Cell
|
||||
scope={
|
||||
isHeader ? 'row' : null
|
||||
}
|
||||
key={ cellKey }
|
||||
className={ cellClasses }
|
||||
>
|
||||
{ getDisplay( cell ) }
|
||||
</Cell>
|
||||
);
|
||||
} ) }
|
||||
</tr>
|
||||
) )
|
||||
) : (
|
||||
<tr>
|
||||
<td
|
||||
className="woocommerce-table__empty-item"
|
||||
colSpan={ headers.length }
|
||||
>
|
||||
{ emptyMessage ??
|
||||
__(
|
||||
'No data to display',
|
||||
'woocommerce'
|
||||
) }
|
||||
</td>
|
||||
</tr>
|
||||
) }
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Table.propTypes = {
|
||||
/**
|
||||
* Controls whether this component is hidden from screen readers. Used by the loading state, before there is data to read.
|
||||
* Don't use this on real tables unless the table data is loaded elsewhere on the page.
|
||||
*/
|
||||
ariaHidden: PropTypes.bool,
|
||||
/**
|
||||
* A label for the content in this table
|
||||
*/
|
||||
caption: PropTypes.string.isRequired,
|
||||
/**
|
||||
* Additional CSS classes.
|
||||
*/
|
||||
className: PropTypes.string,
|
||||
/**
|
||||
* An array of column headers, as objects.
|
||||
*/
|
||||
headers: PropTypes.arrayOf(
|
||||
PropTypes.shape( {
|
||||
/**
|
||||
* Boolean, true if this column is the default for sorting. Only one column should have this set.
|
||||
*/
|
||||
defaultSort: PropTypes.bool,
|
||||
/**
|
||||
* String, asc|desc if this column is the default for sorting. Only one column should have this set.
|
||||
*/
|
||||
defaultOrder: PropTypes.string,
|
||||
/**
|
||||
* Boolean, true if this column should be aligned to the left.
|
||||
*/
|
||||
isLeftAligned: PropTypes.bool,
|
||||
/**
|
||||
* Boolean, true if this column is a number value.
|
||||
*/
|
||||
isNumeric: PropTypes.bool,
|
||||
/**
|
||||
* Boolean, true if this column is sortable.
|
||||
*/
|
||||
isSortable: PropTypes.bool,
|
||||
/**
|
||||
* The API parameter name for this column, passed to `orderby` when sorting via API.
|
||||
*/
|
||||
key: PropTypes.string,
|
||||
/**
|
||||
* The display label for this column.
|
||||
*/
|
||||
label: PropTypes.node,
|
||||
/**
|
||||
* Boolean, true if this column should always display in the table (not shown in toggle-able list).
|
||||
*/
|
||||
required: PropTypes.bool,
|
||||
/**
|
||||
* The label used for screen readers for this column.
|
||||
*/
|
||||
screenReaderLabel: PropTypes.string,
|
||||
} )
|
||||
),
|
||||
/**
|
||||
* A function called when sortable table headers are clicked, gets the `header.key` as argument.
|
||||
*/
|
||||
onSort: PropTypes.func,
|
||||
/**
|
||||
* The query string represented in object form
|
||||
*/
|
||||
query: PropTypes.object,
|
||||
/**
|
||||
* An array of arrays of display/value object pairs.
|
||||
*/
|
||||
rows: PropTypes.arrayOf(
|
||||
PropTypes.arrayOf(
|
||||
PropTypes.shape( {
|
||||
/**
|
||||
* Display value, used for rendering- strings or elements are best here.
|
||||
*/
|
||||
display: PropTypes.node,
|
||||
/**
|
||||
* "Real" value used for sorting, and should be a string or number. A column with `false` value will not be sortable.
|
||||
*/
|
||||
value: PropTypes.oneOfType( [
|
||||
PropTypes.string,
|
||||
PropTypes.number,
|
||||
PropTypes.bool,
|
||||
] ),
|
||||
} )
|
||||
)
|
||||
).isRequired,
|
||||
/**
|
||||
* Which column should be the row header, defaults to the first item (`0`) (but could be set to `1`, if the first col
|
||||
* is checkboxes, for example). Set to false to disable row headers.
|
||||
*/
|
||||
rowHeader: PropTypes.oneOfType( [ PropTypes.number, PropTypes.bool ] ),
|
||||
/**
|
||||
* The rowKey used for the key value on each row, a function that returns the key.
|
||||
* Defaults to index.
|
||||
*/
|
||||
rowKey: PropTypes.func,
|
||||
/**
|
||||
* Customize the message to show when there are no rows in the table.
|
||||
*/
|
||||
emptyMessage: PropTypes.string,
|
||||
};
|
||||
|
||||
Table.defaultProps = {
|
||||
ariaHidden: false,
|
||||
headers: [],
|
||||
onSort: noop,
|
||||
query: {},
|
||||
rowHeader: 0,
|
||||
emptyMessage: undefined,
|
||||
};
|
||||
|
||||
export default withInstanceId( Table );
|
|
@ -0,0 +1,374 @@
|
|||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __, sprintf } from '@wordpress/i18n';
|
||||
import {
|
||||
createElement,
|
||||
useRef,
|
||||
Fragment,
|
||||
useState,
|
||||
useEffect,
|
||||
} from '@wordpress/element';
|
||||
import classnames from 'classnames';
|
||||
import { Button } from '@wordpress/components';
|
||||
import { find, get, noop } from 'lodash';
|
||||
import { withInstanceId } from '@wordpress/compose';
|
||||
import { Icon, chevronUp, chevronDown } from '@wordpress/icons';
|
||||
import deprecated from '@wordpress/deprecated';
|
||||
import React from 'react';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { TableRow, TableProps } from './types';
|
||||
|
||||
const ASC = 'asc';
|
||||
const DESC = 'desc';
|
||||
|
||||
const getDisplay = ( cell: { display?: React.ReactNode } ) =>
|
||||
cell.display || null;
|
||||
|
||||
/**
|
||||
* A table component, without the Card wrapper. This is a basic table display, sortable, but no default filtering.
|
||||
*
|
||||
* Row data should be passed to the component as a list of arrays, where each array is a row in the table.
|
||||
* Headers are passed in separately as an array of objects with column-related properties. For example,
|
||||
* this data would render the following table.
|
||||
*
|
||||
* ```js
|
||||
* const headers = [ { label: 'Month' }, { label: 'Orders' }, { label: 'Revenue' } ];
|
||||
* const rows = [
|
||||
* [
|
||||
* { display: 'January', value: 1 },
|
||||
* { display: 10, value: 10 },
|
||||
* { display: '$530.00', value: 530 },
|
||||
* ],
|
||||
* [
|
||||
* { display: 'February', value: 2 },
|
||||
* { display: 13, value: 13 },
|
||||
* { display: '$675.00', value: 675 },
|
||||
* ],
|
||||
* [
|
||||
* { display: 'March', value: 3 },
|
||||
* { display: 9, value: 9 },
|
||||
* { display: '$460.00', value: 460 },
|
||||
* ],
|
||||
* ]
|
||||
* ```
|
||||
*
|
||||
* | Month | Orders | Revenue |
|
||||
* | ---------|--------|---------|
|
||||
* | January | 10 | $530.00 |
|
||||
* | February | 13 | $675.00 |
|
||||
* | March | 9 | $460.00 |
|
||||
*/
|
||||
|
||||
const Table: React.VFC< TableProps > = ( {
|
||||
instanceId,
|
||||
headers = [],
|
||||
rows = [],
|
||||
ariaHidden,
|
||||
caption,
|
||||
className,
|
||||
onSort = ( f ) => f,
|
||||
query = {},
|
||||
rowHeader,
|
||||
rowKey,
|
||||
emptyMessage,
|
||||
...props
|
||||
} ) => {
|
||||
const { classNames } = props;
|
||||
const [ tabIndex, setTabIndex ] = useState< number | undefined >(
|
||||
undefined
|
||||
);
|
||||
const [ isScrollableRight, setIsScrollableRight ] = useState( false );
|
||||
const [ isScrollableLeft, setIsScrollableLeft ] = useState( false );
|
||||
|
||||
const container = useRef< HTMLDivElement >( null );
|
||||
|
||||
if ( classNames ) {
|
||||
deprecated( `Table component's classNames prop`, {
|
||||
since: '11.1.0',
|
||||
version: '12.0.0',
|
||||
alternative: 'className',
|
||||
plugin: '@woocommerce/components',
|
||||
} );
|
||||
}
|
||||
|
||||
const classes = classnames(
|
||||
'woocommerce-table__table',
|
||||
classNames,
|
||||
className,
|
||||
{
|
||||
'is-scrollable-right': isScrollableRight,
|
||||
'is-scrollable-left': isScrollableLeft,
|
||||
}
|
||||
);
|
||||
|
||||
const sortBy = ( key: string ) => {
|
||||
return () => {
|
||||
const currentKey =
|
||||
query.orderby ||
|
||||
get( find( headers, { defaultSort: true } ), 'key', false );
|
||||
const currentDir =
|
||||
query.order ||
|
||||
get(
|
||||
find( headers, { key: currentKey } ),
|
||||
'defaultOrder',
|
||||
DESC
|
||||
);
|
||||
let dir = DESC;
|
||||
if ( key === currentKey ) {
|
||||
dir = DESC === currentDir ? ASC : DESC;
|
||||
}
|
||||
onSort( key, dir );
|
||||
};
|
||||
};
|
||||
|
||||
const getRowKey = ( row: TableRow[], index: number ) => {
|
||||
if ( rowKey && typeof rowKey === 'function' ) {
|
||||
return rowKey( row, index );
|
||||
}
|
||||
return index;
|
||||
};
|
||||
|
||||
const updateTableShadow = () => {
|
||||
const table = container.current;
|
||||
|
||||
if ( table?.scrollWidth && table?.scrollHeight && table?.offsetWidth ) {
|
||||
const scrolledToEnd =
|
||||
table?.scrollWidth - table?.scrollLeft <= table?.offsetWidth;
|
||||
if ( scrolledToEnd && isScrollableRight ) {
|
||||
setIsScrollableRight( false );
|
||||
} else if ( ! scrolledToEnd && ! isScrollableRight ) {
|
||||
setIsScrollableRight( true );
|
||||
}
|
||||
}
|
||||
|
||||
if ( table?.scrollLeft ) {
|
||||
const scrolledToStart = table?.scrollLeft <= 0;
|
||||
if ( scrolledToStart && isScrollableLeft ) {
|
||||
setIsScrollableLeft( false );
|
||||
} else if ( ! scrolledToStart && ! isScrollableLeft ) {
|
||||
setIsScrollableLeft( true );
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const sortedBy =
|
||||
query.orderby ||
|
||||
get( find( headers, { defaultSort: true } ), 'key', false );
|
||||
const sortDir =
|
||||
query.order ||
|
||||
get( find( headers, { key: sortedBy } ), 'defaultOrder', DESC );
|
||||
const hasData = !! rows.length;
|
||||
|
||||
useEffect( () => {
|
||||
const scrollWidth = container.current?.scrollWidth;
|
||||
const clientWidth = container.current?.clientWidth;
|
||||
|
||||
if ( scrollWidth === undefined || clientWidth === undefined ) {
|
||||
return;
|
||||
}
|
||||
|
||||
const scrollable = scrollWidth > clientWidth;
|
||||
setTabIndex( scrollable ? 0 : undefined );
|
||||
updateTableShadow();
|
||||
window.addEventListener( 'resize', updateTableShadow );
|
||||
|
||||
return () => {
|
||||
window.removeEventListener( 'resize', updateTableShadow );
|
||||
};
|
||||
}, [] );
|
||||
|
||||
useEffect( updateTableShadow, [ headers, rows, emptyMessage ] );
|
||||
|
||||
return (
|
||||
<div
|
||||
className={ classes }
|
||||
ref={ container }
|
||||
tabIndex={ tabIndex }
|
||||
aria-hidden={ ariaHidden }
|
||||
aria-labelledby={ `caption-${ instanceId }` }
|
||||
role="group"
|
||||
onScroll={ updateTableShadow }
|
||||
>
|
||||
<table>
|
||||
<caption
|
||||
id={ `caption-${ instanceId }` }
|
||||
className="woocommerce-table__caption screen-reader-text"
|
||||
>
|
||||
{ caption }
|
||||
{ tabIndex === 0 && (
|
||||
<small>
|
||||
{ __( '(scroll to see more)', 'woocommerce' ) }
|
||||
</small>
|
||||
) }
|
||||
</caption>
|
||||
<tbody>
|
||||
<tr>
|
||||
{ headers.map( ( header, i ) => {
|
||||
const {
|
||||
cellClassName,
|
||||
isLeftAligned,
|
||||
isSortable,
|
||||
isNumeric,
|
||||
key,
|
||||
label,
|
||||
screenReaderLabel,
|
||||
} = header;
|
||||
const labelId = `header-${ instanceId }-${ i }`;
|
||||
const thProps: { [ key: string ]: string } = {
|
||||
className: classnames(
|
||||
'woocommerce-table__header',
|
||||
cellClassName,
|
||||
{
|
||||
'is-left-aligned':
|
||||
isLeftAligned || ! isNumeric,
|
||||
'is-sortable': isSortable,
|
||||
'is-sorted': sortedBy === key,
|
||||
'is-numeric': isNumeric,
|
||||
}
|
||||
),
|
||||
};
|
||||
if ( isSortable ) {
|
||||
thProps[ 'aria-sort' ] = 'none';
|
||||
if ( sortedBy === key ) {
|
||||
thProps[ 'aria-sort' ] =
|
||||
sortDir === ASC
|
||||
? 'ascending'
|
||||
: 'descending';
|
||||
}
|
||||
}
|
||||
// We only sort by ascending if the col is already sorted descending
|
||||
const iconLabel =
|
||||
sortedBy === key && sortDir !== ASC
|
||||
? sprintf(
|
||||
__(
|
||||
'Sort by %s in ascending order',
|
||||
'woocommerce'
|
||||
),
|
||||
screenReaderLabel || label
|
||||
)
|
||||
: sprintf(
|
||||
__(
|
||||
'Sort by %s in descending order',
|
||||
'woocommerce'
|
||||
),
|
||||
screenReaderLabel || label
|
||||
);
|
||||
|
||||
const textLabel = (
|
||||
<Fragment>
|
||||
<span
|
||||
aria-hidden={ Boolean(
|
||||
screenReaderLabel
|
||||
) }
|
||||
>
|
||||
{ label }
|
||||
</span>
|
||||
{ screenReaderLabel && (
|
||||
<span className="screen-reader-text">
|
||||
{ screenReaderLabel }
|
||||
</span>
|
||||
) }
|
||||
</Fragment>
|
||||
);
|
||||
|
||||
return (
|
||||
<th
|
||||
role="columnheader"
|
||||
scope="col"
|
||||
key={ header.key || i }
|
||||
{ ...thProps }
|
||||
>
|
||||
{ isSortable ? (
|
||||
<Fragment>
|
||||
<Button
|
||||
aria-describedby={ labelId }
|
||||
onClick={
|
||||
hasData
|
||||
? sortBy( key )
|
||||
: noop
|
||||
}
|
||||
>
|
||||
{ sortedBy === key &&
|
||||
sortDir === ASC ? (
|
||||
<Icon icon={ chevronUp } />
|
||||
) : (
|
||||
<Icon
|
||||
icon={ chevronDown }
|
||||
/>
|
||||
) }
|
||||
{ textLabel }
|
||||
</Button>
|
||||
<span
|
||||
className="screen-reader-text"
|
||||
id={ labelId }
|
||||
>
|
||||
{ iconLabel }
|
||||
</span>
|
||||
</Fragment>
|
||||
) : (
|
||||
textLabel
|
||||
) }
|
||||
</th>
|
||||
);
|
||||
} ) }
|
||||
</tr>
|
||||
{ hasData ? (
|
||||
rows.map( ( row, i ) => (
|
||||
<tr key={ getRowKey( row, i ) }>
|
||||
{ row.map( ( cell, j ) => {
|
||||
const {
|
||||
cellClassName,
|
||||
isLeftAligned,
|
||||
isNumeric,
|
||||
} = headers[ j ];
|
||||
const isHeader = rowHeader === j;
|
||||
const Cell = isHeader ? 'th' : 'td';
|
||||
const cellClasses = classnames(
|
||||
'woocommerce-table__item',
|
||||
cellClassName,
|
||||
{
|
||||
'is-left-aligned':
|
||||
isLeftAligned || ! isNumeric,
|
||||
'is-numeric': isNumeric,
|
||||
'is-sorted':
|
||||
sortedBy === headers[ j ].key,
|
||||
}
|
||||
);
|
||||
const cellKey =
|
||||
getRowKey( row, i ).toString() + j;
|
||||
return (
|
||||
<Cell
|
||||
scope={
|
||||
isHeader ? 'row' : undefined
|
||||
}
|
||||
key={ cellKey }
|
||||
className={ cellClasses }
|
||||
>
|
||||
{ getDisplay( cell ) }
|
||||
</Cell>
|
||||
);
|
||||
} ) }
|
||||
</tr>
|
||||
) )
|
||||
) : (
|
||||
<tr>
|
||||
<td
|
||||
className="woocommerce-table__empty-item"
|
||||
colSpan={ headers.length }
|
||||
>
|
||||
{ emptyMessage ??
|
||||
__( 'No data to display', 'woocommerce' ) }
|
||||
</td>
|
||||
</tr>
|
||||
) }
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default withInstanceId( Table );
|
|
@ -0,0 +1,189 @@
|
|||
export type QueryProps = {
|
||||
orderby?: string;
|
||||
order?: string;
|
||||
page?: number;
|
||||
per_page?: number;
|
||||
/**
|
||||
* Allowing string for backward compatibility
|
||||
*/
|
||||
paged?: number | string;
|
||||
};
|
||||
|
||||
export type TableHeader = {
|
||||
/**
|
||||
* Boolean, true if this column is the default for sorting. Only one column should have this set.
|
||||
*/
|
||||
defaultSort?: boolean;
|
||||
/**
|
||||
* String, asc|desc if this column is the default for sorting. Only one column should have this set.
|
||||
*/
|
||||
defaultOrder?: string;
|
||||
/**
|
||||
* Boolean, true if this column should be aligned to the left.
|
||||
*/
|
||||
isLeftAligned?: boolean;
|
||||
/**
|
||||
* Boolean, true if this column is a number value.
|
||||
*/
|
||||
isNumeric?: boolean;
|
||||
/**
|
||||
* Boolean, true if this column is sortable.
|
||||
*/
|
||||
isSortable?: boolean;
|
||||
/**
|
||||
* The API parameter name for this column, passed to `orderby` when sorting via API.
|
||||
*/
|
||||
key: string;
|
||||
/**
|
||||
* The display label for this column.
|
||||
*/
|
||||
label?: React.ReactNode;
|
||||
/**
|
||||
* Boolean, true if this column should always display in the table (not shown in toggle-able list).
|
||||
*/
|
||||
required?: boolean;
|
||||
/**
|
||||
* The label used for screen readers for this column.
|
||||
*/
|
||||
screenReaderLabel?: string;
|
||||
/**
|
||||
* Additional classname for the header cell
|
||||
*/
|
||||
cellClassName?: string;
|
||||
/**
|
||||
* Boolean value to control visibility of a header
|
||||
*/
|
||||
visible?: boolean;
|
||||
};
|
||||
|
||||
export type TableRow = {
|
||||
/**
|
||||
* Display value, used for rendering- strings or elements are best here.
|
||||
*/
|
||||
display?: React.ReactNode;
|
||||
/**
|
||||
* "Real" value used for sorting, and should be a string or number. A column with `false` value will not be sortable.
|
||||
*/
|
||||
value?: string | number | boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Props shared between TableProps and TableCardProps.
|
||||
*/
|
||||
type CommonTableProps = {
|
||||
/**
|
||||
* The rowKey used for the key value on each row, a function that returns the key.
|
||||
* Defaults to index.
|
||||
*/
|
||||
rowKey?: ( row: TableRow[], index: number ) => number;
|
||||
/**
|
||||
* Customize the message to show when there are no rows in the table.
|
||||
*/
|
||||
emptyMessage?: string;
|
||||
/**
|
||||
* The query string represented in object form
|
||||
*/
|
||||
query?: QueryProps;
|
||||
/**
|
||||
* Which column should be the row header, defaults to the first item (`0`) (but could be set to `1`, if the first col
|
||||
* is checkboxes, for example). Set to false to disable row headers.
|
||||
*/
|
||||
rowHeader?: number | false;
|
||||
/**
|
||||
* An array of column headers (see `Table` props).
|
||||
*/
|
||||
headers?: Array< TableHeader >;
|
||||
/**
|
||||
* An array of arrays of display/value object pairs (see `Table` props).
|
||||
*/
|
||||
rows?: Array< Array< TableRow > >;
|
||||
/**
|
||||
* Additional CSS classes.
|
||||
*/
|
||||
className?: string;
|
||||
/**
|
||||
* A function called when sortable table headers are clicked, gets the `header.key` as argument.
|
||||
*/
|
||||
onSort?: ( key: string, direction: string ) => void;
|
||||
};
|
||||
|
||||
export type TableProps = CommonTableProps & {
|
||||
/** A unique ID for this instance of the component. This is automatically generated by withInstanceId. */
|
||||
instanceId: number | string;
|
||||
/**
|
||||
* Controls whether this component is hidden from screen readers. Used by the loading state, before there is data to read.
|
||||
* Don't use this on real tables unless the table data is loaded elsewhere on the page.
|
||||
*/
|
||||
ariaHidden?: boolean;
|
||||
/**
|
||||
* A label for the content in this table
|
||||
*/
|
||||
caption?: string;
|
||||
/**
|
||||
* Additional classnames
|
||||
*/
|
||||
classNames?: string | Record< string, string >;
|
||||
};
|
||||
|
||||
export type TableSummaryProps = {
|
||||
// An array of objects with `label` & `value` properties, which display on a single line.
|
||||
data: Array< {
|
||||
label: string;
|
||||
value: boolean | number | string | React.ReactNode;
|
||||
} >;
|
||||
};
|
||||
|
||||
export type TableCardProps = CommonTableProps & {
|
||||
/**
|
||||
* An array of custom React nodes that is placed at the top right corner.
|
||||
*/
|
||||
actions?: Array< React.ReactNode >;
|
||||
/**
|
||||
* If a search is provided in actions and should reorder actions on mobile.
|
||||
*/
|
||||
hasSearch?: boolean;
|
||||
/**
|
||||
* A list of IDs, matching to the row list so that ids[ 0 ] contains the object ID for the object displayed in row[ 0 ].
|
||||
*/
|
||||
ids?: Array< number >;
|
||||
/**
|
||||
* Defines if the table contents are loading.
|
||||
* It will display `TablePlaceholder` component instead of `Table` if that's the case.
|
||||
*/
|
||||
isLoading?: boolean;
|
||||
/**
|
||||
* A function which returns a callback function to update the query string for a given `param`.
|
||||
*/
|
||||
// Allowing any for backward compatibitlity
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
onQueryChange?: ( param: string ) => ( ...props: any ) => void;
|
||||
/**
|
||||
* A function which returns a callback function which is called upon the user changing the visiblity of columns.
|
||||
*/
|
||||
onColumnsChange?: ( showCols: Array< string >, key?: string ) => void;
|
||||
/**
|
||||
* A callback function that is invoked when the current page is changed.
|
||||
*/
|
||||
onPageChange?: ( newPage: number, direction?: 'previous' | 'next' ) => void;
|
||||
/**
|
||||
* The total number of rows to display per page.
|
||||
*/
|
||||
rowsPerPage: number;
|
||||
/**
|
||||
* Boolean to determine whether or not ellipsis menu is shown.
|
||||
*/
|
||||
showMenu?: boolean;
|
||||
/**
|
||||
* An array of objects with `label` & `value` properties, which display in a line under the table.
|
||||
* Optional, can be left off to show no summary.
|
||||
*/
|
||||
summary?: TableSummaryProps[ 'data' ];
|
||||
/**
|
||||
* The title used in the card header, also used as the caption for the content in this table.
|
||||
*/
|
||||
title: string;
|
||||
/**
|
||||
* The total number of rows (across all pages).
|
||||
*/
|
||||
totalRows: number;
|
||||
};
|
|
@ -1,7 +1,7 @@
|
|||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import React, { isValidElement, Fragment } from 'react';
|
||||
import { isValidElement, Fragment } from 'react';
|
||||
import { Slot, Fill } from '@wordpress/components';
|
||||
import { cloneElement, createElement } from '@wordpress/element';
|
||||
|
||||
|
@ -13,15 +13,16 @@ import { cloneElement, createElement } from '@wordpress/element';
|
|||
* @param {Array} props - Fill props.
|
||||
* @return {Node} Node.
|
||||
*/
|
||||
function createOrderedChildren< T = Fill.Props >(
|
||||
function createOrderedChildren< T = Fill.Props, S = Record< string, unknown > >(
|
||||
children: React.ReactNode,
|
||||
order: number,
|
||||
props: T
|
||||
props: T,
|
||||
injectProps?: S
|
||||
) {
|
||||
if ( typeof children === 'function' ) {
|
||||
return cloneElement( children( props ), { order } );
|
||||
return cloneElement( children( props ), { order, ...injectProps } );
|
||||
} else if ( isValidElement( children ) ) {
|
||||
return cloneElement( children, { ...props, order } );
|
||||
return cloneElement( children, { ...props, order, ...injectProps } );
|
||||
}
|
||||
throw Error( 'Invalid children type' );
|
||||
}
|
||||
|
|
|
@ -9,6 +9,7 @@ import { createElement, Children } from '@wordpress/element';
|
|||
* Internal dependencies
|
||||
*/
|
||||
import { createOrderedChildren, sortFillsByOrder } from '../utils';
|
||||
import { useSlotContext, SlotContextHelpersType } from '../slot-context';
|
||||
|
||||
type WooProductFieldItemProps = {
|
||||
id: string;
|
||||
|
@ -23,36 +24,55 @@ type WooProductFieldSlotProps = {
|
|||
|
||||
export const WooProductFieldItem: React.FC< WooProductFieldItemProps > & {
|
||||
Slot: React.FC< Slot.Props & WooProductFieldSlotProps >;
|
||||
} = ( { children, order = 1, section } ) => (
|
||||
<Fill name={ `woocommerce_product_field_${ section }` }>
|
||||
{ ( fillProps: Fill.Props ) => {
|
||||
return createOrderedChildren< Fill.Props >(
|
||||
children,
|
||||
order,
|
||||
fillProps
|
||||
);
|
||||
} }
|
||||
</Fill>
|
||||
);
|
||||
} = ( { children, order = 20, section, id } ) => {
|
||||
const { registerFill, getFillHelpers } = useSlotContext();
|
||||
|
||||
WooProductFieldItem.Slot = ( { fillProps, section } ) => (
|
||||
<Slot
|
||||
name={ `woocommerce_product_field_${ section }` }
|
||||
fillProps={ fillProps }
|
||||
>
|
||||
{ ( fills ) => {
|
||||
if ( ! sortFillsByOrder ) {
|
||||
return null;
|
||||
}
|
||||
registerFill( id );
|
||||
|
||||
return Children.map(
|
||||
sortFillsByOrder( fills )?.props.children,
|
||||
( child ) => (
|
||||
<div className="woocommerce-product-form__field">
|
||||
{ child }
|
||||
</div>
|
||||
)
|
||||
);
|
||||
} }
|
||||
</Slot>
|
||||
);
|
||||
return (
|
||||
<Fill name={ `woocommerce_product_field_${ section }` }>
|
||||
{ ( fillProps: Fill.Props ) => {
|
||||
return createOrderedChildren<
|
||||
Fill.Props & SlotContextHelpersType,
|
||||
{ _id: string }
|
||||
>(
|
||||
children,
|
||||
order,
|
||||
{
|
||||
...fillProps,
|
||||
...getFillHelpers(),
|
||||
},
|
||||
{ _id: id }
|
||||
);
|
||||
} }
|
||||
</Fill>
|
||||
);
|
||||
};
|
||||
|
||||
WooProductFieldItem.Slot = ( { fillProps, section } ) => {
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
const { filterRegisteredFills } = useSlotContext();
|
||||
|
||||
return (
|
||||
<Slot
|
||||
name={ `woocommerce_product_field_${ section }` }
|
||||
fillProps={ fillProps }
|
||||
>
|
||||
{ ( fills ) => {
|
||||
if ( ! sortFillsByOrder ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Children.map(
|
||||
sortFillsByOrder( filterRegisteredFills( fills ) )?.props
|
||||
.children,
|
||||
( child ) => (
|
||||
<div className="woocommerce-product-form__field">
|
||||
{ child }
|
||||
</div>
|
||||
)
|
||||
);
|
||||
} }
|
||||
</Slot>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -23,7 +23,7 @@ type WooProductFieldSlotProps = {
|
|||
|
||||
export const WooProductSectionItem: React.FC< WooProductSectionItemProps > & {
|
||||
Slot: React.FC< Slot.Props & WooProductFieldSlotProps >;
|
||||
} = ( { children, order = 1, location } ) => (
|
||||
} = ( { children, order = 20, location } ) => (
|
||||
<Fill name={ `woocommerce_product_section_${ location }` }>
|
||||
{ ( fillProps: Fill.Props ) => {
|
||||
return createOrderedChildren< Fill.Props >(
|
||||
|
|
|
@ -0,0 +1,4 @@
|
|||
Significance: minor
|
||||
Type: add
|
||||
|
||||
Adding Product Form data store.
|
|
@ -0,0 +1,4 @@
|
|||
Significance: patch
|
||||
Type: tweak
|
||||
|
||||
Tweak the product form types and exports.
|
|
@ -0,0 +1,4 @@
|
|||
Significance: minor
|
||||
Type: add
|
||||
|
||||
Add ability to check CRUD dispatch action status
|
|
@ -1,13 +1,16 @@
|
|||
export enum TYPES {
|
||||
CREATE_ITEM_ERROR = 'CREATE_ITEM_ERROR',
|
||||
CREATE_ITEM_REQUEST = 'CREATE_ITEM_REQUEST',
|
||||
CREATE_ITEM_SUCCESS = 'CREATE_ITEM_SUCCESS',
|
||||
DELETE_ITEM_ERROR = 'DELETE_ITEM_ERROR',
|
||||
DELETE_ITEM_REQUEST = 'DELETE_ITEM_REQUEST',
|
||||
DELETE_ITEM_SUCCESS = 'DELETE_ITEM_SUCCESS',
|
||||
GET_ITEM_ERROR = 'GET_ITEM_ERROR',
|
||||
GET_ITEM_SUCCESS = 'GET_ITEM_SUCCESS',
|
||||
GET_ITEMS_ERROR = 'GET_ITEMS_ERROR',
|
||||
GET_ITEMS_SUCCESS = 'GET_ITEMS_SUCCESS',
|
||||
UPDATE_ITEM_ERROR = 'UPDATE_ITEM_ERROR',
|
||||
UPDATE_ITEM_REQUEST = 'UPDATE_ITEM_REQUEST',
|
||||
UPDATE_ITEM_SUCCESS = 'UPDATE_ITEM_SUCCESS',
|
||||
GET_ITEMS_TOTAL_COUNT_SUCCESS = 'GET_ITEMS_TOTAL_COUNT_SUCCESS',
|
||||
GET_ITEMS_TOTAL_COUNT_ERROR = 'GET_ITEMS_TOTAL_COUNT_ERROR',
|
||||
|
|
|
@ -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 {
|
||||
type: TYPES.CREATE_ITEM_SUCCESS as const,
|
||||
key,
|
||||
item,
|
||||
query,
|
||||
};
|
||||
}
|
||||
|
||||
export function deleteItemError( key: IdType, error: unknown ) {
|
||||
export function deleteItemError( key: IdType, error: unknown, force: boolean ) {
|
||||
return {
|
||||
type: TYPES.DELETE_ITEM_ERROR as const,
|
||||
key,
|
||||
error,
|
||||
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 {
|
||||
type: TYPES.UPDATE_ITEM_ERROR as const,
|
||||
key,
|
||||
error,
|
||||
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 {
|
||||
type: TYPES.UPDATE_ITEM_SUCCESS as const,
|
||||
key,
|
||||
item,
|
||||
query,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -138,6 +177,7 @@ export const createDispatchActions = ( {
|
|||
resourceName,
|
||||
}: ResolverOptions ) => {
|
||||
const createItem = function* ( query: Partial< ItemQuery > ) {
|
||||
yield createItemRequest( query );
|
||||
const urlParameters = getUrlParameters( namespace, query );
|
||||
|
||||
try {
|
||||
|
@ -151,7 +191,7 @@ export const createDispatchActions = ( {
|
|||
} );
|
||||
const { key } = parseId( item.id, urlParameters );
|
||||
|
||||
yield createItemSuccess( key, item );
|
||||
yield createItemSuccess( key, item, query );
|
||||
return item;
|
||||
} catch ( error ) {
|
||||
yield createItemError( query, error );
|
||||
|
@ -162,6 +202,7 @@ export const createDispatchActions = ( {
|
|||
const deleteItem = function* ( idQuery: IdQuery, force = true ) {
|
||||
const urlParameters = getUrlParameters( namespace, idQuery );
|
||||
const { id, key } = parseId( idQuery, urlParameters );
|
||||
yield deleteItemRequest( key, force );
|
||||
|
||||
try {
|
||||
const item: Item = yield apiFetch( {
|
||||
|
@ -176,7 +217,7 @@ export const createDispatchActions = ( {
|
|||
yield deleteItemSuccess( key, force, item );
|
||||
return item;
|
||||
} catch ( error ) {
|
||||
yield deleteItemError( key, error );
|
||||
yield deleteItemError( key, error, force );
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
@ -187,6 +228,7 @@ export const createDispatchActions = ( {
|
|||
) {
|
||||
const urlParameters = getUrlParameters( namespace, idQuery );
|
||||
const { id, key } = parseId( idQuery, urlParameters );
|
||||
yield updateItemRequest( key, query );
|
||||
|
||||
try {
|
||||
const item: Item = yield apiFetch( {
|
||||
|
@ -199,10 +241,10 @@ export const createDispatchActions = ( {
|
|||
data: query,
|
||||
} );
|
||||
|
||||
yield updateItemSuccess( key, item );
|
||||
yield updateItemSuccess( key, item, query );
|
||||
return item;
|
||||
} catch ( error ) {
|
||||
yield updateItemError( key, error );
|
||||
yield updateItemError( key, error, query );
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
@ -216,8 +258,10 @@ export const createDispatchActions = ( {
|
|||
|
||||
export type Actions = ReturnType<
|
||||
| typeof createItemError
|
||||
| typeof createItemRequest
|
||||
| typeof createItemSuccess
|
||||
| typeof deleteItemError
|
||||
| typeof deleteItemRequest
|
||||
| typeof deleteItemSuccess
|
||||
| typeof getItemError
|
||||
| typeof getItemSuccess
|
||||
|
@ -226,5 +270,6 @@ export type Actions = ReturnType<
|
|||
| typeof getItemsTotalCountSuccess
|
||||
| typeof getItemsTotalCountError
|
||||
| typeof updateItemError
|
||||
| typeof updateItemRequest
|
||||
| typeof updateItemSuccess
|
||||
>;
|
||||
|
|
|
@ -8,8 +8,8 @@ import { Reducer } from 'redux';
|
|||
*/
|
||||
import { Actions } from './actions';
|
||||
import CRUD_ACTIONS from './crud-actions';
|
||||
import { getKey } from './utils';
|
||||
import { getResourceName, getTotalCountResourceName } from '../utils';
|
||||
import { getKey, getRequestIdentifier } from './utils';
|
||||
import { getTotalCountResourceName } from '../utils';
|
||||
import { IdType, Item, ItemQuery } from './types';
|
||||
import { TYPES } from './action-types';
|
||||
|
||||
|
@ -24,6 +24,7 @@ export type ResourceState = {
|
|||
data: Data;
|
||||
itemsCount: Record< string, number >;
|
||||
errors: Record< string, unknown >;
|
||||
requesting: Record< string, boolean >;
|
||||
};
|
||||
|
||||
export const createReducer = () => {
|
||||
|
@ -33,19 +34,37 @@ export const createReducer = () => {
|
|||
data: {},
|
||||
itemsCount: {},
|
||||
errors: {},
|
||||
requesting: {},
|
||||
},
|
||||
payload
|
||||
) => {
|
||||
const itemData = state.data || {};
|
||||
|
||||
if ( payload && 'type' in payload ) {
|
||||
switch ( payload.type ) {
|
||||
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_ERROR:
|
||||
return {
|
||||
...state,
|
||||
errors: {
|
||||
...state.errors,
|
||||
[ getResourceName(
|
||||
[ getRequestIdentifier(
|
||||
payload.errorType,
|
||||
( payload.query || {} ) as ItemQuery
|
||||
) ]: payload.error,
|
||||
|
@ -64,9 +83,27 @@ export const createReducer = () => {
|
|||
};
|
||||
|
||||
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.UPDATE_ITEM_SUCCESS:
|
||||
const itemData = state.data || {};
|
||||
return {
|
||||
...state,
|
||||
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:
|
||||
const deleteItemSuccessRequestId = getRequestIdentifier(
|
||||
CRUD_ACTIONS.DELETE_ITEM,
|
||||
payload.key,
|
||||
payload.force
|
||||
);
|
||||
const itemKeys = Object.keys( state.data );
|
||||
const nextData = itemKeys.reduce< Data >(
|
||||
( items: Data, key: string ) => {
|
||||
|
@ -98,18 +161,57 @@ export const createReducer = () => {
|
|||
return {
|
||||
...state,
|
||||
data: nextData,
|
||||
requesting: {
|
||||
...state.requesting,
|
||||
[ deleteItemSuccessRequestId ]: false,
|
||||
},
|
||||
};
|
||||
|
||||
case TYPES.DELETE_ITEM_ERROR:
|
||||
case TYPES.GET_ITEM_ERROR:
|
||||
case TYPES.UPDATE_ITEM_ERROR:
|
||||
const deleteItemErrorRequestId = getRequestIdentifier(
|
||||
payload.errorType,
|
||||
payload.key,
|
||||
payload.force
|
||||
);
|
||||
return {
|
||||
...state,
|
||||
errors: {
|
||||
...state.errors,
|
||||
[ getResourceName( payload.errorType, {
|
||||
key: payload.key,
|
||||
} ) ]: payload.error,
|
||||
[ deleteItemErrorRequestId ]: 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;
|
||||
}, {} );
|
||||
|
||||
const itemQuery = getResourceName(
|
||||
const itemQuery = getRequestIdentifier(
|
||||
CRUD_ACTIONS.GET_ITEMS,
|
||||
( 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:
|
||||
return state;
|
||||
}
|
||||
|
|
|
@ -6,8 +6,15 @@ import createSelector from 'rememo';
|
|||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { applyNamespace, getUrlParameters, parseId } from './utils';
|
||||
import { getResourceName, getTotalCountResourceName } from '../utils';
|
||||
import {
|
||||
applyNamespace,
|
||||
getGenericActionName,
|
||||
getRequestIdentifier,
|
||||
getUrlParameters,
|
||||
maybeReplaceIdQuery,
|
||||
parseId,
|
||||
} from './utils';
|
||||
import { getTotalCountResourceName } from '../utils';
|
||||
import { IdQuery, IdType, Item, ItemQuery } from './types';
|
||||
import { ResourceState } from './reducer';
|
||||
import CRUD_ACTIONS from './crud-actions';
|
||||
|
@ -22,7 +29,7 @@ export const getItemCreateError = (
|
|||
state: ResourceState,
|
||||
query: ItemQuery
|
||||
) => {
|
||||
const itemQuery = getResourceName( CRUD_ACTIONS.CREATE_ITEM, query );
|
||||
const itemQuery = getRequestIdentifier( CRUD_ACTIONS.CREATE_ITEM, query );
|
||||
return state.errors[ itemQuery ];
|
||||
};
|
||||
|
||||
|
@ -33,7 +40,7 @@ export const getItemDeleteError = (
|
|||
) => {
|
||||
const urlParameters = getUrlParameters( namespace, idQuery );
|
||||
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 ];
|
||||
};
|
||||
|
||||
|
@ -54,13 +61,13 @@ export const getItemError = (
|
|||
) => {
|
||||
const urlParameters = getUrlParameters( namespace, idQuery );
|
||||
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 ];
|
||||
};
|
||||
|
||||
export const getItems = createSelector(
|
||||
( state: ResourceState, query?: ItemQuery ) => {
|
||||
const itemQuery = getResourceName(
|
||||
const itemQuery = getRequestIdentifier(
|
||||
CRUD_ACTIONS.GET_ITEMS,
|
||||
query || {}
|
||||
);
|
||||
|
@ -96,7 +103,7 @@ export const getItems = createSelector(
|
|||
return data;
|
||||
},
|
||||
( state, query ) => {
|
||||
const itemQuery = getResourceName(
|
||||
const itemQuery = getRequestIdentifier(
|
||||
CRUD_ACTIONS.GET_ITEMS,
|
||||
query || {}
|
||||
);
|
||||
|
@ -129,7 +136,10 @@ export const getItemsTotalCount = (
|
|||
};
|
||||
|
||||
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 ];
|
||||
};
|
||||
|
||||
|
@ -138,12 +148,8 @@ export const getItemUpdateError = (
|
|||
idQuery: IdQuery,
|
||||
urlParameters: IdType[]
|
||||
) => {
|
||||
const params = parseId( idQuery, urlParameters );
|
||||
const { key } = params;
|
||||
const itemQuery = getResourceName( CRUD_ACTIONS.UPDATE_ITEM, {
|
||||
key,
|
||||
params,
|
||||
} );
|
||||
const { key } = parseId( idQuery, urlParameters );
|
||||
const itemQuery = getRequestIdentifier( CRUD_ACTIONS.UPDATE_ITEM, key );
|
||||
return state.errors[ itemQuery ];
|
||||
};
|
||||
|
||||
|
@ -154,6 +160,32 @@ export const createSelectors = ( {
|
|||
pluralResourceName,
|
||||
namespace,
|
||||
}: 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 {
|
||||
[ `get${ resourceName }` ]: applyNamespace( getItem, namespace ),
|
||||
[ `get${ resourceName }Error` ]: applyNamespace(
|
||||
|
@ -184,5 +216,7 @@ export const createSelectors = ( {
|
|||
getItemUpdateError,
|
||||
namespace
|
||||
),
|
||||
hasFinishedRequest,
|
||||
isRequesting,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -5,6 +5,7 @@ import { Actions } from '../actions';
|
|||
import { createReducer, ResourceState } from '../reducer';
|
||||
import { CRUD_ACTIONS } from '../crud-actions';
|
||||
import { getResourceName } from '../../utils';
|
||||
import { getRequestIdentifier } from '..//utils';
|
||||
import { Item, ItemQuery } from '../types';
|
||||
import TYPES from '../action-types';
|
||||
|
||||
|
@ -13,6 +14,7 @@ const defaultState: ResourceState = {
|
|||
errors: {},
|
||||
itemsCount: {},
|
||||
data: {},
|
||||
requesting: {},
|
||||
};
|
||||
|
||||
const reducer = createReducer();
|
||||
|
@ -38,6 +40,7 @@ describe( 'crud reducer', () => {
|
|||
1: { id: 1, name: 'Donkey', status: 'draft' },
|
||||
2: { id: 2, name: 'Sauce', status: 'publish' },
|
||||
},
|
||||
requesting: {},
|
||||
};
|
||||
const update: Item = {
|
||||
id: 2,
|
||||
|
@ -72,7 +75,10 @@ describe( 'crud reducer', () => {
|
|||
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.includes( 1 ) ).toBeTruthy();
|
||||
|
@ -108,7 +114,10 @@ describe( 'crud reducer', () => {
|
|||
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.data[ '5/1' ] ).toEqual( items[ 0 ] );
|
||||
|
@ -141,7 +150,10 @@ describe( 'crud reducer', () => {
|
|||
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.includes( 1 ) ).toBeTruthy();
|
||||
|
@ -157,7 +169,10 @@ describe( 'crud reducer', () => {
|
|||
|
||||
it( 'should handle GET_ITEMS_ERROR', () => {
|
||||
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 state = reducer( defaultState, {
|
||||
type: TYPES.GET_ITEMS_ERROR,
|
||||
|
@ -171,7 +186,7 @@ describe( 'crud reducer', () => {
|
|||
|
||||
it( 'should handle GET_ITEMS_TOTAL_COUNT_ERROR', () => {
|
||||
const query: Partial< ItemQuery > = { status: 'draft' };
|
||||
const resourceName = getResourceName(
|
||||
const resourceName = getRequestIdentifier(
|
||||
CRUD_ACTIONS.GET_ITEMS_TOTAL_COUNT,
|
||||
query
|
||||
);
|
||||
|
@ -188,7 +203,7 @@ describe( 'crud reducer', () => {
|
|||
|
||||
it( 'should handle GET_ITEM_ERROR', () => {
|
||||
const key = 3;
|
||||
const resourceName = getResourceName( CRUD_ACTIONS.GET_ITEM, { key } );
|
||||
const resourceName = getRequestIdentifier( CRUD_ACTIONS.GET_ITEM, key );
|
||||
const error = 'Baaam!';
|
||||
const state = reducer( defaultState, {
|
||||
type: TYPES.GET_ITEM_ERROR,
|
||||
|
@ -202,7 +217,7 @@ describe( 'crud reducer', () => {
|
|||
|
||||
it( 'should handle GET_ITEM_ERROR', () => {
|
||||
const key = 3;
|
||||
const resourceName = getResourceName( CRUD_ACTIONS.GET_ITEM, { key } );
|
||||
const resourceName = getRequestIdentifier( CRUD_ACTIONS.GET_ITEM, key );
|
||||
const error = 'Baaam!';
|
||||
const state = reducer( defaultState, {
|
||||
type: TYPES.GET_ITEM_ERROR,
|
||||
|
@ -216,7 +231,10 @@ describe( 'crud reducer', () => {
|
|||
|
||||
it( 'should handle CREATE_ITEM_ERROR', () => {
|
||||
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 state = reducer( defaultState, {
|
||||
type: TYPES.CREATE_ITEM_ERROR,
|
||||
|
@ -226,22 +244,28 @@ describe( 'crud reducer', () => {
|
|||
} );
|
||||
|
||||
expect( state.errors[ resourceName ] ).toBe( error );
|
||||
expect( state.requesting[ resourceName ] ).toBe( false );
|
||||
} );
|
||||
|
||||
it( 'should handle UPDATE_ITEM_ERROR', () => {
|
||||
const key = 2;
|
||||
const resourceName = getResourceName( CRUD_ACTIONS.UPDATE_ITEM, {
|
||||
const query = { property: 'value' };
|
||||
const resourceName = getRequestIdentifier(
|
||||
CRUD_ACTIONS.UPDATE_ITEM,
|
||||
key,
|
||||
} );
|
||||
query
|
||||
);
|
||||
const error = 'Baaam!';
|
||||
const state = reducer( defaultState, {
|
||||
type: TYPES.UPDATE_ITEM_ERROR,
|
||||
key,
|
||||
error,
|
||||
errorType: CRUD_ACTIONS.UPDATE_ITEM,
|
||||
query,
|
||||
} );
|
||||
|
||||
expect( state.errors[ resourceName ] ).toBe( error );
|
||||
expect( state.requesting[ resourceName ] ).toBe( false );
|
||||
} );
|
||||
|
||||
it( 'should handle UPDATE_ITEM_SUCCESS', () => {
|
||||
|
@ -258,17 +282,25 @@ describe( 'crud reducer', () => {
|
|||
1: { id: 1, name: 'Donkey', status: 'draft' },
|
||||
2: { id: 2, name: 'Sauce', status: 'publish' },
|
||||
},
|
||||
requesting: {},
|
||||
};
|
||||
const item: Item = {
|
||||
id: 2,
|
||||
name: 'Holy smokes!',
|
||||
status: 'draft',
|
||||
};
|
||||
const query = { property: 'value' };
|
||||
const requestId = getRequestIdentifier(
|
||||
CRUD_ACTIONS.UPDATE_ITEM,
|
||||
item.id,
|
||||
query
|
||||
);
|
||||
|
||||
const state = reducer( initialState, {
|
||||
type: TYPES.UPDATE_ITEM_SUCCESS,
|
||||
key: item.id,
|
||||
item,
|
||||
query,
|
||||
} );
|
||||
|
||||
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 ].title ).toEqual( initialState.data[ 2 ].title );
|
||||
expect( state.data[ 2 ].name ).toEqual( item.name );
|
||||
expect( state.requesting[ requestId ] ).toEqual( false );
|
||||
} );
|
||||
|
||||
it( 'should handle CREATE_ITEM_SUCCESS', () => {
|
||||
|
@ -286,15 +319,26 @@ describe( 'crud reducer', () => {
|
|||
name: 'Off the hook!',
|
||||
status: 'draft',
|
||||
};
|
||||
const query = {
|
||||
name: 'Off the hook!',
|
||||
status: 'draft',
|
||||
};
|
||||
const resourceName = getRequestIdentifier(
|
||||
CRUD_ACTIONS.CREATE_ITEM,
|
||||
item.id,
|
||||
query
|
||||
);
|
||||
|
||||
const state = reducer( defaultState, {
|
||||
type: TYPES.CREATE_ITEM_SUCCESS,
|
||||
key: item.id,
|
||||
item,
|
||||
query,
|
||||
} );
|
||||
|
||||
expect( state.data[ 2 ].name ).toEqual( item.name );
|
||||
expect( state.data[ 2 ].status ).toEqual( item.status );
|
||||
expect( state.requesting[ resourceName ] ).toEqual( false );
|
||||
} );
|
||||
|
||||
it( 'should handle DELETE_ITEM_SUCCESS', () => {
|
||||
|
@ -311,6 +355,7 @@ describe( 'crud reducer', () => {
|
|||
1: { id: 1, name: 'Donkey', status: 'draft' },
|
||||
2: { id: 2, name: 'Sauce', status: 'publish' },
|
||||
},
|
||||
requesting: {},
|
||||
};
|
||||
const item1Updated: Item = {
|
||||
id: 1,
|
||||
|
@ -333,25 +378,35 @@ describe( 'crud reducer', () => {
|
|||
item: item2Updated,
|
||||
force: false,
|
||||
} );
|
||||
const resourceName = getRequestIdentifier(
|
||||
CRUD_ACTIONS.DELETE_ITEM,
|
||||
item1Updated.id,
|
||||
true
|
||||
);
|
||||
|
||||
expect( state.errors ).toEqual( initialState.errors );
|
||||
expect( state.data[ 1 ] ).toEqual( undefined );
|
||||
expect( state.data[ 2 ].status ).toEqual( 'trash' );
|
||||
expect( state.requesting[ resourceName ] ).toBe( false );
|
||||
} );
|
||||
|
||||
it( 'should handle DELETE_ITEM_ERROR', () => {
|
||||
const key = 2;
|
||||
const resourceName = getResourceName( CRUD_ACTIONS.DELETE_ITEM, {
|
||||
const resourceName = getRequestIdentifier(
|
||||
CRUD_ACTIONS.DELETE_ITEM,
|
||||
key,
|
||||
} );
|
||||
false
|
||||
);
|
||||
const error = 'Baaam!';
|
||||
const state = reducer( defaultState, {
|
||||
type: TYPES.DELETE_ITEM_ERROR,
|
||||
key,
|
||||
error,
|
||||
errorType: CRUD_ACTIONS.DELETE_ITEM,
|
||||
force: false,
|
||||
} );
|
||||
|
||||
expect( state.errors[ resourceName ] ).toBe( error );
|
||||
expect( state.requesting[ resourceName ] ).toBe( false );
|
||||
} );
|
||||
} );
|
||||
|
|
|
@ -11,7 +11,7 @@ const selectors = createSelectors( {
|
|||
|
||||
describe( 'crud 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( 'getProducts' );
|
||||
expect( selectors ).toHaveProperty( 'getProductsTotalCount' );
|
||||
|
@ -20,5 +20,7 @@ describe( 'crud selectors', () => {
|
|||
expect( selectors ).toHaveProperty( 'getProductCreateError' );
|
||||
expect( selectors ).toHaveProperty( 'getProductDeleteError' );
|
||||
expect( selectors ).toHaveProperty( 'getProductUpdateError' );
|
||||
expect( selectors ).toHaveProperty( 'hasFinishedRequest' );
|
||||
expect( selectors ).toHaveProperty( 'isRequesting' );
|
||||
} );
|
||||
} );
|
||||
|
|
|
@ -1,13 +1,18 @@
|
|||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import CRUD_ACTIONS from '../crud-actions';
|
||||
import {
|
||||
applyNamespace,
|
||||
cleanQuery,
|
||||
getGenericActionName,
|
||||
getKey,
|
||||
getNamespaceKeys,
|
||||
getRequestIdentifier,
|
||||
getRestPath,
|
||||
getUrlParameters,
|
||||
maybeReplaceIdQuery,
|
||||
isValidIdQuery,
|
||||
parseId,
|
||||
} from '../utils';
|
||||
|
||||
|
@ -113,4 +118,104 @@ describe( 'utils', () => {
|
|||
expect( params.other_attribute ).toBe( 'a' );
|
||||
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 );
|
||||
} );
|
||||
} );
|
||||
|
|
|
@ -6,6 +6,7 @@ import { addQueryArgs } from '@wordpress/url';
|
|||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import CRUD_ACTIONS from './crud-actions';
|
||||
import { IdQuery, IdType, ItemQuery } from './types';
|
||||
|
||||
/**
|
||||
|
@ -148,6 +149,52 @@ export const getUrlParameters = (
|
|||
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.
|
||||
*
|
||||
|
@ -168,3 +215,46 @@ export const cleanQuery = (
|
|||
|
||||
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;
|
||||
};
|
||||
|
|
|
@ -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_ATTRIBUTE_TERMS_STORE_NAME } from './product-attribute-terms';
|
||||
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 { PaymentGateway } from './payment-gateways/types';
|
||||
|
||||
|
@ -75,6 +76,11 @@ export {
|
|||
// Export types
|
||||
export * from './types';
|
||||
export * from './countries/types';
|
||||
export {
|
||||
ProductForm,
|
||||
ProductFormField,
|
||||
ProductFormSection,
|
||||
} from './product-form/types';
|
||||
export * from './onboarding/types';
|
||||
export * from './plugins/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_PRODUCT_TAGS_STORE_NAME } from './product-tags';
|
||||
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_VARIATIONS_STORE_NAME } from './product-variations';
|
||||
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_CATEGORIES_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
|
||||
|
@ -168,6 +176,7 @@ import { ProductCategorySelectors } from './product-categories/types';
|
|||
import { ProductAttributeTermsSelectors } from './product-attribute-terms/types';
|
||||
import { ProductVariationSelectors } from './product-variations/types';
|
||||
import { TaxClassSelectors } from './tax-classes/types';
|
||||
import { ProductFormSelectors } from './product-form/selectors';
|
||||
|
||||
// As we add types to all the package selectors we can fill out these unknown types with real ones. See one
|
||||
// of the already typed selectors for an example of how you can do this.
|
||||
|
@ -215,6 +224,8 @@ export type WCSelectorType< T > = T extends typeof REVIEWS_STORE_NAME
|
|||
? ShippingZonesSelectors
|
||||
: T extends typeof EXPERIMENTAL_TAX_CLASSES_STORE_NAME
|
||||
? TaxClassSelectors
|
||||
: T extends typeof EXPERIMENTAL_PRODUCT_FORM_STORE_NAME
|
||||
? ProductFormSelectors
|
||||
: never;
|
||||
|
||||
export interface WCDataSelector {
|
||||
|
|
|
@ -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;
|
|
@ -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
|
||||
>;
|
|
@ -0,0 +1 @@
|
|||
export const STORE_NAME = 'experimental/wc/admin/product-form';
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
|
@ -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 );
|
||||
}
|
||||
}
|
|
@ -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;
|
|
@ -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;
|
||||
};
|
||||
};
|
|
@ -45,6 +45,7 @@ $alert-green: $valid-green;
|
|||
$adminbar-height: 32px;
|
||||
$adminbar-height-mobile: 46px;
|
||||
$admin-menu-width: 160px;
|
||||
$admin-menu-width-collapsed: 36px;
|
||||
|
||||
// wp-admin colors
|
||||
$wp-admin-background: #f1f1f1;
|
||||
|
|
|
@ -0,0 +1,36 @@
|
|||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { useSlot } from '@woocommerce/experimental';
|
||||
import classnames from 'classnames';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import {
|
||||
EXPERIMENTAL_WC_HOMESCREEN_HEADER_BANNER_SLOT_NAME,
|
||||
WooHomescreenHeaderBannerItem,
|
||||
} from './utils';
|
||||
|
||||
export const WooHomescreenHeaderBanner = ( {
|
||||
className,
|
||||
}: {
|
||||
className: string;
|
||||
} ) => {
|
||||
const slot = useSlot( EXPERIMENTAL_WC_HOMESCREEN_HEADER_BANNER_SLOT_NAME );
|
||||
const hasFills = Boolean( slot?.fills?.length );
|
||||
|
||||
if ( ! hasFills ) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className={ classnames(
|
||||
'woocommerce-homescreen__header',
|
||||
className
|
||||
) }
|
||||
>
|
||||
<WooHomescreenHeaderBannerItem.Slot />
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,2 @@
|
|||
export * from './header-banner-slot';
|
||||
export * from './utils';
|
|
@ -0,0 +1,58 @@
|
|||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { Slot, Fill } from '@wordpress/components';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { createOrderedChildren, sortFillsByOrder } from '../../utils';
|
||||
|
||||
export const EXPERIMENTAL_WC_HOMESCREEN_HEADER_BANNER_SLOT_NAME =
|
||||
'woocommerce_homescreen_experimental_header_banner_item';
|
||||
/**
|
||||
* Create a Fill for extensions to add items to the WooCommerce Admin Homescreen header banner.
|
||||
*
|
||||
* @slotFill WooHomescreenHeaderBannerItem
|
||||
* @scope woocommerce-admin
|
||||
* @example
|
||||
* const MyHeaderItem = () => (
|
||||
* <WooHomescreenHeaderBannerItem>My header item</WooHomescreenHeaderBannerItem>
|
||||
* );
|
||||
*
|
||||
* registerPlugin( 'my-extension', {
|
||||
* render: MyHeaderItem,
|
||||
* scope: 'woocommerce-admin',
|
||||
* } );
|
||||
* @param {Object} param0
|
||||
* @param {Array} param0.children - Node children.
|
||||
* @param {Array} param0.order - Node order.
|
||||
*/
|
||||
export const WooHomescreenHeaderBannerItem = ( {
|
||||
children,
|
||||
order = 1,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
order?: number;
|
||||
} ) => {
|
||||
return (
|
||||
<Fill name={ EXPERIMENTAL_WC_HOMESCREEN_HEADER_BANNER_SLOT_NAME }>
|
||||
{ ( fillProps: Fill.Props ) => {
|
||||
return createOrderedChildren( children, order, fillProps );
|
||||
} }
|
||||
</Fill>
|
||||
);
|
||||
};
|
||||
|
||||
WooHomescreenHeaderBannerItem.Slot = ( {
|
||||
fillProps,
|
||||
}: {
|
||||
fillProps?: Slot.Props;
|
||||
} ) => (
|
||||
<Slot
|
||||
name={ EXPERIMENTAL_WC_HOMESCREEN_HEADER_BANNER_SLOT_NAME }
|
||||
fillProps={ fillProps }
|
||||
>
|
||||
{ sortFillsByOrder }
|
||||
</Slot>
|
||||
);
|
|
@ -43,6 +43,7 @@ import './style.scss';
|
|||
import '../dashboard/style.scss';
|
||||
import { getAdminSetting } from '~/utils/admin-settings';
|
||||
import { ProgressTitle } from '../task-lists';
|
||||
import { WooHomescreenHeaderBanner } from './header-banner-slot';
|
||||
|
||||
const Tasks = lazy( () =>
|
||||
import( /* webpackChunkName: "tasks" */ '../tasks' ).then( ( module ) => ( {
|
||||
|
@ -126,7 +127,9 @@ export const Layout = ( {
|
|||
return (
|
||||
<Suspense fallback={ <TasksPlaceholder query={ query } /> }>
|
||||
{ activeSetupTaskList && isDashboardShown && (
|
||||
<ProgressTitle taskListId={ activeSetupTaskList } />
|
||||
<>
|
||||
<ProgressTitle taskListId={ activeSetupTaskList } />
|
||||
</>
|
||||
) }
|
||||
<Tasks query={ query } />
|
||||
</Suspense>
|
||||
|
@ -135,6 +138,13 @@ export const Layout = ( {
|
|||
|
||||
return (
|
||||
<>
|
||||
{ isDashboardShown && (
|
||||
<WooHomescreenHeaderBanner
|
||||
className={ classnames( 'woocommerce-homescreen', {
|
||||
'woocommerce-homescreen-column': ! twoColumns,
|
||||
} ) }
|
||||
/>
|
||||
) }
|
||||
<div
|
||||
className={ classnames( 'woocommerce-homescreen', {
|
||||
'two-columns': twoColumns,
|
||||
|
|
|
@ -2,22 +2,46 @@
|
|||
* External dependencies
|
||||
*/
|
||||
import { recordEvent } from '@woocommerce/tracks';
|
||||
import { useSelect } from '@wordpress/data';
|
||||
import { useEffect } from '@wordpress/element';
|
||||
import { Spinner } from '@wordpress/components';
|
||||
import {
|
||||
EXPERIMENTAL_PRODUCT_FORM_STORE_NAME,
|
||||
WCDataSelector,
|
||||
} from '@woocommerce/data';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { ProductForm } from './product-form';
|
||||
import { ProductTourContainer } from './tour';
|
||||
import './product-page.scss';
|
||||
import './fills';
|
||||
|
||||
const AddProductPage: React.FC = () => {
|
||||
const { isLoading } = useSelect( ( select: WCDataSelector ) => {
|
||||
const { hasFinishedResolution: hasProductFormFinishedResolution } =
|
||||
select( EXPERIMENTAL_PRODUCT_FORM_STORE_NAME );
|
||||
return {
|
||||
isLoading: ! hasProductFormFinishedResolution( 'getProductForm' ),
|
||||
};
|
||||
} );
|
||||
useEffect( () => {
|
||||
recordEvent( 'view_new_product_management_experience' );
|
||||
}, [] );
|
||||
|
||||
return (
|
||||
<div className="woocommerce-add-product">
|
||||
<ProductForm />
|
||||
{ isLoading ? (
|
||||
<div className="woocommerce-edit-product__spinner">
|
||||
<Spinner />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<ProductForm />
|
||||
<ProductTourContainer />
|
||||
</>
|
||||
) }
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
*/
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import {
|
||||
EXPERIMENTAL_PRODUCT_FORM_STORE_NAME,
|
||||
EXPERIMENTAL_PRODUCT_VARIATIONS_STORE_NAME,
|
||||
PartialProduct,
|
||||
Product,
|
||||
|
@ -21,6 +22,7 @@ import { ProductForm } from './product-form';
|
|||
import { ProductFormLayout } from './layout/product-form-layout';
|
||||
import { ProductVariationForm } from './product-variation-form';
|
||||
import './product-page.scss';
|
||||
import './fills';
|
||||
|
||||
const EditProductPage: React.FC = () => {
|
||||
const { productId, variationId } = useParams();
|
||||
|
@ -35,6 +37,8 @@ const EditProductPage: React.FC = () => {
|
|||
isPending,
|
||||
getPermalinkParts,
|
||||
} = select( PRODUCTS_STORE_NAME );
|
||||
const { hasFinishedResolution: hasProductFormFinishedResolution } =
|
||||
select( EXPERIMENTAL_PRODUCT_FORM_STORE_NAME );
|
||||
const {
|
||||
getProductVariation,
|
||||
hasFinishedResolution: hasProductVariationFinishedResolution,
|
||||
|
@ -71,7 +75,8 @@ const EditProductPage: React.FC = () => {
|
|||
'getProductVariation',
|
||||
[ parseInt( variationId, 10 ) ]
|
||||
)
|
||||
),
|
||||
) ||
|
||||
! hasProductFormFinishedResolution( 'getProductForm' ),
|
||||
isPendingAction:
|
||||
isPending( 'createProduct' ) ||
|
||||
isPending(
|
||||
|
|
|
@ -26,7 +26,7 @@ import {
|
|||
AttributeTermInputField,
|
||||
CustomAttributeTermInputField,
|
||||
} from '../attribute-term-input-field';
|
||||
import { HydratedAttributeType } from '../attribute-field';
|
||||
import { EnhancedProductAttribute } from '~/products/hooks/use-product-attributes';
|
||||
import { getProductAttributeObject } from './utils';
|
||||
|
||||
type AddAttributeModalProps = {
|
||||
|
@ -46,12 +46,12 @@ type AddAttributeModalProps = {
|
|||
confirmCancelLabel?: string;
|
||||
confirmConfirmLabel?: string;
|
||||
onCancel: () => void;
|
||||
onAdd: ( newCategories: HydratedAttributeType[] ) => void;
|
||||
onAdd: ( newCategories: EnhancedProductAttribute[] ) => void;
|
||||
selectedAttributeIds?: number[];
|
||||
};
|
||||
|
||||
type AttributeForm = {
|
||||
attributes: Array< HydratedAttributeType | null >;
|
||||
attributes: Array< EnhancedProductAttribute | null >;
|
||||
};
|
||||
|
||||
export const AddAttributeModal: React.FC< AddAttributeModalProps > = ( {
|
||||
|
@ -80,6 +80,15 @@ export const AddAttributeModal: React.FC< AddAttributeModalProps > = ( {
|
|||
onAdd,
|
||||
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 addAnother = (
|
||||
values: AttributeForm,
|
||||
|
@ -89,10 +98,11 @@ export const AddAttributeModal: React.FC< AddAttributeModalProps > = ( {
|
|||
) => void
|
||||
) => {
|
||||
setValue( 'attributes', [ ...values.attributes, null ] );
|
||||
scrollAttributeIntoView( values.attributes.length );
|
||||
};
|
||||
|
||||
const onAddingAttributes = ( values: AttributeForm ) => {
|
||||
const newAttributesToAdd: HydratedAttributeType[] = [];
|
||||
const newAttributesToAdd: EnhancedProductAttribute[] = [];
|
||||
values.attributes.forEach( ( attr ) => {
|
||||
if (
|
||||
attr !== null &&
|
||||
|
@ -105,7 +115,7 @@ export const AddAttributeModal: React.FC< AddAttributeModalProps > = ( {
|
|||
? ( attr.terms || [] ).map( ( term ) => term.name )
|
||||
: attr.options;
|
||||
newAttributesToAdd.push( {
|
||||
...( attr as HydratedAttributeType ),
|
||||
...( attr as EnhancedProductAttribute ),
|
||||
options,
|
||||
} );
|
||||
}
|
|
@ -2,13 +2,8 @@
|
|||
* External dependencies
|
||||
*/
|
||||
import { __, sprintf } from '@wordpress/i18n';
|
||||
import { useState, useCallback, useEffect } from '@wordpress/element';
|
||||
import {
|
||||
ProductAttribute,
|
||||
EXPERIMENTAL_PRODUCT_ATTRIBUTE_TERMS_STORE_NAME,
|
||||
ProductAttributeTerm,
|
||||
} from '@woocommerce/data';
|
||||
import { resolveSelect } from '@wordpress/data';
|
||||
import { useState } from '@wordpress/element';
|
||||
import { ProductAttribute } from '@woocommerce/data';
|
||||
import {
|
||||
Sortable,
|
||||
__experimentalSelectControlMenuSlot as SelectControlMenuSlot,
|
||||
|
@ -24,42 +19,31 @@ import { getAdminLink } from '@woocommerce/settings';
|
|||
import './attribute-field.scss';
|
||||
import { AddAttributeModal } from './add-attribute-modal';
|
||||
import { EditAttributeModal } from './edit-attribute-modal';
|
||||
import { EnhancedProductAttribute } from '~/products/hooks/use-product-attributes';
|
||||
import {
|
||||
getAttributeKey,
|
||||
reorderSortableProductAttributePositions,
|
||||
} from './utils';
|
||||
import { sift } from '../../../utils';
|
||||
import { AttributeEmptyState } from '../attribute-empty-state';
|
||||
import {
|
||||
AddAttributeListItem,
|
||||
AttributeListItem,
|
||||
} from '../attribute-list-item';
|
||||
|
||||
type AttributeFieldProps = {
|
||||
type AttributeControlProps = {
|
||||
value: ProductAttribute[];
|
||||
onChange: ( value: ProductAttribute[] ) => void;
|
||||
productId?: number;
|
||||
// TODO: should we support an 'any' option to show all attributes?
|
||||
attributeType?: 'regular' | 'for-variations';
|
||||
};
|
||||
|
||||
export type HydratedAttributeType = Omit< ProductAttribute, 'options' > & {
|
||||
options?: string[];
|
||||
terms?: ProductAttributeTerm[];
|
||||
visible?: boolean;
|
||||
};
|
||||
|
||||
export const AttributeField: React.FC< AttributeFieldProps > = ( {
|
||||
export const AttributeControl: React.FC< AttributeControlProps > = ( {
|
||||
value,
|
||||
onChange,
|
||||
productId,
|
||||
attributeType = 'regular',
|
||||
onChange,
|
||||
} ) => {
|
||||
const [ showAddAttributeModal, setShowAddAttributeModal ] =
|
||||
useState( false );
|
||||
const [ hydratedAttributes, setHydratedAttributes ] = useState<
|
||||
HydratedAttributeType[]
|
||||
>( [] );
|
||||
const [ editingAttributeId, setEditingAttributeId ] = useState<
|
||||
null | string
|
||||
>( null );
|
||||
|
@ -72,73 +56,12 @@ export const AttributeField: React.FC< AttributeFieldProps > = ( {
|
|||
? 'product_add_options_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 } ) =>
|
||||
`${ attribute.id }-${ attribute.name }`;
|
||||
|
||||
const updateAttributes = ( attributes: HydratedAttributeType[] ) => {
|
||||
setHydratedAttributes( attributes );
|
||||
const handleChange = ( newAttributes: EnhancedProductAttribute[] ) => {
|
||||
onChange(
|
||||
attributes.map( ( attr ) => {
|
||||
newAttributes.map( ( attr ) => {
|
||||
return {
|
||||
...attr,
|
||||
options: attr.terms
|
||||
|
@ -157,8 +80,8 @@ export const AttributeField: React.FC< AttributeFieldProps > = ( {
|
|||
recordEvent(
|
||||
'product_remove_attribute_confirmation_confirm_click'
|
||||
);
|
||||
updateAttributes(
|
||||
hydratedAttributes.filter(
|
||||
handleChange(
|
||||
value.filter(
|
||||
( attr ) =>
|
||||
fetchAttributeId( attr ) !==
|
||||
fetchAttributeId( attribute )
|
||||
|
@ -169,9 +92,11 @@ export const AttributeField: React.FC< AttributeFieldProps > = ( {
|
|||
}
|
||||
};
|
||||
|
||||
const onAddNewAttributes = ( newAttributes: HydratedAttributeType[] ) => {
|
||||
updateAttributes( [
|
||||
...( hydratedAttributes || [] ),
|
||||
const onAddNewAttributes = (
|
||||
newAttributes: EnhancedProductAttribute[]
|
||||
) => {
|
||||
handleChange( [
|
||||
...( value || [] ),
|
||||
...newAttributes
|
||||
.filter(
|
||||
( newAttr ) =>
|
||||
|
@ -193,18 +118,7 @@ export const AttributeField: React.FC< AttributeFieldProps > = ( {
|
|||
setShowAddAttributeModal( false );
|
||||
};
|
||||
|
||||
const filteredAttributes = value
|
||||
? value.filter(
|
||||
( attribute: ProductAttribute ) =>
|
||||
attribute.variation === isOnlyForVariations
|
||||
)
|
||||
: false;
|
||||
|
||||
if (
|
||||
! filteredAttributes ||
|
||||
filteredAttributes.length === 0 ||
|
||||
hydratedAttributes.length === 0
|
||||
) {
|
||||
if ( ! value.length ) {
|
||||
return (
|
||||
<>
|
||||
<AttributeEmptyState
|
||||
|
@ -232,9 +146,7 @@ export const AttributeField: React.FC< AttributeFieldProps > = ( {
|
|||
setShowAddAttributeModal( false );
|
||||
} }
|
||||
onAdd={ onAddNewAttributes }
|
||||
selectedAttributeIds={ ( filteredAttributes || [] ).map(
|
||||
( attr ) => attr.id
|
||||
) }
|
||||
selectedAttributeIds={ [] }
|
||||
/>
|
||||
) }
|
||||
<SelectControlMenuSlot />
|
||||
|
@ -242,9 +154,8 @@ export const AttributeField: React.FC< AttributeFieldProps > = ( {
|
|||
);
|
||||
}
|
||||
|
||||
const sortedAttributes = filteredAttributes.sort(
|
||||
( a, b ) => a.position - b.position
|
||||
);
|
||||
const sortedAttributes = value.sort( ( a, b ) => a.position - b.position );
|
||||
|
||||
const attributeKeyValues = value.reduce(
|
||||
(
|
||||
keyValue: Record< number | string, ProductAttribute >,
|
||||
|
@ -256,9 +167,9 @@ export const AttributeField: React.FC< AttributeFieldProps > = ( {
|
|||
{} as Record< number | string, ProductAttribute >
|
||||
);
|
||||
|
||||
const attribute = hydratedAttributes.find(
|
||||
const editingAttribute = value.find(
|
||||
( attr ) => fetchAttributeId( attr ) === editingAttributeId
|
||||
) as HydratedAttributeType;
|
||||
) as EnhancedProductAttribute;
|
||||
|
||||
const editAttributeCopy = isOnlyForVariations
|
||||
? __(
|
||||
|
@ -332,15 +243,13 @@ export const AttributeField: React.FC< AttributeFieldProps > = ( {
|
|||
/>
|
||||
) }
|
||||
<SelectControlMenuSlot />
|
||||
{ editingAttributeId && (
|
||||
{ editingAttribute && (
|
||||
<EditAttributeModal
|
||||
title={
|
||||
title={ sprintf(
|
||||
/* translators: %s is the attribute name */
|
||||
sprintf(
|
||||
__( 'Edit %s', 'woocommerce' ),
|
||||
attribute.name
|
||||
)
|
||||
}
|
||||
__( 'Edit %s', 'woocommerce' ),
|
||||
editingAttribute.name
|
||||
) }
|
||||
globalAttributeHelperMessage={ interpolateComponents( {
|
||||
mixedString: editAttributeCopy,
|
||||
components: {
|
||||
|
@ -359,7 +268,7 @@ export const AttributeField: React.FC< AttributeFieldProps > = ( {
|
|||
} ) }
|
||||
onCancel={ () => setEditingAttributeId( null ) }
|
||||
onEdit={ ( changedAttribute ) => {
|
||||
const newAttributesSet = [ ...hydratedAttributes ];
|
||||
const newAttributesSet = [ ...value ];
|
||||
const changedAttributeIndex: number =
|
||||
newAttributesSet.findIndex( ( attr ) =>
|
||||
attr.id !== 0
|
||||
|
@ -373,10 +282,10 @@ export const AttributeField: React.FC< AttributeFieldProps > = ( {
|
|||
changedAttribute
|
||||
);
|
||||
|
||||
updateAttributes( newAttributesSet );
|
||||
handleChange( newAttributesSet );
|
||||
setEditingAttributeId( null );
|
||||
} }
|
||||
attribute={ attribute }
|
||||
attribute={ editingAttribute }
|
||||
/>
|
||||
) }
|
||||
</div>
|
|
@ -18,7 +18,7 @@ import {
|
|||
AttributeTermInputField,
|
||||
CustomAttributeTermInputField,
|
||||
} from '../attribute-term-input-field';
|
||||
import { HydratedAttributeType } from './attribute-field';
|
||||
import { EnhancedProductAttribute } from '../../hooks/use-product-attributes';
|
||||
|
||||
import './edit-attribute-modal.scss';
|
||||
|
||||
|
@ -36,8 +36,8 @@ type EditAttributeModalProps = {
|
|||
updateAccessibleLabel?: string;
|
||||
updateLabel?: string;
|
||||
onCancel: () => void;
|
||||
onEdit: ( alteredAttribute: HydratedAttributeType ) => void;
|
||||
attribute: HydratedAttributeType;
|
||||
onEdit: ( alteredAttribute: EnhancedProductAttribute ) => void;
|
||||
attribute: EnhancedProductAttribute;
|
||||
};
|
||||
|
||||
export const EditAttributeModal: React.FC< EditAttributeModalProps > = ( {
|
||||
|
@ -64,7 +64,7 @@ export const EditAttributeModal: React.FC< EditAttributeModalProps > = ( {
|
|||
attribute,
|
||||
} ) => {
|
||||
const [ editableAttribute, setEditableAttribute ] = useState<
|
||||
HydratedAttributeType | undefined
|
||||
EnhancedProductAttribute | undefined
|
||||
>( { ...attribute } );
|
||||
|
||||
const isCustomAttribute = editableAttribute?.id === 0;
|
||||
|
@ -84,7 +84,7 @@ export const EditAttributeModal: React.FC< EditAttributeModalProps > = ( {
|
|||
}
|
||||
onChange={ ( val ) =>
|
||||
setEditableAttribute( {
|
||||
...( editableAttribute as HydratedAttributeType ),
|
||||
...( editableAttribute as EnhancedProductAttribute ),
|
||||
name: val,
|
||||
} )
|
||||
}
|
||||
|
@ -102,7 +102,7 @@ export const EditAttributeModal: React.FC< EditAttributeModalProps > = ( {
|
|||
attributeId={ editableAttribute?.id }
|
||||
onChange={ ( val ) => {
|
||||
setEditableAttribute( {
|
||||
...( editableAttribute as HydratedAttributeType ),
|
||||
...( editableAttribute as EnhancedProductAttribute ),
|
||||
terms: val,
|
||||
} );
|
||||
} }
|
||||
|
@ -115,7 +115,7 @@ export const EditAttributeModal: React.FC< EditAttributeModalProps > = ( {
|
|||
value={ editableAttribute?.options }
|
||||
onChange={ ( val ) => {
|
||||
setEditableAttribute( {
|
||||
...( editableAttribute as HydratedAttributeType ),
|
||||
...( editableAttribute as EnhancedProductAttribute ),
|
||||
options: val,
|
||||
} );
|
||||
} }
|
||||
|
@ -126,7 +126,7 @@ export const EditAttributeModal: React.FC< EditAttributeModalProps > = ( {
|
|||
<CheckboxControl
|
||||
onChange={ ( val ) =>
|
||||
setEditableAttribute( {
|
||||
...( editableAttribute as HydratedAttributeType ),
|
||||
...( editableAttribute as EnhancedProductAttribute ),
|
||||
visible: val,
|
||||
} )
|
||||
}
|
||||
|
@ -148,7 +148,7 @@ export const EditAttributeModal: React.FC< EditAttributeModalProps > = ( {
|
|||
isPrimary
|
||||
label={ updateAccessibleLabel }
|
||||
onClick={ () => {
|
||||
onEdit( editableAttribute as HydratedAttributeType );
|
||||
onEdit( editableAttribute as EnhancedProductAttribute );
|
||||
} }
|
||||
>
|
||||
{ updateLabel }
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue