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 }}
|
${{ env.ALLURE_REPORT_DIR }}
|
||||||
if-no-files-found: ignore
|
if-no-files-found: ignore
|
||||||
retention-days: 5
|
retention-days: 5
|
||||||
|
|
||||||
# test-summary:
|
|
||||||
# name: Post test results
|
|
||||||
# if: |
|
|
||||||
# always() &&
|
|
||||||
# ! github.event.pull_request.head.repo.fork &&
|
|
||||||
# (
|
|
||||||
# contains( needs.*.result, 'success' ) ||
|
|
||||||
# contains( needs.*.result, 'failure' )
|
|
||||||
# )
|
|
||||||
# runs-on: ubuntu-20.04
|
|
||||||
# permissions:
|
|
||||||
# contents: read
|
|
||||||
# needs: [cot-api-tests-run, cot-e2e-tests-run]
|
|
||||||
# steps:
|
|
||||||
# - name: Create dirs
|
|
||||||
# run: |
|
|
||||||
# mkdir -p repo
|
|
||||||
# mkdir -p artifacts/api
|
|
||||||
# mkdir -p artifacts/e2e
|
|
||||||
# mkdir -p output
|
|
||||||
#
|
|
||||||
# - name: Checkout code
|
|
||||||
# uses: actions/checkout@v3
|
|
||||||
# with:
|
|
||||||
# path: repo
|
|
||||||
#
|
|
||||||
# - name: Download API test report artifact
|
|
||||||
# uses: actions/download-artifact@v3
|
|
||||||
# with:
|
|
||||||
# name: api-test-report---pr-${{ github.event.number }}
|
|
||||||
# path: artifacts/api
|
|
||||||
#
|
|
||||||
# - name: Download Playwright E2E test report artifact
|
|
||||||
# uses: actions/download-artifact@v3
|
|
||||||
# with:
|
|
||||||
# name: e2e-test-report---pr-${{ github.event.number }}
|
|
||||||
# path: artifacts/e2e
|
|
||||||
#
|
|
||||||
# - name: Prepare test summary
|
|
||||||
# id: prepare-test-summary
|
|
||||||
# uses: actions/github-script@v6
|
|
||||||
# env:
|
|
||||||
# API_SUMMARY_PATH: ${{ github.workspace }}/artifacts/api/allure-report/widgets/summary.json
|
|
||||||
# E2E_PW_SUMMARY_PATH: ${{ github.workspace }}/artifacts/e2e/allure-report/widgets/summary.json
|
|
||||||
# PR_NUMBER: ${{ github.event.number }}
|
|
||||||
# SHA: ${{ github.event.pull_request.head.sha }}
|
|
||||||
# with:
|
|
||||||
# result-encoding: string
|
|
||||||
# script: |
|
|
||||||
# const script = require( './repo/.github/workflows/scripts/prepare-test-summary.js' )
|
|
||||||
# return await script( { core } )
|
|
||||||
#
|
|
||||||
# - name: Find PR comment by github-actions[bot]
|
|
||||||
# uses: peter-evans/find-comment@v2
|
|
||||||
# id: find-comment
|
|
||||||
# with:
|
|
||||||
# issue-number: ${{ github.event.pull_request.number }}
|
|
||||||
# comment-author: 'github-actions[bot]'
|
|
||||||
# body-includes: Test Results Summary
|
|
||||||
#
|
|
||||||
# - name: Create or update PR comment
|
|
||||||
# uses: peter-evans/create-or-update-comment@v2
|
|
||||||
# with:
|
|
||||||
# comment-id: ${{ steps.find-comment.outputs.comment-id }}
|
|
||||||
# issue-number: ${{ github.event.pull_request.number }}
|
|
||||||
# body: ${{ steps.prepare-test-summary.outputs.result }}
|
|
||||||
# edit-mode: replace
|
|
||||||
#
|
|
||||||
# publish-test-reports:
|
|
||||||
# name: Publish test reports
|
|
||||||
# if: |
|
|
||||||
# always() &&
|
|
||||||
# ! github.event.pull_request.head.repo.fork &&
|
|
||||||
# (
|
|
||||||
# contains( needs.*.result, 'success' ) ||
|
|
||||||
# contains( needs.*.result, 'failure' )
|
|
||||||
# )
|
|
||||||
# runs-on: ubuntu-20.04
|
|
||||||
# needs: [cot-api-tests-run, cot-e2e-tests-run]
|
|
||||||
# env:
|
|
||||||
# GITHUB_TOKEN: ${{ secrets.REPORTS_TOKEN }}
|
|
||||||
# PR_NUMBER: ${{ github.event.number }}
|
|
||||||
# RUN_ID: ${{ github.run_id }}
|
|
||||||
# COMMIT_SHA: ${{ github.event.pull_request.head.sha }}
|
|
||||||
# steps:
|
|
||||||
# - name: Publish test reports
|
|
||||||
# env:
|
|
||||||
# API_ARTIFACT: api-test-report---pr-${{ github.event.number }}
|
|
||||||
# E2E_ARTIFACT: e2e-test-report---pr-${{ github.event.number }}
|
|
||||||
# run: |
|
|
||||||
# gh workflow run publish-test-reports-pr.yml \
|
|
||||||
# -f run_id=$RUN_ID \
|
|
||||||
# -f api_artifact=$API_ARTIFACT \
|
|
||||||
# -f e2e_artifact=$E2E_ARTIFACT \
|
|
||||||
# -f pr_number=$PR_NUMBER \
|
|
||||||
# -f commit_sha=$COMMIT_SHA \
|
|
||||||
# -f s3_root=public \
|
|
||||||
# --repo woocommerce/woocommerce-test-reports
|
|
||||||
|
|
|
@ -23,7 +23,7 @@ jobs:
|
||||||
uses: ./.github/actions/setup-woocommerce-monorepo
|
uses: ./.github/actions/setup-woocommerce-monorepo
|
||||||
|
|
||||||
- name: Lint
|
- name: Lint
|
||||||
run: pnpm run -r --filter='woocommerce/client/admin...' --filter='!@woocommerce/e2e*' --filter='!@woocommerce/api' --color lint
|
run: pnpm run -r --filter='release-posts' --filter='woocommerce/client/admin...' --filter='!@woocommerce/e2e*' --filter='!@woocommerce/api' --color lint
|
||||||
|
|
||||||
- name: Test
|
- name: Test
|
||||||
run: pnpm run test --filter='woocommerce/client/admin...' --filter='!@woocommerce/e2e*' --filter='!@woocommerce/api' --color
|
run: pnpm run test --filter='woocommerce/client/admin...' --filter='!@woocommerce/e2e*' --filter='!@woocommerce/api' --color
|
||||||
|
|
|
@ -21,7 +21,7 @@ jobs:
|
||||||
create-changelog-prs:
|
create-changelog-prs:
|
||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-20.04
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: write
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
|
@ -46,6 +46,9 @@ jobs:
|
||||||
- name: 'Generate the changelog file'
|
- name: 'Generate the changelog file'
|
||||||
run: pnpm --filter=woocommerce run changelog write --add-pr-num -n -vvv --use-version ${{ inputs.releaseVersion }}
|
run: pnpm --filter=woocommerce run changelog write --add-pr-num -n -vvv --use-version ${{ inputs.releaseVersion }}
|
||||||
|
|
||||||
|
- name: Checkout pnpm-lock.yaml to prevent issues
|
||||||
|
run: git checkout pnpm-lock.yaml
|
||||||
|
|
||||||
- name: 'git rm deleted files'
|
- name: 'git rm deleted files'
|
||||||
run: git rm $(git ls-files --deleted)
|
run: git rm $(git ls-files --deleted)
|
||||||
|
|
||||||
|
|
|
@ -60,7 +60,8 @@ jobs:
|
||||||
name: 'Maybe create next milestone and release branch'
|
name: 'Maybe create next milestone and release branch'
|
||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-20.04
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: write
|
||||||
|
issues: write
|
||||||
needs: verify-code-freeze
|
needs: verify-code-freeze
|
||||||
if: needs.verify-code-freeze.outputs.freeze == 0
|
if: needs.verify-code-freeze.outputs.freeze == 0
|
||||||
outputs:
|
outputs:
|
||||||
|
@ -89,7 +90,7 @@ jobs:
|
||||||
name: Preps trunk for next development cycle
|
name: Preps trunk for next development cycle
|
||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-20.04
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: write
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
needs: maybe-create-next-milestone-and-release-branch
|
needs: maybe-create-next-milestone-and-release-branch
|
||||||
steps:
|
steps:
|
||||||
|
|
|
@ -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,
|
UseComboboxState,
|
||||||
UseComboboxStateChangeOptions,
|
UseComboboxStateChangeOptions,
|
||||||
useMultipleSelection,
|
useMultipleSelection,
|
||||||
|
GetInputPropsOptions,
|
||||||
} from 'downshift';
|
} from 'downshift';
|
||||||
import {
|
import {
|
||||||
useState,
|
useState,
|
||||||
|
@ -65,6 +66,7 @@ export type SelectControlProps< ItemType > = {
|
||||||
selected: ItemType | ItemType[] | null;
|
selected: ItemType | ItemType[] | null;
|
||||||
className?: string;
|
className?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
inputProps?: GetInputPropsOptions;
|
||||||
suffix?: JSX.Element | null;
|
suffix?: JSX.Element | null;
|
||||||
/**
|
/**
|
||||||
* This is a feature already implemented in downshift@7.0.0 through the
|
* This is a feature already implemented in downshift@7.0.0 through the
|
||||||
|
@ -119,6 +121,7 @@ function SelectControl< ItemType = DefaultItemType >( {
|
||||||
selected,
|
selected,
|
||||||
className,
|
className,
|
||||||
disabled,
|
disabled,
|
||||||
|
inputProps = {},
|
||||||
suffix = <SuffixIcon icon={ search } />,
|
suffix = <SuffixIcon icon={ search } />,
|
||||||
__experimentalOpenMenuOnFocus = false,
|
__experimentalOpenMenuOnFocus = false,
|
||||||
}: SelectControlProps< ItemType > ) {
|
}: SelectControlProps< ItemType > ) {
|
||||||
|
@ -268,6 +271,7 @@ function SelectControl< ItemType = DefaultItemType >( {
|
||||||
onBlur: () => setIsFocused( false ),
|
onBlur: () => setIsFocused( false ),
|
||||||
placeholder,
|
placeholder,
|
||||||
disabled,
|
disabled,
|
||||||
|
...inputProps,
|
||||||
} ) }
|
} ) }
|
||||||
suffix={ suffix }
|
suffix={ suffix }
|
||||||
>
|
>
|
||||||
|
|
|
@ -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 { createOrderedChildren, sortFillsByOrder } from './utils';
|
||||||
export { WooProductFieldItem as __experimentalWooProductFieldItem } from './woo-product-field-item';
|
export { WooProductFieldItem as __experimentalWooProductFieldItem } from './woo-product-field-item';
|
||||||
export { WooProductSectionItem as __experimentalWooProductSectionItem } from './woo-product-section-item';
|
export { WooProductSectionItem as __experimentalWooProductSectionItem } from './woo-product-section-item';
|
||||||
|
export {
|
||||||
|
ProductSectionLayout as __experimentalProductSectionLayout,
|
||||||
|
ProductFieldSection as __experimentalProductFieldSection,
|
||||||
|
} from './product-section-layout';
|
||||||
|
export * from './product-fields';
|
||||||
|
export {
|
||||||
|
SlotContextProvider,
|
||||||
|
useSlotContext,
|
||||||
|
SlotContextType,
|
||||||
|
SlotContextHelpersType,
|
||||||
|
} from './slot-context';
|
||||||
|
|
|
@ -3,6 +3,10 @@
|
||||||
*/
|
*/
|
||||||
import { select } from '@wordpress/data';
|
import { select } from '@wordpress/data';
|
||||||
import { createElement } from '@wordpress/element';
|
import { createElement } from '@wordpress/element';
|
||||||
|
import {
|
||||||
|
// @ts-expect-error `__experimentalInputControl` does exist.
|
||||||
|
__experimentalInputControl as InputControl,
|
||||||
|
} from '@wordpress/components';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Internal dependencies
|
* Internal dependencies
|
||||||
|
@ -19,10 +23,7 @@ export function renderField( name: string, props: Record< string, any > ) {
|
||||||
return <fieldConfig.render { ...props } />;
|
return <fieldConfig.render { ...props } />;
|
||||||
}
|
}
|
||||||
if ( fieldConfig.type ) {
|
if ( fieldConfig.type ) {
|
||||||
return createElement( 'input', {
|
return <InputControl type={ fieldConfig.type } { ...props } />;
|
||||||
type: fieldConfig.type,
|
|
||||||
...props,
|
|
||||||
} );
|
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 { store } from './store';
|
||||||
export * from './api';
|
export * from './api';
|
||||||
|
export * from './fields';
|
||||||
|
|
|
@ -1,11 +1,11 @@
|
||||||
/**
|
/**
|
||||||
* External dependencies
|
* External dependencies
|
||||||
*/
|
*/
|
||||||
import { ComponentType } from 'react';
|
import { ComponentType, HTMLInputTypeAttribute } from 'react';
|
||||||
|
|
||||||
export type ProductFieldDefinition = {
|
export type ProductFieldDefinition = {
|
||||||
name: string;
|
name: string;
|
||||||
type?: string;
|
type?: HTMLInputTypeAttribute;
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
render?: ComponentType;
|
render?: ComponentType;
|
||||||
};
|
};
|
||||||
|
|
|
@ -3,56 +3,92 @@
|
||||||
*/
|
*/
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useState, createElement } from '@wordpress/element';
|
import { useState, createElement } from '@wordpress/element';
|
||||||
import { createRegistry, RegistryProvider, select } from '@wordpress/data';
|
import { createRegistry, RegistryProvider } from '@wordpress/data';
|
||||||
import {
|
|
||||||
// @ts-expect-error `__experimentalInputControl` does exist.
|
|
||||||
__experimentalInputControl as InputControl,
|
|
||||||
} from '@wordpress/components';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Internal dependencies
|
* Internal dependencies
|
||||||
*/
|
*/
|
||||||
import { store } from '../store';
|
import { store } from '../store';
|
||||||
import { registerProductField, renderField } from '../api';
|
import { renderField } from '../api';
|
||||||
|
import { registerCoreProductFields } from '../fields';
|
||||||
|
|
||||||
const registry = createRegistry();
|
const registry = createRegistry();
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
// @ts-ignore No types for this exist yet.
|
// @ts-ignore No types for this exist yet.
|
||||||
registry.register( store );
|
registry.register( store );
|
||||||
|
|
||||||
registerProductField( 'text', {
|
registerCoreProductFields();
|
||||||
name: 'text',
|
|
||||||
render: ( props ) => {
|
|
||||||
return <InputControl type="text" { ...props } />;
|
|
||||||
},
|
|
||||||
} );
|
|
||||||
|
|
||||||
registerProductField( 'number', {
|
const fieldConfigs = [
|
||||||
name: 'number',
|
{
|
||||||
render: () => {
|
name: 'text-field',
|
||||||
return <InputControl type="number" />;
|
type: 'text',
|
||||||
|
label: 'Text field',
|
||||||
},
|
},
|
||||||
} );
|
{
|
||||||
|
name: 'number-field',
|
||||||
|
type: 'number',
|
||||||
|
label: 'Number field',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'toggle-field',
|
||||||
|
type: 'toggle',
|
||||||
|
label: 'Toggle field',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'checkbox-field',
|
||||||
|
type: 'checkbox',
|
||||||
|
label: 'Checkbox field',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'radio-field',
|
||||||
|
type: 'radio',
|
||||||
|
label: 'Radio field',
|
||||||
|
options: [
|
||||||
|
{ label: 'Option', value: 'option' },
|
||||||
|
{ label: 'Option 2', value: 'option2' },
|
||||||
|
{ label: 'Option 3', value: 'option3' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'basic-select-control-field',
|
||||||
|
type: 'basic-select-control',
|
||||||
|
label: 'Basic select control field',
|
||||||
|
options: [
|
||||||
|
{ label: 'Option', value: 'option' },
|
||||||
|
{ label: 'Option 2', value: 'option2' },
|
||||||
|
{ label: 'Option 3', value: 'option3' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
const RenderField = () => {
|
const RenderField = () => {
|
||||||
const fields: string[] = select( store ).getRegisteredProductFields();
|
|
||||||
const [ selectedField, setSelectedField ] = useState(
|
const [ selectedField, setSelectedField ] = useState(
|
||||||
fields ? fields[ 0 ] : undefined
|
fieldConfigs[ 0 ].name || undefined
|
||||||
);
|
);
|
||||||
|
const [ value, setValue ] = useState();
|
||||||
|
|
||||||
const handleChange = ( event ) => {
|
const handleChange = ( event ) => {
|
||||||
setSelectedField( event.target.value );
|
setSelectedField( event.target.value );
|
||||||
};
|
};
|
||||||
|
const selectedFieldConfig = fieldConfigs.find(
|
||||||
|
( f ) => f.name === selectedField
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<select value={ selectedField } onChange={ handleChange }>
|
<select value={ selectedField } onChange={ handleChange }>
|
||||||
{ fields.map( ( field ) => (
|
{ fieldConfigs.map( ( field ) => (
|
||||||
<option key={ field } value={ field }>
|
<option key={ field.name } value={ field.name }>
|
||||||
{ field }
|
{ field.label }
|
||||||
</option>
|
</option>
|
||||||
) ) }
|
) ) }
|
||||||
</select>
|
</select>
|
||||||
{ selectedField && renderField( selectedField, { name: 'test' } ) }
|
{ selectedFieldConfig &&
|
||||||
|
renderField( selectedFieldConfig.type, {
|
||||||
|
value,
|
||||||
|
onChange: setValue,
|
||||||
|
...selectedFieldConfig,
|
||||||
|
} ) }
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -65,6 +101,21 @@ export const Basic: React.FC = () => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const ToggleWithTooltip: React.FC = () => {
|
||||||
|
const [ value, setValue ] = useState();
|
||||||
|
return (
|
||||||
|
<RegistryProvider value={ registry }>
|
||||||
|
{ renderField( 'toggle', {
|
||||||
|
value,
|
||||||
|
onChange: setValue,
|
||||||
|
name: 'toggle',
|
||||||
|
label: 'Toggle with Tooltip',
|
||||||
|
tooltip: 'This is a sample tooltip',
|
||||||
|
} ) }
|
||||||
|
</RegistryProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
title: 'WooCommerce Admin/experimental/product-fields',
|
title: 'WooCommerce Admin/experimental/product-fields',
|
||||||
component: Basic,
|
component: Basic,
|
||||||
|
|
|
@ -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 'tour-kit/style.scss';
|
||||||
@import 'collapsible-content/style.scss';
|
@import 'collapsible-content/style.scss';
|
||||||
@import 'form/style.scss';
|
@import 'form/style.scss';
|
||||||
|
@import 'product-section-layout/style.scss';
|
||||||
|
|
|
@ -1,39 +1,32 @@
|
||||||
/**
|
/**
|
||||||
* External dependencies
|
* External dependencies
|
||||||
*/
|
*/
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import { createElement } from '@wordpress/element';
|
import { createElement } from '@wordpress/element';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
type EmptyTableProps = {
|
||||||
|
children: React.ReactNode;
|
||||||
|
|
||||||
|
/** An integer with the number of rows the box should occupy. */
|
||||||
|
numberOfRows?: number;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `EmptyTable` displays a blank space with an optional message passed as a children node
|
* `EmptyTable` displays a blank space with an optional message passed as a children node
|
||||||
* with the purpose of replacing a table with no rows.
|
* with the purpose of replacing a table with no rows.
|
||||||
* It mimics the same height a table would have according to the `numberOfRows` prop.
|
* It mimics the same height a table would have according to the `numberOfRows` prop.
|
||||||
*
|
|
||||||
* @param {Object} props
|
|
||||||
* @param {Node} props.children
|
|
||||||
* @param {number} props.numberOfRows
|
|
||||||
* @return {Object} -
|
|
||||||
*/
|
*/
|
||||||
const EmptyTable = ( { children, numberOfRows } ) => {
|
const EmptyTable = ( { children, numberOfRows = 5 }: EmptyTableProps ) => {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="woocommerce-table is-empty"
|
className="woocommerce-table is-empty"
|
||||||
style={ { '--number-of-rows': numberOfRows } }
|
style={
|
||||||
|
{ '--number-of-rows': numberOfRows } as React.CSSProperties
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{ children }
|
{ children }
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
EmptyTable.propTypes = {
|
|
||||||
/**
|
|
||||||
* An integer with the number of rows the box should occupy.
|
|
||||||
*/
|
|
||||||
numberOfRows: PropTypes.number,
|
|
||||||
};
|
|
||||||
|
|
||||||
EmptyTable.defaultProps = {
|
|
||||||
numberOfRows: 5,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default EmptyTable;
|
export default EmptyTable;
|
|
@ -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
|
* External dependencies
|
||||||
*/
|
*/
|
||||||
import { EmptyTable } from '@woocommerce/components';
|
import { EmptyTable } from '@woocommerce/components';
|
||||||
|
import { createElement } from '@wordpress/element';
|
||||||
|
|
||||||
export const Basic = () => <EmptyTable>There are no entries.</EmptyTable>;
|
export const Basic = () => <EmptyTable>There are no entries.</EmptyTable>;
|
||||||
|
|
|
@ -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 { Card } from '@wordpress/components';
|
||||||
import { TablePlaceholder } from '@woocommerce/components';
|
import { TablePlaceholder } from '@woocommerce/components';
|
||||||
|
import { createElement } from '@wordpress/element';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Internal dependencies
|
* Internal dependencies
|
||||||
*/
|
*/
|
||||||
import { headers } from './index';
|
import { headers } from './index';
|
||||||
|
|
||||||
export const Basic = () => (
|
export const Basic = () => {
|
||||||
|
return (
|
||||||
|
/* @ts-expect-error: size must be one of small, medium, largel, xSmall, extraSmall. */
|
||||||
<Card size={ null }>
|
<Card size={ null }>
|
||||||
<TablePlaceholder caption="Revenue last week" headers={ headers } />
|
<TablePlaceholder caption="Revenue last week" headers={ headers } />
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
title: 'WooCommerce Admin/components/TablePlaceholder',
|
title: 'WooCommerce Admin/components/TablePlaceholder',
|
|
@ -3,14 +3,18 @@
|
||||||
*/
|
*/
|
||||||
import { Card, CardFooter } from '@wordpress/components';
|
import { Card, CardFooter } from '@wordpress/components';
|
||||||
import { TableSummaryPlaceholder } from '@woocommerce/components';
|
import { TableSummaryPlaceholder } from '@woocommerce/components';
|
||||||
|
import { createElement } from '@wordpress/element';
|
||||||
|
|
||||||
export const Basic = () => (
|
export const Basic = () => {
|
||||||
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
|
{ /* @ts-expect-error: justify is missing from the latest type def. */ }
|
||||||
<CardFooter justify="center">
|
<CardFooter justify="center">
|
||||||
<TableSummaryPlaceholder />
|
<TableSummaryPlaceholder />
|
||||||
</CardFooter>
|
</CardFooter>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
title: 'WooCommerce Admin/components/TableSummaryPlaceholder',
|
title: 'WooCommerce Admin/components/TableSummaryPlaceholder',
|
|
@ -3,6 +3,7 @@
|
||||||
*/
|
*/
|
||||||
import { Card } from '@wordpress/components';
|
import { Card } from '@wordpress/components';
|
||||||
import { Table } from '@woocommerce/components';
|
import { Table } from '@woocommerce/components';
|
||||||
|
import { createElement } from '@wordpress/element';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Internal dependencies
|
* Internal dependencies
|
||||||
|
@ -20,7 +21,9 @@ export const Basic = () => (
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|
||||||
export const NoDataCustomMessage = () => (
|
export const NoDataCustomMessage = () => {
|
||||||
|
return (
|
||||||
|
/* @ts-expect-error: size must be one of small, medium, largel, xSmall, extraSmall. */
|
||||||
<Card size={ null }>
|
<Card size={ null }>
|
||||||
<Table
|
<Table
|
||||||
caption="Revenue last week"
|
caption="Revenue last week"
|
||||||
|
@ -31,6 +34,7 @@ export const NoDataCustomMessage = () => (
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
title: 'WooCommerce Admin/components/Table',
|
title: 'WooCommerce Admin/components/Table',
|
|
@ -1,17 +1,17 @@
|
||||||
/**
|
/**
|
||||||
* External dependencies
|
* External dependencies
|
||||||
*/
|
*/
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import { createElement } from '@wordpress/element';
|
import { createElement } from '@wordpress/element';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A component to display summarized table data - the list of data passed in on a single line.
|
* Internal dependencies
|
||||||
*
|
|
||||||
* @param {Object} props
|
|
||||||
* @param {Array} props.data
|
|
||||||
* @return {Object} -
|
|
||||||
*/
|
*/
|
||||||
const TableSummary = ( { data } ) => {
|
import { TableSummaryProps } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A component to display summarized table data - the list of data passed in on a single line.
|
||||||
|
*/
|
||||||
|
const TableSummary = ( { data }: TableSummaryProps ) => {
|
||||||
return (
|
return (
|
||||||
<ul className="woocommerce-table__summary" role="complementary">
|
<ul className="woocommerce-table__summary" role="complementary">
|
||||||
{ data.map( ( { label, value }, i ) => (
|
{ data.map( ( { label, value }, i ) => (
|
||||||
|
@ -28,13 +28,6 @@ const TableSummary = ( { data } ) => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
TableSummary.propTypes = {
|
|
||||||
/**
|
|
||||||
* An array of objects with `label` & `value` properties, which display on a single line.
|
|
||||||
*/
|
|
||||||
data: PropTypes.array,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default TableSummary;
|
export default TableSummary;
|
||||||
|
|
||||||
/**
|
/**
|
|
@ -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
|
* External dependencies
|
||||||
*/
|
*/
|
||||||
import React, { isValidElement, Fragment } from 'react';
|
import { isValidElement, Fragment } from 'react';
|
||||||
import { Slot, Fill } from '@wordpress/components';
|
import { Slot, Fill } from '@wordpress/components';
|
||||||
import { cloneElement, createElement } from '@wordpress/element';
|
import { cloneElement, createElement } from '@wordpress/element';
|
||||||
|
|
||||||
|
@ -13,15 +13,16 @@ import { cloneElement, createElement } from '@wordpress/element';
|
||||||
* @param {Array} props - Fill props.
|
* @param {Array} props - Fill props.
|
||||||
* @return {Node} Node.
|
* @return {Node} Node.
|
||||||
*/
|
*/
|
||||||
function createOrderedChildren< T = Fill.Props >(
|
function createOrderedChildren< T = Fill.Props, S = Record< string, unknown > >(
|
||||||
children: React.ReactNode,
|
children: React.ReactNode,
|
||||||
order: number,
|
order: number,
|
||||||
props: T
|
props: T,
|
||||||
|
injectProps?: S
|
||||||
) {
|
) {
|
||||||
if ( typeof children === 'function' ) {
|
if ( typeof children === 'function' ) {
|
||||||
return cloneElement( children( props ), { order } );
|
return cloneElement( children( props ), { order, ...injectProps } );
|
||||||
} else if ( isValidElement( children ) ) {
|
} else if ( isValidElement( children ) ) {
|
||||||
return cloneElement( children, { ...props, order } );
|
return cloneElement( children, { ...props, order, ...injectProps } );
|
||||||
}
|
}
|
||||||
throw Error( 'Invalid children type' );
|
throw Error( 'Invalid children type' );
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,6 +9,7 @@ import { createElement, Children } from '@wordpress/element';
|
||||||
* Internal dependencies
|
* Internal dependencies
|
||||||
*/
|
*/
|
||||||
import { createOrderedChildren, sortFillsByOrder } from '../utils';
|
import { createOrderedChildren, sortFillsByOrder } from '../utils';
|
||||||
|
import { useSlotContext, SlotContextHelpersType } from '../slot-context';
|
||||||
|
|
||||||
type WooProductFieldItemProps = {
|
type WooProductFieldItemProps = {
|
||||||
id: string;
|
id: string;
|
||||||
|
@ -23,19 +24,36 @@ type WooProductFieldSlotProps = {
|
||||||
|
|
||||||
export const WooProductFieldItem: React.FC< WooProductFieldItemProps > & {
|
export const WooProductFieldItem: React.FC< WooProductFieldItemProps > & {
|
||||||
Slot: React.FC< Slot.Props & WooProductFieldSlotProps >;
|
Slot: React.FC< Slot.Props & WooProductFieldSlotProps >;
|
||||||
} = ( { children, order = 1, section } ) => (
|
} = ( { children, order = 20, section, id } ) => {
|
||||||
|
const { registerFill, getFillHelpers } = useSlotContext();
|
||||||
|
|
||||||
|
registerFill( id );
|
||||||
|
|
||||||
|
return (
|
||||||
<Fill name={ `woocommerce_product_field_${ section }` }>
|
<Fill name={ `woocommerce_product_field_${ section }` }>
|
||||||
{ ( fillProps: Fill.Props ) => {
|
{ ( fillProps: Fill.Props ) => {
|
||||||
return createOrderedChildren< Fill.Props >(
|
return createOrderedChildren<
|
||||||
|
Fill.Props & SlotContextHelpersType,
|
||||||
|
{ _id: string }
|
||||||
|
>(
|
||||||
children,
|
children,
|
||||||
order,
|
order,
|
||||||
fillProps
|
{
|
||||||
|
...fillProps,
|
||||||
|
...getFillHelpers(),
|
||||||
|
},
|
||||||
|
{ _id: id }
|
||||||
);
|
);
|
||||||
} }
|
} }
|
||||||
</Fill>
|
</Fill>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
WooProductFieldItem.Slot = ( { fillProps, section } ) => (
|
WooProductFieldItem.Slot = ( { fillProps, section } ) => {
|
||||||
|
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||||
|
const { filterRegisteredFills } = useSlotContext();
|
||||||
|
|
||||||
|
return (
|
||||||
<Slot
|
<Slot
|
||||||
name={ `woocommerce_product_field_${ section }` }
|
name={ `woocommerce_product_field_${ section }` }
|
||||||
fillProps={ fillProps }
|
fillProps={ fillProps }
|
||||||
|
@ -46,7 +64,8 @@ WooProductFieldItem.Slot = ( { fillProps, section } ) => (
|
||||||
}
|
}
|
||||||
|
|
||||||
return Children.map(
|
return Children.map(
|
||||||
sortFillsByOrder( fills )?.props.children,
|
sortFillsByOrder( filterRegisteredFills( fills ) )?.props
|
||||||
|
.children,
|
||||||
( child ) => (
|
( child ) => (
|
||||||
<div className="woocommerce-product-form__field">
|
<div className="woocommerce-product-form__field">
|
||||||
{ child }
|
{ child }
|
||||||
|
@ -56,3 +75,4 @@ WooProductFieldItem.Slot = ( { fillProps, section } ) => (
|
||||||
} }
|
} }
|
||||||
</Slot>
|
</Slot>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
|
@ -23,7 +23,7 @@ type WooProductFieldSlotProps = {
|
||||||
|
|
||||||
export const WooProductSectionItem: React.FC< WooProductSectionItemProps > & {
|
export const WooProductSectionItem: React.FC< WooProductSectionItemProps > & {
|
||||||
Slot: React.FC< Slot.Props & WooProductFieldSlotProps >;
|
Slot: React.FC< Slot.Props & WooProductFieldSlotProps >;
|
||||||
} = ( { children, order = 1, location } ) => (
|
} = ( { children, order = 20, location } ) => (
|
||||||
<Fill name={ `woocommerce_product_section_${ location }` }>
|
<Fill name={ `woocommerce_product_section_${ location }` }>
|
||||||
{ ( fillProps: Fill.Props ) => {
|
{ ( fillProps: Fill.Props ) => {
|
||||||
return createOrderedChildren< Fill.Props >(
|
return createOrderedChildren< Fill.Props >(
|
||||||
|
|
|
@ -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 {
|
export enum TYPES {
|
||||||
CREATE_ITEM_ERROR = 'CREATE_ITEM_ERROR',
|
CREATE_ITEM_ERROR = 'CREATE_ITEM_ERROR',
|
||||||
|
CREATE_ITEM_REQUEST = 'CREATE_ITEM_REQUEST',
|
||||||
CREATE_ITEM_SUCCESS = 'CREATE_ITEM_SUCCESS',
|
CREATE_ITEM_SUCCESS = 'CREATE_ITEM_SUCCESS',
|
||||||
DELETE_ITEM_ERROR = 'DELETE_ITEM_ERROR',
|
DELETE_ITEM_ERROR = 'DELETE_ITEM_ERROR',
|
||||||
|
DELETE_ITEM_REQUEST = 'DELETE_ITEM_REQUEST',
|
||||||
DELETE_ITEM_SUCCESS = 'DELETE_ITEM_SUCCESS',
|
DELETE_ITEM_SUCCESS = 'DELETE_ITEM_SUCCESS',
|
||||||
GET_ITEM_ERROR = 'GET_ITEM_ERROR',
|
GET_ITEM_ERROR = 'GET_ITEM_ERROR',
|
||||||
GET_ITEM_SUCCESS = 'GET_ITEM_SUCCESS',
|
GET_ITEM_SUCCESS = 'GET_ITEM_SUCCESS',
|
||||||
GET_ITEMS_ERROR = 'GET_ITEMS_ERROR',
|
GET_ITEMS_ERROR = 'GET_ITEMS_ERROR',
|
||||||
GET_ITEMS_SUCCESS = 'GET_ITEMS_SUCCESS',
|
GET_ITEMS_SUCCESS = 'GET_ITEMS_SUCCESS',
|
||||||
UPDATE_ITEM_ERROR = 'UPDATE_ITEM_ERROR',
|
UPDATE_ITEM_ERROR = 'UPDATE_ITEM_ERROR',
|
||||||
|
UPDATE_ITEM_REQUEST = 'UPDATE_ITEM_REQUEST',
|
||||||
UPDATE_ITEM_SUCCESS = 'UPDATE_ITEM_SUCCESS',
|
UPDATE_ITEM_SUCCESS = 'UPDATE_ITEM_SUCCESS',
|
||||||
GET_ITEMS_TOTAL_COUNT_SUCCESS = 'GET_ITEMS_TOTAL_COUNT_SUCCESS',
|
GET_ITEMS_TOTAL_COUNT_SUCCESS = 'GET_ITEMS_TOTAL_COUNT_SUCCESS',
|
||||||
GET_ITEMS_TOTAL_COUNT_ERROR = 'GET_ITEMS_TOTAL_COUNT_ERROR',
|
GET_ITEMS_TOTAL_COUNT_ERROR = 'GET_ITEMS_TOTAL_COUNT_ERROR',
|
||||||
|
|
|
@ -25,20 +25,41 @@ export function createItemError( query: Partial< ItemQuery >, error: unknown ) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createItemSuccess( key: IdType, item: Item ) {
|
export function createItemRequest( query: Partial< ItemQuery > ) {
|
||||||
|
return {
|
||||||
|
type: TYPES.CREATE_ITEM_REQUEST as const,
|
||||||
|
query,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createItemSuccess(
|
||||||
|
key: IdType,
|
||||||
|
item: Item,
|
||||||
|
query: Partial< ItemQuery >
|
||||||
|
) {
|
||||||
return {
|
return {
|
||||||
type: TYPES.CREATE_ITEM_SUCCESS as const,
|
type: TYPES.CREATE_ITEM_SUCCESS as const,
|
||||||
key,
|
key,
|
||||||
item,
|
item,
|
||||||
|
query,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteItemError( key: IdType, error: unknown ) {
|
export function deleteItemError( key: IdType, error: unknown, force: boolean ) {
|
||||||
return {
|
return {
|
||||||
type: TYPES.DELETE_ITEM_ERROR as const,
|
type: TYPES.DELETE_ITEM_ERROR as const,
|
||||||
key,
|
key,
|
||||||
error,
|
error,
|
||||||
errorType: CRUD_ACTIONS.DELETE_ITEM,
|
errorType: CRUD_ACTIONS.DELETE_ITEM,
|
||||||
|
force,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteItemRequest( key: IdType, force: boolean ) {
|
||||||
|
return {
|
||||||
|
type: TYPES.DELETE_ITEM_REQUEST as const,
|
||||||
|
key,
|
||||||
|
force,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -116,20 +137,38 @@ export function getItemsTotalCountError(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateItemError( key: IdType, error: unknown ) {
|
export function updateItemError(
|
||||||
|
key: IdType,
|
||||||
|
error: unknown,
|
||||||
|
query: Partial< ItemQuery >
|
||||||
|
) {
|
||||||
return {
|
return {
|
||||||
type: TYPES.UPDATE_ITEM_ERROR as const,
|
type: TYPES.UPDATE_ITEM_ERROR as const,
|
||||||
key,
|
key,
|
||||||
error,
|
error,
|
||||||
errorType: CRUD_ACTIONS.UPDATE_ITEM,
|
errorType: CRUD_ACTIONS.UPDATE_ITEM,
|
||||||
|
query,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateItemSuccess( key: IdType, item: Item ) {
|
export function updateItemRequest( key: IdType, query: Partial< ItemQuery > ) {
|
||||||
|
return {
|
||||||
|
type: TYPES.UPDATE_ITEM_REQUEST as const,
|
||||||
|
key,
|
||||||
|
query,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateItemSuccess(
|
||||||
|
key: IdType,
|
||||||
|
item: Item,
|
||||||
|
query: Partial< ItemQuery >
|
||||||
|
) {
|
||||||
return {
|
return {
|
||||||
type: TYPES.UPDATE_ITEM_SUCCESS as const,
|
type: TYPES.UPDATE_ITEM_SUCCESS as const,
|
||||||
key,
|
key,
|
||||||
item,
|
item,
|
||||||
|
query,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -138,6 +177,7 @@ export const createDispatchActions = ( {
|
||||||
resourceName,
|
resourceName,
|
||||||
}: ResolverOptions ) => {
|
}: ResolverOptions ) => {
|
||||||
const createItem = function* ( query: Partial< ItemQuery > ) {
|
const createItem = function* ( query: Partial< ItemQuery > ) {
|
||||||
|
yield createItemRequest( query );
|
||||||
const urlParameters = getUrlParameters( namespace, query );
|
const urlParameters = getUrlParameters( namespace, query );
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
@ -151,7 +191,7 @@ export const createDispatchActions = ( {
|
||||||
} );
|
} );
|
||||||
const { key } = parseId( item.id, urlParameters );
|
const { key } = parseId( item.id, urlParameters );
|
||||||
|
|
||||||
yield createItemSuccess( key, item );
|
yield createItemSuccess( key, item, query );
|
||||||
return item;
|
return item;
|
||||||
} catch ( error ) {
|
} catch ( error ) {
|
||||||
yield createItemError( query, error );
|
yield createItemError( query, error );
|
||||||
|
@ -162,6 +202,7 @@ export const createDispatchActions = ( {
|
||||||
const deleteItem = function* ( idQuery: IdQuery, force = true ) {
|
const deleteItem = function* ( idQuery: IdQuery, force = true ) {
|
||||||
const urlParameters = getUrlParameters( namespace, idQuery );
|
const urlParameters = getUrlParameters( namespace, idQuery );
|
||||||
const { id, key } = parseId( idQuery, urlParameters );
|
const { id, key } = parseId( idQuery, urlParameters );
|
||||||
|
yield deleteItemRequest( key, force );
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const item: Item = yield apiFetch( {
|
const item: Item = yield apiFetch( {
|
||||||
|
@ -176,7 +217,7 @@ export const createDispatchActions = ( {
|
||||||
yield deleteItemSuccess( key, force, item );
|
yield deleteItemSuccess( key, force, item );
|
||||||
return item;
|
return item;
|
||||||
} catch ( error ) {
|
} catch ( error ) {
|
||||||
yield deleteItemError( key, error );
|
yield deleteItemError( key, error, force );
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -187,6 +228,7 @@ export const createDispatchActions = ( {
|
||||||
) {
|
) {
|
||||||
const urlParameters = getUrlParameters( namespace, idQuery );
|
const urlParameters = getUrlParameters( namespace, idQuery );
|
||||||
const { id, key } = parseId( idQuery, urlParameters );
|
const { id, key } = parseId( idQuery, urlParameters );
|
||||||
|
yield updateItemRequest( key, query );
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const item: Item = yield apiFetch( {
|
const item: Item = yield apiFetch( {
|
||||||
|
@ -199,10 +241,10 @@ export const createDispatchActions = ( {
|
||||||
data: query,
|
data: query,
|
||||||
} );
|
} );
|
||||||
|
|
||||||
yield updateItemSuccess( key, item );
|
yield updateItemSuccess( key, item, query );
|
||||||
return item;
|
return item;
|
||||||
} catch ( error ) {
|
} catch ( error ) {
|
||||||
yield updateItemError( key, error );
|
yield updateItemError( key, error, query );
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -216,8 +258,10 @@ export const createDispatchActions = ( {
|
||||||
|
|
||||||
export type Actions = ReturnType<
|
export type Actions = ReturnType<
|
||||||
| typeof createItemError
|
| typeof createItemError
|
||||||
|
| typeof createItemRequest
|
||||||
| typeof createItemSuccess
|
| typeof createItemSuccess
|
||||||
| typeof deleteItemError
|
| typeof deleteItemError
|
||||||
|
| typeof deleteItemRequest
|
||||||
| typeof deleteItemSuccess
|
| typeof deleteItemSuccess
|
||||||
| typeof getItemError
|
| typeof getItemError
|
||||||
| typeof getItemSuccess
|
| typeof getItemSuccess
|
||||||
|
@ -226,5 +270,6 @@ export type Actions = ReturnType<
|
||||||
| typeof getItemsTotalCountSuccess
|
| typeof getItemsTotalCountSuccess
|
||||||
| typeof getItemsTotalCountError
|
| typeof getItemsTotalCountError
|
||||||
| typeof updateItemError
|
| typeof updateItemError
|
||||||
|
| typeof updateItemRequest
|
||||||
| typeof updateItemSuccess
|
| typeof updateItemSuccess
|
||||||
>;
|
>;
|
||||||
|
|
|
@ -8,8 +8,8 @@ import { Reducer } from 'redux';
|
||||||
*/
|
*/
|
||||||
import { Actions } from './actions';
|
import { Actions } from './actions';
|
||||||
import CRUD_ACTIONS from './crud-actions';
|
import CRUD_ACTIONS from './crud-actions';
|
||||||
import { getKey } from './utils';
|
import { getKey, getRequestIdentifier } from './utils';
|
||||||
import { getResourceName, getTotalCountResourceName } from '../utils';
|
import { getTotalCountResourceName } from '../utils';
|
||||||
import { IdType, Item, ItemQuery } from './types';
|
import { IdType, Item, ItemQuery } from './types';
|
||||||
import { TYPES } from './action-types';
|
import { TYPES } from './action-types';
|
||||||
|
|
||||||
|
@ -24,6 +24,7 @@ export type ResourceState = {
|
||||||
data: Data;
|
data: Data;
|
||||||
itemsCount: Record< string, number >;
|
itemsCount: Record< string, number >;
|
||||||
errors: Record< string, unknown >;
|
errors: Record< string, unknown >;
|
||||||
|
requesting: Record< string, boolean >;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createReducer = () => {
|
export const createReducer = () => {
|
||||||
|
@ -33,19 +34,37 @@ export const createReducer = () => {
|
||||||
data: {},
|
data: {},
|
||||||
itemsCount: {},
|
itemsCount: {},
|
||||||
errors: {},
|
errors: {},
|
||||||
|
requesting: {},
|
||||||
},
|
},
|
||||||
payload
|
payload
|
||||||
) => {
|
) => {
|
||||||
|
const itemData = state.data || {};
|
||||||
|
|
||||||
if ( payload && 'type' in payload ) {
|
if ( payload && 'type' in payload ) {
|
||||||
switch ( payload.type ) {
|
switch ( payload.type ) {
|
||||||
case TYPES.CREATE_ITEM_ERROR:
|
case TYPES.CREATE_ITEM_ERROR:
|
||||||
|
const createItemErrorRequestId = getRequestIdentifier(
|
||||||
|
payload.errorType,
|
||||||
|
payload.query || {}
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
errors: {
|
||||||
|
...state.errors,
|
||||||
|
[ createItemErrorRequestId ]: payload.error,
|
||||||
|
},
|
||||||
|
requesting: {
|
||||||
|
...state.requesting,
|
||||||
|
[ createItemErrorRequestId ]: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
case TYPES.GET_ITEMS_TOTAL_COUNT_ERROR:
|
case TYPES.GET_ITEMS_TOTAL_COUNT_ERROR:
|
||||||
case TYPES.GET_ITEMS_ERROR:
|
case TYPES.GET_ITEMS_ERROR:
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
errors: {
|
errors: {
|
||||||
...state.errors,
|
...state.errors,
|
||||||
[ getResourceName(
|
[ getRequestIdentifier(
|
||||||
payload.errorType,
|
payload.errorType,
|
||||||
( payload.query || {} ) as ItemQuery
|
( payload.query || {} ) as ItemQuery
|
||||||
) ]: payload.error,
|
) ]: payload.error,
|
||||||
|
@ -64,9 +83,27 @@ export const createReducer = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
case TYPES.CREATE_ITEM_SUCCESS:
|
case TYPES.CREATE_ITEM_SUCCESS:
|
||||||
|
const createItemSuccessRequestId = getRequestIdentifier(
|
||||||
|
CRUD_ACTIONS.CREATE_ITEM,
|
||||||
|
payload.key,
|
||||||
|
payload.query
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
data: {
|
||||||
|
...itemData,
|
||||||
|
[ payload.key ]: {
|
||||||
|
...( itemData[ payload.key ] || {} ),
|
||||||
|
...payload.item,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
requesting: {
|
||||||
|
...state.requesting,
|
||||||
|
[ createItemSuccessRequestId ]: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
case TYPES.GET_ITEM_SUCCESS:
|
case TYPES.GET_ITEM_SUCCESS:
|
||||||
case TYPES.UPDATE_ITEM_SUCCESS:
|
|
||||||
const itemData = state.data || {};
|
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
data: {
|
data: {
|
||||||
|
@ -78,7 +115,33 @@ export const createReducer = () => {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
case TYPES.UPDATE_ITEM_SUCCESS:
|
||||||
|
const updateItemSuccessRequestId = getRequestIdentifier(
|
||||||
|
CRUD_ACTIONS.UPDATE_ITEM,
|
||||||
|
payload.key,
|
||||||
|
payload.query
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
data: {
|
||||||
|
...itemData,
|
||||||
|
[ payload.key ]: {
|
||||||
|
...( itemData[ payload.key ] || {} ),
|
||||||
|
...payload.item,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
requesting: {
|
||||||
|
...state.requesting,
|
||||||
|
[ updateItemSuccessRequestId ]: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
case TYPES.DELETE_ITEM_SUCCESS:
|
case TYPES.DELETE_ITEM_SUCCESS:
|
||||||
|
const deleteItemSuccessRequestId = getRequestIdentifier(
|
||||||
|
CRUD_ACTIONS.DELETE_ITEM,
|
||||||
|
payload.key,
|
||||||
|
payload.force
|
||||||
|
);
|
||||||
const itemKeys = Object.keys( state.data );
|
const itemKeys = Object.keys( state.data );
|
||||||
const nextData = itemKeys.reduce< Data >(
|
const nextData = itemKeys.reduce< Data >(
|
||||||
( items: Data, key: string ) => {
|
( items: Data, key: string ) => {
|
||||||
|
@ -98,18 +161,57 @@ export const createReducer = () => {
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
data: nextData,
|
data: nextData,
|
||||||
|
requesting: {
|
||||||
|
...state.requesting,
|
||||||
|
[ deleteItemSuccessRequestId ]: false,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
case TYPES.DELETE_ITEM_ERROR:
|
case TYPES.DELETE_ITEM_ERROR:
|
||||||
case TYPES.GET_ITEM_ERROR:
|
const deleteItemErrorRequestId = getRequestIdentifier(
|
||||||
case TYPES.UPDATE_ITEM_ERROR:
|
payload.errorType,
|
||||||
|
payload.key,
|
||||||
|
payload.force
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
errors: {
|
errors: {
|
||||||
...state.errors,
|
...state.errors,
|
||||||
[ getResourceName( payload.errorType, {
|
[ deleteItemErrorRequestId ]: payload.error,
|
||||||
key: payload.key,
|
},
|
||||||
} ) ]: payload.error,
|
requesting: {
|
||||||
|
...state.requesting,
|
||||||
|
[ deleteItemErrorRequestId ]: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
case TYPES.GET_ITEM_ERROR:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
errors: {
|
||||||
|
...state.errors,
|
||||||
|
[ getRequestIdentifier(
|
||||||
|
payload.errorType,
|
||||||
|
payload.key
|
||||||
|
) ]: payload.error,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
case TYPES.UPDATE_ITEM_ERROR:
|
||||||
|
const upateItemErrorRequestId = getRequestIdentifier(
|
||||||
|
payload.errorType,
|
||||||
|
payload.key,
|
||||||
|
payload.query
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
errors: {
|
||||||
|
...state.errors,
|
||||||
|
[ upateItemErrorRequestId ]: payload.error,
|
||||||
|
},
|
||||||
|
requesting: {
|
||||||
|
...state.requesting,
|
||||||
|
[ upateItemErrorRequestId ]: false,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -128,7 +230,7 @@ export const createReducer = () => {
|
||||||
return result;
|
return result;
|
||||||
}, {} );
|
}, {} );
|
||||||
|
|
||||||
const itemQuery = getResourceName(
|
const itemQuery = getRequestIdentifier(
|
||||||
CRUD_ACTIONS.GET_ITEMS,
|
CRUD_ACTIONS.GET_ITEMS,
|
||||||
( payload.query || {} ) as ItemQuery
|
( payload.query || {} ) as ItemQuery
|
||||||
);
|
);
|
||||||
|
@ -145,6 +247,44 @@ export const createReducer = () => {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
case TYPES.CREATE_ITEM_REQUEST:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
requesting: {
|
||||||
|
...state.requesting,
|
||||||
|
[ getRequestIdentifier(
|
||||||
|
CRUD_ACTIONS.CREATE_ITEM,
|
||||||
|
payload.query
|
||||||
|
) ]: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
case TYPES.DELETE_ITEM_REQUEST:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
requesting: {
|
||||||
|
...state.requesting,
|
||||||
|
[ getRequestIdentifier(
|
||||||
|
CRUD_ACTIONS.DELETE_ITEM,
|
||||||
|
payload.key,
|
||||||
|
payload.force
|
||||||
|
) ]: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
case TYPES.UPDATE_ITEM_REQUEST:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
requesting: {
|
||||||
|
...state.requesting,
|
||||||
|
[ getRequestIdentifier(
|
||||||
|
CRUD_ACTIONS.UPDATE_ITEM,
|
||||||
|
payload.key,
|
||||||
|
payload.query
|
||||||
|
) ]: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,8 +6,15 @@ import createSelector from 'rememo';
|
||||||
/**
|
/**
|
||||||
* Internal dependencies
|
* Internal dependencies
|
||||||
*/
|
*/
|
||||||
import { applyNamespace, getUrlParameters, parseId } from './utils';
|
import {
|
||||||
import { getResourceName, getTotalCountResourceName } from '../utils';
|
applyNamespace,
|
||||||
|
getGenericActionName,
|
||||||
|
getRequestIdentifier,
|
||||||
|
getUrlParameters,
|
||||||
|
maybeReplaceIdQuery,
|
||||||
|
parseId,
|
||||||
|
} from './utils';
|
||||||
|
import { getTotalCountResourceName } from '../utils';
|
||||||
import { IdQuery, IdType, Item, ItemQuery } from './types';
|
import { IdQuery, IdType, Item, ItemQuery } from './types';
|
||||||
import { ResourceState } from './reducer';
|
import { ResourceState } from './reducer';
|
||||||
import CRUD_ACTIONS from './crud-actions';
|
import CRUD_ACTIONS from './crud-actions';
|
||||||
|
@ -22,7 +29,7 @@ export const getItemCreateError = (
|
||||||
state: ResourceState,
|
state: ResourceState,
|
||||||
query: ItemQuery
|
query: ItemQuery
|
||||||
) => {
|
) => {
|
||||||
const itemQuery = getResourceName( CRUD_ACTIONS.CREATE_ITEM, query );
|
const itemQuery = getRequestIdentifier( CRUD_ACTIONS.CREATE_ITEM, query );
|
||||||
return state.errors[ itemQuery ];
|
return state.errors[ itemQuery ];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -33,7 +40,7 @@ export const getItemDeleteError = (
|
||||||
) => {
|
) => {
|
||||||
const urlParameters = getUrlParameters( namespace, idQuery );
|
const urlParameters = getUrlParameters( namespace, idQuery );
|
||||||
const { key } = parseId( idQuery, urlParameters );
|
const { key } = parseId( idQuery, urlParameters );
|
||||||
const itemQuery = getResourceName( CRUD_ACTIONS.DELETE_ITEM, { key } );
|
const itemQuery = getRequestIdentifier( CRUD_ACTIONS.DELETE_ITEM, key );
|
||||||
return state.errors[ itemQuery ];
|
return state.errors[ itemQuery ];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -54,13 +61,13 @@ export const getItemError = (
|
||||||
) => {
|
) => {
|
||||||
const urlParameters = getUrlParameters( namespace, idQuery );
|
const urlParameters = getUrlParameters( namespace, idQuery );
|
||||||
const { key } = parseId( idQuery, urlParameters );
|
const { key } = parseId( idQuery, urlParameters );
|
||||||
const itemQuery = getResourceName( CRUD_ACTIONS.GET_ITEM, { key } );
|
const itemQuery = getRequestIdentifier( CRUD_ACTIONS.GET_ITEM, key );
|
||||||
return state.errors[ itemQuery ];
|
return state.errors[ itemQuery ];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getItems = createSelector(
|
export const getItems = createSelector(
|
||||||
( state: ResourceState, query?: ItemQuery ) => {
|
( state: ResourceState, query?: ItemQuery ) => {
|
||||||
const itemQuery = getResourceName(
|
const itemQuery = getRequestIdentifier(
|
||||||
CRUD_ACTIONS.GET_ITEMS,
|
CRUD_ACTIONS.GET_ITEMS,
|
||||||
query || {}
|
query || {}
|
||||||
);
|
);
|
||||||
|
@ -96,7 +103,7 @@ export const getItems = createSelector(
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
( state, query ) => {
|
( state, query ) => {
|
||||||
const itemQuery = getResourceName(
|
const itemQuery = getRequestIdentifier(
|
||||||
CRUD_ACTIONS.GET_ITEMS,
|
CRUD_ACTIONS.GET_ITEMS,
|
||||||
query || {}
|
query || {}
|
||||||
);
|
);
|
||||||
|
@ -129,7 +136,10 @@ export const getItemsTotalCount = (
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getItemsError = ( state: ResourceState, query?: ItemQuery ) => {
|
export const getItemsError = ( state: ResourceState, query?: ItemQuery ) => {
|
||||||
const itemQuery = getResourceName( CRUD_ACTIONS.GET_ITEMS, query || {} );
|
const itemQuery = getRequestIdentifier(
|
||||||
|
CRUD_ACTIONS.GET_ITEMS,
|
||||||
|
query || {}
|
||||||
|
);
|
||||||
return state.errors[ itemQuery ];
|
return state.errors[ itemQuery ];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -138,12 +148,8 @@ export const getItemUpdateError = (
|
||||||
idQuery: IdQuery,
|
idQuery: IdQuery,
|
||||||
urlParameters: IdType[]
|
urlParameters: IdType[]
|
||||||
) => {
|
) => {
|
||||||
const params = parseId( idQuery, urlParameters );
|
const { key } = parseId( idQuery, urlParameters );
|
||||||
const { key } = params;
|
const itemQuery = getRequestIdentifier( CRUD_ACTIONS.UPDATE_ITEM, key );
|
||||||
const itemQuery = getResourceName( CRUD_ACTIONS.UPDATE_ITEM, {
|
|
||||||
key,
|
|
||||||
params,
|
|
||||||
} );
|
|
||||||
return state.errors[ itemQuery ];
|
return state.errors[ itemQuery ];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -154,6 +160,32 @@ export const createSelectors = ( {
|
||||||
pluralResourceName,
|
pluralResourceName,
|
||||||
namespace,
|
namespace,
|
||||||
}: SelectorOptions ) => {
|
}: SelectorOptions ) => {
|
||||||
|
const hasFinishedRequest = (
|
||||||
|
state: ResourceState,
|
||||||
|
action: string,
|
||||||
|
args = []
|
||||||
|
) => {
|
||||||
|
const sanitizedArgs = maybeReplaceIdQuery( args, namespace );
|
||||||
|
const actionName = getGenericActionName( action, resourceName );
|
||||||
|
const requestId = getRequestIdentifier( actionName, ...sanitizedArgs );
|
||||||
|
if ( action )
|
||||||
|
return (
|
||||||
|
state.requesting.hasOwnProperty( requestId ) &&
|
||||||
|
! state.requesting[ requestId ]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isRequesting = (
|
||||||
|
state: ResourceState,
|
||||||
|
action: string,
|
||||||
|
args = []
|
||||||
|
) => {
|
||||||
|
const sanitizedArgs = maybeReplaceIdQuery( args, namespace );
|
||||||
|
const actionName = getGenericActionName( action, resourceName );
|
||||||
|
const requestId = getRequestIdentifier( actionName, ...sanitizedArgs );
|
||||||
|
return state.requesting[ requestId ];
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
[ `get${ resourceName }` ]: applyNamespace( getItem, namespace ),
|
[ `get${ resourceName }` ]: applyNamespace( getItem, namespace ),
|
||||||
[ `get${ resourceName }Error` ]: applyNamespace(
|
[ `get${ resourceName }Error` ]: applyNamespace(
|
||||||
|
@ -184,5 +216,7 @@ export const createSelectors = ( {
|
||||||
getItemUpdateError,
|
getItemUpdateError,
|
||||||
namespace
|
namespace
|
||||||
),
|
),
|
||||||
|
hasFinishedRequest,
|
||||||
|
isRequesting,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
@ -5,6 +5,7 @@ import { Actions } from '../actions';
|
||||||
import { createReducer, ResourceState } from '../reducer';
|
import { createReducer, ResourceState } from '../reducer';
|
||||||
import { CRUD_ACTIONS } from '../crud-actions';
|
import { CRUD_ACTIONS } from '../crud-actions';
|
||||||
import { getResourceName } from '../../utils';
|
import { getResourceName } from '../../utils';
|
||||||
|
import { getRequestIdentifier } from '..//utils';
|
||||||
import { Item, ItemQuery } from '../types';
|
import { Item, ItemQuery } from '../types';
|
||||||
import TYPES from '../action-types';
|
import TYPES from '../action-types';
|
||||||
|
|
||||||
|
@ -13,6 +14,7 @@ const defaultState: ResourceState = {
|
||||||
errors: {},
|
errors: {},
|
||||||
itemsCount: {},
|
itemsCount: {},
|
||||||
data: {},
|
data: {},
|
||||||
|
requesting: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
const reducer = createReducer();
|
const reducer = createReducer();
|
||||||
|
@ -38,6 +40,7 @@ describe( 'crud reducer', () => {
|
||||||
1: { id: 1, name: 'Donkey', status: 'draft' },
|
1: { id: 1, name: 'Donkey', status: 'draft' },
|
||||||
2: { id: 2, name: 'Sauce', status: 'publish' },
|
2: { id: 2, name: 'Sauce', status: 'publish' },
|
||||||
},
|
},
|
||||||
|
requesting: {},
|
||||||
};
|
};
|
||||||
const update: Item = {
|
const update: Item = {
|
||||||
id: 2,
|
id: 2,
|
||||||
|
@ -72,7 +75,10 @@ describe( 'crud reducer', () => {
|
||||||
urlParameters: [],
|
urlParameters: [],
|
||||||
} );
|
} );
|
||||||
|
|
||||||
const resourceName = getResourceName( CRUD_ACTIONS.GET_ITEMS, query );
|
const resourceName = getRequestIdentifier(
|
||||||
|
CRUD_ACTIONS.GET_ITEMS,
|
||||||
|
query
|
||||||
|
);
|
||||||
|
|
||||||
expect( state.items[ resourceName ].data ).toHaveLength( 2 );
|
expect( state.items[ resourceName ].data ).toHaveLength( 2 );
|
||||||
expect( state.items[ resourceName ].data.includes( 1 ) ).toBeTruthy();
|
expect( state.items[ resourceName ].data.includes( 1 ) ).toBeTruthy();
|
||||||
|
@ -108,7 +114,10 @@ describe( 'crud reducer', () => {
|
||||||
urlParameters: [ 5 ],
|
urlParameters: [ 5 ],
|
||||||
} );
|
} );
|
||||||
|
|
||||||
const resourceName = getResourceName( CRUD_ACTIONS.GET_ITEMS, query );
|
const resourceName = getRequestIdentifier(
|
||||||
|
CRUD_ACTIONS.GET_ITEMS,
|
||||||
|
query
|
||||||
|
);
|
||||||
|
|
||||||
expect( state.items[ resourceName ].data ).toHaveLength( 2 );
|
expect( state.items[ resourceName ].data ).toHaveLength( 2 );
|
||||||
expect( state.data[ '5/1' ] ).toEqual( items[ 0 ] );
|
expect( state.data[ '5/1' ] ).toEqual( items[ 0 ] );
|
||||||
|
@ -141,7 +150,10 @@ describe( 'crud reducer', () => {
|
||||||
urlParameters: [],
|
urlParameters: [],
|
||||||
} );
|
} );
|
||||||
|
|
||||||
const resourceName = getResourceName( CRUD_ACTIONS.GET_ITEMS, query );
|
const resourceName = getRequestIdentifier(
|
||||||
|
CRUD_ACTIONS.GET_ITEMS,
|
||||||
|
query
|
||||||
|
);
|
||||||
|
|
||||||
expect( state.items[ resourceName ].data ).toHaveLength( 2 );
|
expect( state.items[ resourceName ].data ).toHaveLength( 2 );
|
||||||
expect( state.items[ resourceName ].data.includes( 1 ) ).toBeTruthy();
|
expect( state.items[ resourceName ].data.includes( 1 ) ).toBeTruthy();
|
||||||
|
@ -157,7 +169,10 @@ describe( 'crud reducer', () => {
|
||||||
|
|
||||||
it( 'should handle GET_ITEMS_ERROR', () => {
|
it( 'should handle GET_ITEMS_ERROR', () => {
|
||||||
const query: Partial< ItemQuery > = { status: 'draft' };
|
const query: Partial< ItemQuery > = { status: 'draft' };
|
||||||
const resourceName = getResourceName( CRUD_ACTIONS.GET_ITEMS, query );
|
const resourceName = getRequestIdentifier(
|
||||||
|
CRUD_ACTIONS.GET_ITEMS,
|
||||||
|
query
|
||||||
|
);
|
||||||
const error = 'Baaam!';
|
const error = 'Baaam!';
|
||||||
const state = reducer( defaultState, {
|
const state = reducer( defaultState, {
|
||||||
type: TYPES.GET_ITEMS_ERROR,
|
type: TYPES.GET_ITEMS_ERROR,
|
||||||
|
@ -171,7 +186,7 @@ describe( 'crud reducer', () => {
|
||||||
|
|
||||||
it( 'should handle GET_ITEMS_TOTAL_COUNT_ERROR', () => {
|
it( 'should handle GET_ITEMS_TOTAL_COUNT_ERROR', () => {
|
||||||
const query: Partial< ItemQuery > = { status: 'draft' };
|
const query: Partial< ItemQuery > = { status: 'draft' };
|
||||||
const resourceName = getResourceName(
|
const resourceName = getRequestIdentifier(
|
||||||
CRUD_ACTIONS.GET_ITEMS_TOTAL_COUNT,
|
CRUD_ACTIONS.GET_ITEMS_TOTAL_COUNT,
|
||||||
query
|
query
|
||||||
);
|
);
|
||||||
|
@ -188,7 +203,7 @@ describe( 'crud reducer', () => {
|
||||||
|
|
||||||
it( 'should handle GET_ITEM_ERROR', () => {
|
it( 'should handle GET_ITEM_ERROR', () => {
|
||||||
const key = 3;
|
const key = 3;
|
||||||
const resourceName = getResourceName( CRUD_ACTIONS.GET_ITEM, { key } );
|
const resourceName = getRequestIdentifier( CRUD_ACTIONS.GET_ITEM, key );
|
||||||
const error = 'Baaam!';
|
const error = 'Baaam!';
|
||||||
const state = reducer( defaultState, {
|
const state = reducer( defaultState, {
|
||||||
type: TYPES.GET_ITEM_ERROR,
|
type: TYPES.GET_ITEM_ERROR,
|
||||||
|
@ -202,7 +217,7 @@ describe( 'crud reducer', () => {
|
||||||
|
|
||||||
it( 'should handle GET_ITEM_ERROR', () => {
|
it( 'should handle GET_ITEM_ERROR', () => {
|
||||||
const key = 3;
|
const key = 3;
|
||||||
const resourceName = getResourceName( CRUD_ACTIONS.GET_ITEM, { key } );
|
const resourceName = getRequestIdentifier( CRUD_ACTIONS.GET_ITEM, key );
|
||||||
const error = 'Baaam!';
|
const error = 'Baaam!';
|
||||||
const state = reducer( defaultState, {
|
const state = reducer( defaultState, {
|
||||||
type: TYPES.GET_ITEM_ERROR,
|
type: TYPES.GET_ITEM_ERROR,
|
||||||
|
@ -216,7 +231,10 @@ describe( 'crud reducer', () => {
|
||||||
|
|
||||||
it( 'should handle CREATE_ITEM_ERROR', () => {
|
it( 'should handle CREATE_ITEM_ERROR', () => {
|
||||||
const query = { name: 'Invalid product' };
|
const query = { name: 'Invalid product' };
|
||||||
const resourceName = getResourceName( CRUD_ACTIONS.CREATE_ITEM, query );
|
const resourceName = getRequestIdentifier(
|
||||||
|
CRUD_ACTIONS.CREATE_ITEM,
|
||||||
|
query
|
||||||
|
);
|
||||||
const error = 'Baaam!';
|
const error = 'Baaam!';
|
||||||
const state = reducer( defaultState, {
|
const state = reducer( defaultState, {
|
||||||
type: TYPES.CREATE_ITEM_ERROR,
|
type: TYPES.CREATE_ITEM_ERROR,
|
||||||
|
@ -226,22 +244,28 @@ describe( 'crud reducer', () => {
|
||||||
} );
|
} );
|
||||||
|
|
||||||
expect( state.errors[ resourceName ] ).toBe( error );
|
expect( state.errors[ resourceName ] ).toBe( error );
|
||||||
|
expect( state.requesting[ resourceName ] ).toBe( false );
|
||||||
} );
|
} );
|
||||||
|
|
||||||
it( 'should handle UPDATE_ITEM_ERROR', () => {
|
it( 'should handle UPDATE_ITEM_ERROR', () => {
|
||||||
const key = 2;
|
const key = 2;
|
||||||
const resourceName = getResourceName( CRUD_ACTIONS.UPDATE_ITEM, {
|
const query = { property: 'value' };
|
||||||
|
const resourceName = getRequestIdentifier(
|
||||||
|
CRUD_ACTIONS.UPDATE_ITEM,
|
||||||
key,
|
key,
|
||||||
} );
|
query
|
||||||
|
);
|
||||||
const error = 'Baaam!';
|
const error = 'Baaam!';
|
||||||
const state = reducer( defaultState, {
|
const state = reducer( defaultState, {
|
||||||
type: TYPES.UPDATE_ITEM_ERROR,
|
type: TYPES.UPDATE_ITEM_ERROR,
|
||||||
key,
|
key,
|
||||||
error,
|
error,
|
||||||
errorType: CRUD_ACTIONS.UPDATE_ITEM,
|
errorType: CRUD_ACTIONS.UPDATE_ITEM,
|
||||||
|
query,
|
||||||
} );
|
} );
|
||||||
|
|
||||||
expect( state.errors[ resourceName ] ).toBe( error );
|
expect( state.errors[ resourceName ] ).toBe( error );
|
||||||
|
expect( state.requesting[ resourceName ] ).toBe( false );
|
||||||
} );
|
} );
|
||||||
|
|
||||||
it( 'should handle UPDATE_ITEM_SUCCESS', () => {
|
it( 'should handle UPDATE_ITEM_SUCCESS', () => {
|
||||||
|
@ -258,17 +282,25 @@ describe( 'crud reducer', () => {
|
||||||
1: { id: 1, name: 'Donkey', status: 'draft' },
|
1: { id: 1, name: 'Donkey', status: 'draft' },
|
||||||
2: { id: 2, name: 'Sauce', status: 'publish' },
|
2: { id: 2, name: 'Sauce', status: 'publish' },
|
||||||
},
|
},
|
||||||
|
requesting: {},
|
||||||
};
|
};
|
||||||
const item: Item = {
|
const item: Item = {
|
||||||
id: 2,
|
id: 2,
|
||||||
name: 'Holy smokes!',
|
name: 'Holy smokes!',
|
||||||
status: 'draft',
|
status: 'draft',
|
||||||
};
|
};
|
||||||
|
const query = { property: 'value' };
|
||||||
|
const requestId = getRequestIdentifier(
|
||||||
|
CRUD_ACTIONS.UPDATE_ITEM,
|
||||||
|
item.id,
|
||||||
|
query
|
||||||
|
);
|
||||||
|
|
||||||
const state = reducer( initialState, {
|
const state = reducer( initialState, {
|
||||||
type: TYPES.UPDATE_ITEM_SUCCESS,
|
type: TYPES.UPDATE_ITEM_SUCCESS,
|
||||||
key: item.id,
|
key: item.id,
|
||||||
item,
|
item,
|
||||||
|
query,
|
||||||
} );
|
} );
|
||||||
|
|
||||||
expect( state.items ).toEqual( initialState.items );
|
expect( state.items ).toEqual( initialState.items );
|
||||||
|
@ -278,6 +310,7 @@ describe( 'crud reducer', () => {
|
||||||
expect( state.data[ 2 ].id ).toEqual( initialState.data[ 2 ].id );
|
expect( state.data[ 2 ].id ).toEqual( initialState.data[ 2 ].id );
|
||||||
expect( state.data[ 2 ].title ).toEqual( initialState.data[ 2 ].title );
|
expect( state.data[ 2 ].title ).toEqual( initialState.data[ 2 ].title );
|
||||||
expect( state.data[ 2 ].name ).toEqual( item.name );
|
expect( state.data[ 2 ].name ).toEqual( item.name );
|
||||||
|
expect( state.requesting[ requestId ] ).toEqual( false );
|
||||||
} );
|
} );
|
||||||
|
|
||||||
it( 'should handle CREATE_ITEM_SUCCESS', () => {
|
it( 'should handle CREATE_ITEM_SUCCESS', () => {
|
||||||
|
@ -286,15 +319,26 @@ describe( 'crud reducer', () => {
|
||||||
name: 'Off the hook!',
|
name: 'Off the hook!',
|
||||||
status: 'draft',
|
status: 'draft',
|
||||||
};
|
};
|
||||||
|
const query = {
|
||||||
|
name: 'Off the hook!',
|
||||||
|
status: 'draft',
|
||||||
|
};
|
||||||
|
const resourceName = getRequestIdentifier(
|
||||||
|
CRUD_ACTIONS.CREATE_ITEM,
|
||||||
|
item.id,
|
||||||
|
query
|
||||||
|
);
|
||||||
|
|
||||||
const state = reducer( defaultState, {
|
const state = reducer( defaultState, {
|
||||||
type: TYPES.CREATE_ITEM_SUCCESS,
|
type: TYPES.CREATE_ITEM_SUCCESS,
|
||||||
key: item.id,
|
key: item.id,
|
||||||
item,
|
item,
|
||||||
|
query,
|
||||||
} );
|
} );
|
||||||
|
|
||||||
expect( state.data[ 2 ].name ).toEqual( item.name );
|
expect( state.data[ 2 ].name ).toEqual( item.name );
|
||||||
expect( state.data[ 2 ].status ).toEqual( item.status );
|
expect( state.data[ 2 ].status ).toEqual( item.status );
|
||||||
|
expect( state.requesting[ resourceName ] ).toEqual( false );
|
||||||
} );
|
} );
|
||||||
|
|
||||||
it( 'should handle DELETE_ITEM_SUCCESS', () => {
|
it( 'should handle DELETE_ITEM_SUCCESS', () => {
|
||||||
|
@ -311,6 +355,7 @@ describe( 'crud reducer', () => {
|
||||||
1: { id: 1, name: 'Donkey', status: 'draft' },
|
1: { id: 1, name: 'Donkey', status: 'draft' },
|
||||||
2: { id: 2, name: 'Sauce', status: 'publish' },
|
2: { id: 2, name: 'Sauce', status: 'publish' },
|
||||||
},
|
},
|
||||||
|
requesting: {},
|
||||||
};
|
};
|
||||||
const item1Updated: Item = {
|
const item1Updated: Item = {
|
||||||
id: 1,
|
id: 1,
|
||||||
|
@ -333,25 +378,35 @@ describe( 'crud reducer', () => {
|
||||||
item: item2Updated,
|
item: item2Updated,
|
||||||
force: false,
|
force: false,
|
||||||
} );
|
} );
|
||||||
|
const resourceName = getRequestIdentifier(
|
||||||
|
CRUD_ACTIONS.DELETE_ITEM,
|
||||||
|
item1Updated.id,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
expect( state.errors ).toEqual( initialState.errors );
|
expect( state.errors ).toEqual( initialState.errors );
|
||||||
expect( state.data[ 1 ] ).toEqual( undefined );
|
expect( state.data[ 1 ] ).toEqual( undefined );
|
||||||
expect( state.data[ 2 ].status ).toEqual( 'trash' );
|
expect( state.data[ 2 ].status ).toEqual( 'trash' );
|
||||||
|
expect( state.requesting[ resourceName ] ).toBe( false );
|
||||||
} );
|
} );
|
||||||
|
|
||||||
it( 'should handle DELETE_ITEM_ERROR', () => {
|
it( 'should handle DELETE_ITEM_ERROR', () => {
|
||||||
const key = 2;
|
const key = 2;
|
||||||
const resourceName = getResourceName( CRUD_ACTIONS.DELETE_ITEM, {
|
const resourceName = getRequestIdentifier(
|
||||||
|
CRUD_ACTIONS.DELETE_ITEM,
|
||||||
key,
|
key,
|
||||||
} );
|
false
|
||||||
|
);
|
||||||
const error = 'Baaam!';
|
const error = 'Baaam!';
|
||||||
const state = reducer( defaultState, {
|
const state = reducer( defaultState, {
|
||||||
type: TYPES.DELETE_ITEM_ERROR,
|
type: TYPES.DELETE_ITEM_ERROR,
|
||||||
key,
|
key,
|
||||||
error,
|
error,
|
||||||
errorType: CRUD_ACTIONS.DELETE_ITEM,
|
errorType: CRUD_ACTIONS.DELETE_ITEM,
|
||||||
|
force: false,
|
||||||
} );
|
} );
|
||||||
|
|
||||||
expect( state.errors[ resourceName ] ).toBe( error );
|
expect( state.errors[ resourceName ] ).toBe( error );
|
||||||
|
expect( state.requesting[ resourceName ] ).toBe( false );
|
||||||
} );
|
} );
|
||||||
} );
|
} );
|
||||||
|
|
|
@ -11,7 +11,7 @@ const selectors = createSelectors( {
|
||||||
|
|
||||||
describe( 'crud selectors', () => {
|
describe( 'crud selectors', () => {
|
||||||
it( 'should return methods for the default selectors', () => {
|
it( 'should return methods for the default selectors', () => {
|
||||||
expect( Object.keys( selectors ).length ).toEqual( 8 );
|
expect( Object.keys( selectors ).length ).toEqual( 10 );
|
||||||
expect( selectors ).toHaveProperty( 'getProduct' );
|
expect( selectors ).toHaveProperty( 'getProduct' );
|
||||||
expect( selectors ).toHaveProperty( 'getProducts' );
|
expect( selectors ).toHaveProperty( 'getProducts' );
|
||||||
expect( selectors ).toHaveProperty( 'getProductsTotalCount' );
|
expect( selectors ).toHaveProperty( 'getProductsTotalCount' );
|
||||||
|
@ -20,5 +20,7 @@ describe( 'crud selectors', () => {
|
||||||
expect( selectors ).toHaveProperty( 'getProductCreateError' );
|
expect( selectors ).toHaveProperty( 'getProductCreateError' );
|
||||||
expect( selectors ).toHaveProperty( 'getProductDeleteError' );
|
expect( selectors ).toHaveProperty( 'getProductDeleteError' );
|
||||||
expect( selectors ).toHaveProperty( 'getProductUpdateError' );
|
expect( selectors ).toHaveProperty( 'getProductUpdateError' );
|
||||||
|
expect( selectors ).toHaveProperty( 'hasFinishedRequest' );
|
||||||
|
expect( selectors ).toHaveProperty( 'isRequesting' );
|
||||||
} );
|
} );
|
||||||
} );
|
} );
|
||||||
|
|
|
@ -1,13 +1,18 @@
|
||||||
/**
|
/**
|
||||||
* Internal dependencies
|
* Internal dependencies
|
||||||
*/
|
*/
|
||||||
|
import CRUD_ACTIONS from '../crud-actions';
|
||||||
import {
|
import {
|
||||||
applyNamespace,
|
applyNamespace,
|
||||||
cleanQuery,
|
cleanQuery,
|
||||||
|
getGenericActionName,
|
||||||
getKey,
|
getKey,
|
||||||
getNamespaceKeys,
|
getNamespaceKeys,
|
||||||
|
getRequestIdentifier,
|
||||||
getRestPath,
|
getRestPath,
|
||||||
getUrlParameters,
|
getUrlParameters,
|
||||||
|
maybeReplaceIdQuery,
|
||||||
|
isValidIdQuery,
|
||||||
parseId,
|
parseId,
|
||||||
} from '../utils';
|
} from '../utils';
|
||||||
|
|
||||||
|
@ -113,4 +118,104 @@ describe( 'utils', () => {
|
||||||
expect( params.other_attribute ).toBe( 'a' );
|
expect( params.other_attribute ).toBe( 'a' );
|
||||||
expect( params.my_attribute ).toBeUndefined();
|
expect( params.my_attribute ).toBeUndefined();
|
||||||
} );
|
} );
|
||||||
|
|
||||||
|
it( 'should get the request identifier with no arguments', () => {
|
||||||
|
const key = getRequestIdentifier( 'CREATE_ITEM' );
|
||||||
|
expect( key ).toBe( 'CREATE_ITEM/[]' );
|
||||||
|
} );
|
||||||
|
|
||||||
|
it( 'should get the request identifier with a single argument', () => {
|
||||||
|
const key = getRequestIdentifier( 'CREATE_ITEM', 'string_arg' );
|
||||||
|
expect( key ).toBe( 'CREATE_ITEM/["string_arg"]' );
|
||||||
|
} );
|
||||||
|
|
||||||
|
it( 'should get the request identifier with multiple arguments', () => {
|
||||||
|
const key = getRequestIdentifier( 'CREATE_ITEM', 'string_arg', {
|
||||||
|
object_property: 'object_value',
|
||||||
|
} );
|
||||||
|
expect( key ).toBe(
|
||||||
|
'CREATE_ITEM/["string_arg","{"object_property":"object_value"}"]'
|
||||||
|
);
|
||||||
|
} );
|
||||||
|
|
||||||
|
it( 'should sort object properties in the request identifier', () => {
|
||||||
|
const key = getRequestIdentifier( 'CREATE_ITEM', 'string_arg', {
|
||||||
|
b: '2',
|
||||||
|
a: '1',
|
||||||
|
} );
|
||||||
|
expect( key ).toBe( 'CREATE_ITEM/["string_arg","{"a":"1","b":"2"}"]' );
|
||||||
|
} );
|
||||||
|
|
||||||
|
it( 'should directly return the action when the action does not match the resource name', () => {
|
||||||
|
const genercActionName = getGenericActionName(
|
||||||
|
'createNonThing',
|
||||||
|
'Thing'
|
||||||
|
);
|
||||||
|
expect( genercActionName ).toBe( 'createNonThing' );
|
||||||
|
} );
|
||||||
|
|
||||||
|
it( 'should get the generic create action name based on resource name', () => {
|
||||||
|
const genercActionName = getGenericActionName( 'createThing', 'Thing' );
|
||||||
|
expect( genercActionName ).toBe( CRUD_ACTIONS.CREATE_ITEM );
|
||||||
|
} );
|
||||||
|
|
||||||
|
it( 'should get the generic delete action name based on resource name', () => {
|
||||||
|
const genercActionName = getGenericActionName( 'deleteThing', 'Thing' );
|
||||||
|
expect( genercActionName ).toBe( CRUD_ACTIONS.DELETE_ITEM );
|
||||||
|
} );
|
||||||
|
|
||||||
|
it( 'should get the generic update action name based on resource name', () => {
|
||||||
|
const genercActionName = getGenericActionName( 'updateThing', 'Thing' );
|
||||||
|
expect( genercActionName ).toBe( CRUD_ACTIONS.UPDATE_ITEM );
|
||||||
|
} );
|
||||||
|
|
||||||
|
it( 'should return false when a valid ID query is not given', () => {
|
||||||
|
expect( isValidIdQuery( { some: 'data' }, '/my/namespace' ) ).toBe(
|
||||||
|
false
|
||||||
|
);
|
||||||
|
} );
|
||||||
|
|
||||||
|
it( 'should return true when a valid ID is passed in an object', () => {
|
||||||
|
expect( isValidIdQuery( { id: 22 }, '/my/namespace' ) ).toBe( true );
|
||||||
|
} );
|
||||||
|
|
||||||
|
it( 'should return true when a valid ID is passed directly', () => {
|
||||||
|
expect( isValidIdQuery( 22, '/my/namespace' ) ).toBe( true );
|
||||||
|
} );
|
||||||
|
|
||||||
|
it( 'should return false when additional non-ID properties are provided', () => {
|
||||||
|
expect( isValidIdQuery( { id: 22, other: 88 }, '/my/namespace' ) ).toBe(
|
||||||
|
false
|
||||||
|
);
|
||||||
|
} );
|
||||||
|
|
||||||
|
it( 'should return true when namespace ID properties are provided', () => {
|
||||||
|
expect(
|
||||||
|
isValidIdQuery(
|
||||||
|
{ id: 22, parent_id: 88 },
|
||||||
|
'/my/{parent_id}/namespace/'
|
||||||
|
)
|
||||||
|
).toBe( true );
|
||||||
|
} );
|
||||||
|
|
||||||
|
it( 'should replace the first argument when a valid ID query exists', () => {
|
||||||
|
const args = [ { id: 22, parent_id: 88 }, 'second' ];
|
||||||
|
const sanitizedArgs = maybeReplaceIdQuery(
|
||||||
|
args,
|
||||||
|
'/my/{parent_id}/namespace/'
|
||||||
|
);
|
||||||
|
expect( sanitizedArgs ).toEqual( [ '88/22', 'second' ] );
|
||||||
|
} );
|
||||||
|
|
||||||
|
it( 'should remain unchanged when the first argument is a string or number', () => {
|
||||||
|
const args = [ 'first', 'second' ];
|
||||||
|
const sanitizedArgs = maybeReplaceIdQuery( args, '/my/namespace/' );
|
||||||
|
expect( sanitizedArgs ).toEqual( args );
|
||||||
|
} );
|
||||||
|
|
||||||
|
it( 'should remain unchanged when the first argument is not a valid ID query', () => {
|
||||||
|
const args = [ { id: 22, parent_id: 88 }, 'second' ];
|
||||||
|
const sanitizedArgs = maybeReplaceIdQuery( args, '/my/namespace/' );
|
||||||
|
expect( sanitizedArgs ).toEqual( args );
|
||||||
|
} );
|
||||||
} );
|
} );
|
||||||
|
|
|
@ -6,6 +6,7 @@ import { addQueryArgs } from '@wordpress/url';
|
||||||
/**
|
/**
|
||||||
* Internal dependencies
|
* Internal dependencies
|
||||||
*/
|
*/
|
||||||
|
import CRUD_ACTIONS from './crud-actions';
|
||||||
import { IdQuery, IdType, ItemQuery } from './types';
|
import { IdQuery, IdType, ItemQuery } from './types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -148,6 +149,52 @@ export const getUrlParameters = (
|
||||||
return params;
|
return params;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check to see if an argument is a valid type of ID query.
|
||||||
|
*
|
||||||
|
* @param arg Unknow argument to check.
|
||||||
|
* @param namespace The namespace string
|
||||||
|
* @return boolean
|
||||||
|
*/
|
||||||
|
export const isValidIdQuery = ( arg: unknown, namespace: string ) => {
|
||||||
|
if ( typeof arg === 'string' || typeof arg === 'number' ) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const validKeys = [ 'id', ...getNamespaceKeys( namespace ) ];
|
||||||
|
|
||||||
|
if (
|
||||||
|
arg &&
|
||||||
|
typeof arg === 'object' &&
|
||||||
|
arg.hasOwnProperty( 'id' ) &&
|
||||||
|
JSON.stringify( validKeys.sort() ) ===
|
||||||
|
JSON.stringify( Object.keys( arg ).sort() )
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace the initial argument with a key if it's a valid ID query.
|
||||||
|
*
|
||||||
|
* @param args Args to check.
|
||||||
|
* @param namespace Namespace.
|
||||||
|
* @return Sanitized arguments.
|
||||||
|
*/
|
||||||
|
export const maybeReplaceIdQuery = ( args: unknown[], namespace: string ) => {
|
||||||
|
const [ firstArgument, ...rest ] = args;
|
||||||
|
if ( ! firstArgument || ! isValidIdQuery( firstArgument, namespace ) ) {
|
||||||
|
return args;
|
||||||
|
}
|
||||||
|
|
||||||
|
const urlParameters = getUrlParameters( namespace, firstArgument );
|
||||||
|
const { key } = parseId( firstArgument as IdQuery, urlParameters );
|
||||||
|
|
||||||
|
return [ key, ...rest ];
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clean a query of all namespaced params.
|
* Clean a query of all namespaced params.
|
||||||
*
|
*
|
||||||
|
@ -168,3 +215,46 @@ export const cleanQuery = (
|
||||||
|
|
||||||
return cleaned;
|
return cleaned;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the identifier for a request provided its arguments.
|
||||||
|
*
|
||||||
|
* @param name Name of action or selector.
|
||||||
|
* @param args Arguments for the request.
|
||||||
|
* @return Key to identify the request.
|
||||||
|
*/
|
||||||
|
export const getRequestIdentifier = ( name: string, ...args: unknown[] ) => {
|
||||||
|
const suffix = JSON.stringify(
|
||||||
|
args.map( ( arg ) => {
|
||||||
|
if ( typeof arg === 'object' && arg !== null ) {
|
||||||
|
return JSON.stringify( arg, Object.keys( arg ).sort() );
|
||||||
|
}
|
||||||
|
return arg;
|
||||||
|
} )
|
||||||
|
).replace( /\\"/g, '"' );
|
||||||
|
|
||||||
|
return name + '/' + suffix;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a generic action name from a resource action name if one exists.
|
||||||
|
*
|
||||||
|
* @param action Action name to check.
|
||||||
|
* @param resourceName Resurce name.
|
||||||
|
* @return Generic action name if one exists, otherwise the passed action name.
|
||||||
|
*/
|
||||||
|
export const getGenericActionName = (
|
||||||
|
action: string,
|
||||||
|
resourceName: string
|
||||||
|
) => {
|
||||||
|
switch ( action ) {
|
||||||
|
case `create${ resourceName }`:
|
||||||
|
return CRUD_ACTIONS.CREATE_ITEM;
|
||||||
|
case `delete${ resourceName }`:
|
||||||
|
return CRUD_ACTIONS.DELETE_ITEM;
|
||||||
|
case `update${ resourceName }`:
|
||||||
|
return CRUD_ACTIONS.UPDATE_ITEM;
|
||||||
|
}
|
||||||
|
|
||||||
|
return action;
|
||||||
|
};
|
||||||
|
|
|
@ -25,6 +25,7 @@ export { EXPERIMENTAL_PRODUCT_TAGS_STORE_NAME } from './product-tags';
|
||||||
export { EXPERIMENTAL_PRODUCT_CATEGORIES_STORE_NAME } from './product-categories';
|
export { EXPERIMENTAL_PRODUCT_CATEGORIES_STORE_NAME } from './product-categories';
|
||||||
export { EXPERIMENTAL_PRODUCT_ATTRIBUTE_TERMS_STORE_NAME } from './product-attribute-terms';
|
export { EXPERIMENTAL_PRODUCT_ATTRIBUTE_TERMS_STORE_NAME } from './product-attribute-terms';
|
||||||
export { EXPERIMENTAL_PRODUCT_VARIATIONS_STORE_NAME } from './product-variations';
|
export { EXPERIMENTAL_PRODUCT_VARIATIONS_STORE_NAME } from './product-variations';
|
||||||
|
export { EXPERIMENTAL_PRODUCT_FORM_STORE_NAME } from './product-form';
|
||||||
export { EXPERIMENTAL_TAX_CLASSES_STORE_NAME } from './tax-classes';
|
export { EXPERIMENTAL_TAX_CLASSES_STORE_NAME } from './tax-classes';
|
||||||
export { PaymentGateway } from './payment-gateways/types';
|
export { PaymentGateway } from './payment-gateways/types';
|
||||||
|
|
||||||
|
@ -75,6 +76,11 @@ export {
|
||||||
// Export types
|
// Export types
|
||||||
export * from './types';
|
export * from './types';
|
||||||
export * from './countries/types';
|
export * from './countries/types';
|
||||||
|
export {
|
||||||
|
ProductForm,
|
||||||
|
ProductFormField,
|
||||||
|
ProductFormSection,
|
||||||
|
} from './product-form/types';
|
||||||
export * from './onboarding/types';
|
export * from './onboarding/types';
|
||||||
export * from './plugins/types';
|
export * from './plugins/types';
|
||||||
export * from './products/types';
|
export * from './products/types';
|
||||||
|
@ -122,6 +128,7 @@ import type { EXPERIMENTAL_PRODUCT_SHIPPING_CLASSES_STORE_NAME } from './product
|
||||||
import type { EXPERIMENTAL_SHIPPING_ZONES_STORE_NAME } from './shipping-zones';
|
import type { EXPERIMENTAL_SHIPPING_ZONES_STORE_NAME } from './shipping-zones';
|
||||||
import type { EXPERIMENTAL_PRODUCT_TAGS_STORE_NAME } from './product-tags';
|
import type { EXPERIMENTAL_PRODUCT_TAGS_STORE_NAME } from './product-tags';
|
||||||
import type { EXPERIMENTAL_PRODUCT_CATEGORIES_STORE_NAME } from './product-categories';
|
import type { EXPERIMENTAL_PRODUCT_CATEGORIES_STORE_NAME } from './product-categories';
|
||||||
|
import type { EXPERIMENTAL_PRODUCT_FORM_STORE_NAME } from './product-form';
|
||||||
import type { EXPERIMENTAL_PRODUCT_ATTRIBUTE_TERMS_STORE_NAME } from './product-attribute-terms';
|
import type { EXPERIMENTAL_PRODUCT_ATTRIBUTE_TERMS_STORE_NAME } from './product-attribute-terms';
|
||||||
import type { EXPERIMENTAL_PRODUCT_VARIATIONS_STORE_NAME } from './product-variations';
|
import type { EXPERIMENTAL_PRODUCT_VARIATIONS_STORE_NAME } from './product-variations';
|
||||||
import type { EXPERIMENTAL_TAX_CLASSES_STORE_NAME } from './tax-classes';
|
import type { EXPERIMENTAL_TAX_CLASSES_STORE_NAME } from './tax-classes';
|
||||||
|
@ -148,7 +155,8 @@ export type WCDataStoreName =
|
||||||
| typeof EXPERIMENTAL_PRODUCT_TAGS_STORE_NAME
|
| typeof EXPERIMENTAL_PRODUCT_TAGS_STORE_NAME
|
||||||
| typeof EXPERIMENTAL_PRODUCT_CATEGORIES_STORE_NAME
|
| typeof EXPERIMENTAL_PRODUCT_CATEGORIES_STORE_NAME
|
||||||
| typeof EXPERIMENTAL_PRODUCT_VARIATIONS_STORE_NAME
|
| typeof EXPERIMENTAL_PRODUCT_VARIATIONS_STORE_NAME
|
||||||
| typeof EXPERIMENTAL_TAX_CLASSES_STORE_NAME;
|
| typeof EXPERIMENTAL_TAX_CLASSES_STORE_NAME
|
||||||
|
| typeof EXPERIMENTAL_PRODUCT_FORM_STORE_NAME;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Internal dependencies
|
* Internal dependencies
|
||||||
|
@ -168,6 +176,7 @@ import { ProductCategorySelectors } from './product-categories/types';
|
||||||
import { ProductAttributeTermsSelectors } from './product-attribute-terms/types';
|
import { ProductAttributeTermsSelectors } from './product-attribute-terms/types';
|
||||||
import { ProductVariationSelectors } from './product-variations/types';
|
import { ProductVariationSelectors } from './product-variations/types';
|
||||||
import { TaxClassSelectors } from './tax-classes/types';
|
import { TaxClassSelectors } from './tax-classes/types';
|
||||||
|
import { ProductFormSelectors } from './product-form/selectors';
|
||||||
|
|
||||||
// As we add types to all the package selectors we can fill out these unknown types with real ones. See one
|
// As we add types to all the package selectors we can fill out these unknown types with real ones. See one
|
||||||
// of the already typed selectors for an example of how you can do this.
|
// of the already typed selectors for an example of how you can do this.
|
||||||
|
@ -215,6 +224,8 @@ export type WCSelectorType< T > = T extends typeof REVIEWS_STORE_NAME
|
||||||
? ShippingZonesSelectors
|
? ShippingZonesSelectors
|
||||||
: T extends typeof EXPERIMENTAL_TAX_CLASSES_STORE_NAME
|
: T extends typeof EXPERIMENTAL_TAX_CLASSES_STORE_NAME
|
||||||
? TaxClassSelectors
|
? TaxClassSelectors
|
||||||
|
: T extends typeof EXPERIMENTAL_PRODUCT_FORM_STORE_NAME
|
||||||
|
? ProductFormSelectors
|
||||||
: never;
|
: never;
|
||||||
|
|
||||||
export interface WCDataSelector {
|
export interface WCDataSelector {
|
||||||
|
|
|
@ -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: 32px;
|
||||||
$adminbar-height-mobile: 46px;
|
$adminbar-height-mobile: 46px;
|
||||||
$admin-menu-width: 160px;
|
$admin-menu-width: 160px;
|
||||||
|
$admin-menu-width-collapsed: 36px;
|
||||||
|
|
||||||
// wp-admin colors
|
// wp-admin colors
|
||||||
$wp-admin-background: #f1f1f1;
|
$wp-admin-background: #f1f1f1;
|
||||||
|
|
|
@ -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 '../dashboard/style.scss';
|
||||||
import { getAdminSetting } from '~/utils/admin-settings';
|
import { getAdminSetting } from '~/utils/admin-settings';
|
||||||
import { ProgressTitle } from '../task-lists';
|
import { ProgressTitle } from '../task-lists';
|
||||||
|
import { WooHomescreenHeaderBanner } from './header-banner-slot';
|
||||||
|
|
||||||
const Tasks = lazy( () =>
|
const Tasks = lazy( () =>
|
||||||
import( /* webpackChunkName: "tasks" */ '../tasks' ).then( ( module ) => ( {
|
import( /* webpackChunkName: "tasks" */ '../tasks' ).then( ( module ) => ( {
|
||||||
|
@ -126,7 +127,9 @@ export const Layout = ( {
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={ <TasksPlaceholder query={ query } /> }>
|
<Suspense fallback={ <TasksPlaceholder query={ query } /> }>
|
||||||
{ activeSetupTaskList && isDashboardShown && (
|
{ activeSetupTaskList && isDashboardShown && (
|
||||||
|
<>
|
||||||
<ProgressTitle taskListId={ activeSetupTaskList } />
|
<ProgressTitle taskListId={ activeSetupTaskList } />
|
||||||
|
</>
|
||||||
) }
|
) }
|
||||||
<Tasks query={ query } />
|
<Tasks query={ query } />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
@ -135,6 +138,13 @@ export const Layout = ( {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{ isDashboardShown && (
|
||||||
|
<WooHomescreenHeaderBanner
|
||||||
|
className={ classnames( 'woocommerce-homescreen', {
|
||||||
|
'woocommerce-homescreen-column': ! twoColumns,
|
||||||
|
} ) }
|
||||||
|
/>
|
||||||
|
) }
|
||||||
<div
|
<div
|
||||||
className={ classnames( 'woocommerce-homescreen', {
|
className={ classnames( 'woocommerce-homescreen', {
|
||||||
'two-columns': twoColumns,
|
'two-columns': twoColumns,
|
||||||
|
|
|
@ -2,22 +2,46 @@
|
||||||
* External dependencies
|
* External dependencies
|
||||||
*/
|
*/
|
||||||
import { recordEvent } from '@woocommerce/tracks';
|
import { recordEvent } from '@woocommerce/tracks';
|
||||||
|
import { useSelect } from '@wordpress/data';
|
||||||
import { useEffect } from '@wordpress/element';
|
import { useEffect } from '@wordpress/element';
|
||||||
|
import { Spinner } from '@wordpress/components';
|
||||||
|
import {
|
||||||
|
EXPERIMENTAL_PRODUCT_FORM_STORE_NAME,
|
||||||
|
WCDataSelector,
|
||||||
|
} from '@woocommerce/data';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Internal dependencies
|
* Internal dependencies
|
||||||
*/
|
*/
|
||||||
import { ProductForm } from './product-form';
|
import { ProductForm } from './product-form';
|
||||||
|
import { ProductTourContainer } from './tour';
|
||||||
import './product-page.scss';
|
import './product-page.scss';
|
||||||
|
import './fills';
|
||||||
|
|
||||||
const AddProductPage: React.FC = () => {
|
const AddProductPage: React.FC = () => {
|
||||||
|
const { isLoading } = useSelect( ( select: WCDataSelector ) => {
|
||||||
|
const { hasFinishedResolution: hasProductFormFinishedResolution } =
|
||||||
|
select( EXPERIMENTAL_PRODUCT_FORM_STORE_NAME );
|
||||||
|
return {
|
||||||
|
isLoading: ! hasProductFormFinishedResolution( 'getProductForm' ),
|
||||||
|
};
|
||||||
|
} );
|
||||||
useEffect( () => {
|
useEffect( () => {
|
||||||
recordEvent( 'view_new_product_management_experience' );
|
recordEvent( 'view_new_product_management_experience' );
|
||||||
}, [] );
|
}, [] );
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="woocommerce-add-product">
|
<div className="woocommerce-add-product">
|
||||||
|
{ isLoading ? (
|
||||||
|
<div className="woocommerce-edit-product__spinner">
|
||||||
|
<Spinner />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<ProductForm />
|
<ProductForm />
|
||||||
|
<ProductTourContainer />
|
||||||
|
</>
|
||||||
|
) }
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -3,6 +3,7 @@
|
||||||
*/
|
*/
|
||||||
import { __ } from '@wordpress/i18n';
|
import { __ } from '@wordpress/i18n';
|
||||||
import {
|
import {
|
||||||
|
EXPERIMENTAL_PRODUCT_FORM_STORE_NAME,
|
||||||
EXPERIMENTAL_PRODUCT_VARIATIONS_STORE_NAME,
|
EXPERIMENTAL_PRODUCT_VARIATIONS_STORE_NAME,
|
||||||
PartialProduct,
|
PartialProduct,
|
||||||
Product,
|
Product,
|
||||||
|
@ -21,6 +22,7 @@ import { ProductForm } from './product-form';
|
||||||
import { ProductFormLayout } from './layout/product-form-layout';
|
import { ProductFormLayout } from './layout/product-form-layout';
|
||||||
import { ProductVariationForm } from './product-variation-form';
|
import { ProductVariationForm } from './product-variation-form';
|
||||||
import './product-page.scss';
|
import './product-page.scss';
|
||||||
|
import './fills';
|
||||||
|
|
||||||
const EditProductPage: React.FC = () => {
|
const EditProductPage: React.FC = () => {
|
||||||
const { productId, variationId } = useParams();
|
const { productId, variationId } = useParams();
|
||||||
|
@ -35,6 +37,8 @@ const EditProductPage: React.FC = () => {
|
||||||
isPending,
|
isPending,
|
||||||
getPermalinkParts,
|
getPermalinkParts,
|
||||||
} = select( PRODUCTS_STORE_NAME );
|
} = select( PRODUCTS_STORE_NAME );
|
||||||
|
const { hasFinishedResolution: hasProductFormFinishedResolution } =
|
||||||
|
select( EXPERIMENTAL_PRODUCT_FORM_STORE_NAME );
|
||||||
const {
|
const {
|
||||||
getProductVariation,
|
getProductVariation,
|
||||||
hasFinishedResolution: hasProductVariationFinishedResolution,
|
hasFinishedResolution: hasProductVariationFinishedResolution,
|
||||||
|
@ -71,7 +75,8 @@ const EditProductPage: React.FC = () => {
|
||||||
'getProductVariation',
|
'getProductVariation',
|
||||||
[ parseInt( variationId, 10 ) ]
|
[ parseInt( variationId, 10 ) ]
|
||||||
)
|
)
|
||||||
),
|
) ||
|
||||||
|
! hasProductFormFinishedResolution( 'getProductForm' ),
|
||||||
isPendingAction:
|
isPendingAction:
|
||||||
isPending( 'createProduct' ) ||
|
isPending( 'createProduct' ) ||
|
||||||
isPending(
|
isPending(
|
||||||
|
|
|
@ -26,7 +26,7 @@ import {
|
||||||
AttributeTermInputField,
|
AttributeTermInputField,
|
||||||
CustomAttributeTermInputField,
|
CustomAttributeTermInputField,
|
||||||
} from '../attribute-term-input-field';
|
} from '../attribute-term-input-field';
|
||||||
import { HydratedAttributeType } from '../attribute-field';
|
import { EnhancedProductAttribute } from '~/products/hooks/use-product-attributes';
|
||||||
import { getProductAttributeObject } from './utils';
|
import { getProductAttributeObject } from './utils';
|
||||||
|
|
||||||
type AddAttributeModalProps = {
|
type AddAttributeModalProps = {
|
||||||
|
@ -46,12 +46,12 @@ type AddAttributeModalProps = {
|
||||||
confirmCancelLabel?: string;
|
confirmCancelLabel?: string;
|
||||||
confirmConfirmLabel?: string;
|
confirmConfirmLabel?: string;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
onAdd: ( newCategories: HydratedAttributeType[] ) => void;
|
onAdd: ( newCategories: EnhancedProductAttribute[] ) => void;
|
||||||
selectedAttributeIds?: number[];
|
selectedAttributeIds?: number[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type AttributeForm = {
|
type AttributeForm = {
|
||||||
attributes: Array< HydratedAttributeType | null >;
|
attributes: Array< EnhancedProductAttribute | null >;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AddAttributeModal: React.FC< AddAttributeModalProps > = ( {
|
export const AddAttributeModal: React.FC< AddAttributeModalProps > = ( {
|
||||||
|
@ -80,6 +80,15 @@ export const AddAttributeModal: React.FC< AddAttributeModalProps > = ( {
|
||||||
onAdd,
|
onAdd,
|
||||||
selectedAttributeIds = [],
|
selectedAttributeIds = [],
|
||||||
} ) => {
|
} ) => {
|
||||||
|
const scrollAttributeIntoView = ( index: number ) => {
|
||||||
|
setTimeout( () => {
|
||||||
|
const attributeRow = document.querySelector(
|
||||||
|
`.woocommerce-add-attribute-modal__table-row-${ index }`
|
||||||
|
);
|
||||||
|
attributeRow?.scrollIntoView( { behavior: 'smooth' } );
|
||||||
|
}, 0 );
|
||||||
|
};
|
||||||
|
|
||||||
const [ showConfirmClose, setShowConfirmClose ] = useState( false );
|
const [ showConfirmClose, setShowConfirmClose ] = useState( false );
|
||||||
const addAnother = (
|
const addAnother = (
|
||||||
values: AttributeForm,
|
values: AttributeForm,
|
||||||
|
@ -89,10 +98,11 @@ export const AddAttributeModal: React.FC< AddAttributeModalProps > = ( {
|
||||||
) => void
|
) => void
|
||||||
) => {
|
) => {
|
||||||
setValue( 'attributes', [ ...values.attributes, null ] );
|
setValue( 'attributes', [ ...values.attributes, null ] );
|
||||||
|
scrollAttributeIntoView( values.attributes.length );
|
||||||
};
|
};
|
||||||
|
|
||||||
const onAddingAttributes = ( values: AttributeForm ) => {
|
const onAddingAttributes = ( values: AttributeForm ) => {
|
||||||
const newAttributesToAdd: HydratedAttributeType[] = [];
|
const newAttributesToAdd: EnhancedProductAttribute[] = [];
|
||||||
values.attributes.forEach( ( attr ) => {
|
values.attributes.forEach( ( attr ) => {
|
||||||
if (
|
if (
|
||||||
attr !== null &&
|
attr !== null &&
|
||||||
|
@ -105,7 +115,7 @@ export const AddAttributeModal: React.FC< AddAttributeModalProps > = ( {
|
||||||
? ( attr.terms || [] ).map( ( term ) => term.name )
|
? ( attr.terms || [] ).map( ( term ) => term.name )
|
||||||
: attr.options;
|
: attr.options;
|
||||||
newAttributesToAdd.push( {
|
newAttributesToAdd.push( {
|
||||||
...( attr as HydratedAttributeType ),
|
...( attr as EnhancedProductAttribute ),
|
||||||
options,
|
options,
|
||||||
} );
|
} );
|
||||||
}
|
}
|
|
@ -2,13 +2,8 @@
|
||||||
* External dependencies
|
* External dependencies
|
||||||
*/
|
*/
|
||||||
import { __, sprintf } from '@wordpress/i18n';
|
import { __, sprintf } from '@wordpress/i18n';
|
||||||
import { useState, useCallback, useEffect } from '@wordpress/element';
|
import { useState } from '@wordpress/element';
|
||||||
import {
|
import { ProductAttribute } from '@woocommerce/data';
|
||||||
ProductAttribute,
|
|
||||||
EXPERIMENTAL_PRODUCT_ATTRIBUTE_TERMS_STORE_NAME,
|
|
||||||
ProductAttributeTerm,
|
|
||||||
} from '@woocommerce/data';
|
|
||||||
import { resolveSelect } from '@wordpress/data';
|
|
||||||
import {
|
import {
|
||||||
Sortable,
|
Sortable,
|
||||||
__experimentalSelectControlMenuSlot as SelectControlMenuSlot,
|
__experimentalSelectControlMenuSlot as SelectControlMenuSlot,
|
||||||
|
@ -24,42 +19,31 @@ import { getAdminLink } from '@woocommerce/settings';
|
||||||
import './attribute-field.scss';
|
import './attribute-field.scss';
|
||||||
import { AddAttributeModal } from './add-attribute-modal';
|
import { AddAttributeModal } from './add-attribute-modal';
|
||||||
import { EditAttributeModal } from './edit-attribute-modal';
|
import { EditAttributeModal } from './edit-attribute-modal';
|
||||||
|
import { EnhancedProductAttribute } from '~/products/hooks/use-product-attributes';
|
||||||
import {
|
import {
|
||||||
getAttributeKey,
|
getAttributeKey,
|
||||||
reorderSortableProductAttributePositions,
|
reorderSortableProductAttributePositions,
|
||||||
} from './utils';
|
} from './utils';
|
||||||
import { sift } from '../../../utils';
|
|
||||||
import { AttributeEmptyState } from '../attribute-empty-state';
|
import { AttributeEmptyState } from '../attribute-empty-state';
|
||||||
import {
|
import {
|
||||||
AddAttributeListItem,
|
AddAttributeListItem,
|
||||||
AttributeListItem,
|
AttributeListItem,
|
||||||
} from '../attribute-list-item';
|
} from '../attribute-list-item';
|
||||||
|
|
||||||
type AttributeFieldProps = {
|
type AttributeControlProps = {
|
||||||
value: ProductAttribute[];
|
value: ProductAttribute[];
|
||||||
onChange: ( value: ProductAttribute[] ) => void;
|
onChange: ( value: ProductAttribute[] ) => void;
|
||||||
productId?: number;
|
|
||||||
// TODO: should we support an 'any' option to show all attributes?
|
// TODO: should we support an 'any' option to show all attributes?
|
||||||
attributeType?: 'regular' | 'for-variations';
|
attributeType?: 'regular' | 'for-variations';
|
||||||
};
|
};
|
||||||
|
|
||||||
export type HydratedAttributeType = Omit< ProductAttribute, 'options' > & {
|
export const AttributeControl: React.FC< AttributeControlProps > = ( {
|
||||||
options?: string[];
|
|
||||||
terms?: ProductAttributeTerm[];
|
|
||||||
visible?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const AttributeField: React.FC< AttributeFieldProps > = ( {
|
|
||||||
value,
|
value,
|
||||||
onChange,
|
|
||||||
productId,
|
|
||||||
attributeType = 'regular',
|
attributeType = 'regular',
|
||||||
|
onChange,
|
||||||
} ) => {
|
} ) => {
|
||||||
const [ showAddAttributeModal, setShowAddAttributeModal ] =
|
const [ showAddAttributeModal, setShowAddAttributeModal ] =
|
||||||
useState( false );
|
useState( false );
|
||||||
const [ hydratedAttributes, setHydratedAttributes ] = useState<
|
|
||||||
HydratedAttributeType[]
|
|
||||||
>( [] );
|
|
||||||
const [ editingAttributeId, setEditingAttributeId ] = useState<
|
const [ editingAttributeId, setEditingAttributeId ] = useState<
|
||||||
null | string
|
null | string
|
||||||
>( null );
|
>( null );
|
||||||
|
@ -72,73 +56,12 @@ export const AttributeField: React.FC< AttributeFieldProps > = ( {
|
||||||
? 'product_add_options_modal_cancel_button_click'
|
? 'product_add_options_modal_cancel_button_click'
|
||||||
: 'product_add_attributes_modal_cancel_button_click';
|
: 'product_add_attributes_modal_cancel_button_click';
|
||||||
|
|
||||||
const fetchTerms = useCallback(
|
|
||||||
( attributeId: number ) => {
|
|
||||||
return resolveSelect(
|
|
||||||
EXPERIMENTAL_PRODUCT_ATTRIBUTE_TERMS_STORE_NAME
|
|
||||||
)
|
|
||||||
.getProductAttributeTerms< ProductAttributeTerm[] >( {
|
|
||||||
attribute_id: attributeId,
|
|
||||||
product: productId,
|
|
||||||
} )
|
|
||||||
.then(
|
|
||||||
( attributeTerms ) => {
|
|
||||||
return attributeTerms;
|
|
||||||
},
|
|
||||||
( error ) => {
|
|
||||||
return error;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
},
|
|
||||||
[ productId ]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect( () => {
|
|
||||||
// I think we'll need to move the hydration out of the individual component
|
|
||||||
// instance. To where, I do not yet know... maybe in the form context
|
|
||||||
// somewhere so that a single hydration source can be shared between multiple
|
|
||||||
// instances? Something like a simple key-value store in the form context
|
|
||||||
// would be handy.
|
|
||||||
if ( ! value || hydratedAttributes.length !== 0 ) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [ customAttributes, globalAttributes ]: ProductAttribute[][] =
|
|
||||||
sift( value, ( attr: ProductAttribute ) => attr.id === 0 );
|
|
||||||
|
|
||||||
Promise.all(
|
|
||||||
globalAttributes.map( ( attr ) => fetchTerms( attr.id ) )
|
|
||||||
).then( ( allResults ) => {
|
|
||||||
setHydratedAttributes( [
|
|
||||||
...globalAttributes.map( ( attr, index ) => {
|
|
||||||
const fetchedTerms = allResults[ index ];
|
|
||||||
|
|
||||||
const newAttr = {
|
|
||||||
...attr,
|
|
||||||
// I'm not sure this is quite right for handling unpersisted terms,
|
|
||||||
// but this gets things kinda working for now
|
|
||||||
terms:
|
|
||||||
fetchedTerms.length > 0 ? fetchedTerms : undefined,
|
|
||||||
options:
|
|
||||||
fetchedTerms.length === 0
|
|
||||||
? attr.options
|
|
||||||
: undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
return newAttr;
|
|
||||||
} ),
|
|
||||||
...customAttributes,
|
|
||||||
] );
|
|
||||||
} );
|
|
||||||
}, [ fetchTerms, hydratedAttributes, value ] );
|
|
||||||
|
|
||||||
const fetchAttributeId = ( attribute: { id: number; name: string } ) =>
|
const fetchAttributeId = ( attribute: { id: number; name: string } ) =>
|
||||||
`${ attribute.id }-${ attribute.name }`;
|
`${ attribute.id }-${ attribute.name }`;
|
||||||
|
|
||||||
const updateAttributes = ( attributes: HydratedAttributeType[] ) => {
|
const handleChange = ( newAttributes: EnhancedProductAttribute[] ) => {
|
||||||
setHydratedAttributes( attributes );
|
|
||||||
onChange(
|
onChange(
|
||||||
attributes.map( ( attr ) => {
|
newAttributes.map( ( attr ) => {
|
||||||
return {
|
return {
|
||||||
...attr,
|
...attr,
|
||||||
options: attr.terms
|
options: attr.terms
|
||||||
|
@ -157,8 +80,8 @@ export const AttributeField: React.FC< AttributeFieldProps > = ( {
|
||||||
recordEvent(
|
recordEvent(
|
||||||
'product_remove_attribute_confirmation_confirm_click'
|
'product_remove_attribute_confirmation_confirm_click'
|
||||||
);
|
);
|
||||||
updateAttributes(
|
handleChange(
|
||||||
hydratedAttributes.filter(
|
value.filter(
|
||||||
( attr ) =>
|
( attr ) =>
|
||||||
fetchAttributeId( attr ) !==
|
fetchAttributeId( attr ) !==
|
||||||
fetchAttributeId( attribute )
|
fetchAttributeId( attribute )
|
||||||
|
@ -169,9 +92,11 @@ export const AttributeField: React.FC< AttributeFieldProps > = ( {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onAddNewAttributes = ( newAttributes: HydratedAttributeType[] ) => {
|
const onAddNewAttributes = (
|
||||||
updateAttributes( [
|
newAttributes: EnhancedProductAttribute[]
|
||||||
...( hydratedAttributes || [] ),
|
) => {
|
||||||
|
handleChange( [
|
||||||
|
...( value || [] ),
|
||||||
...newAttributes
|
...newAttributes
|
||||||
.filter(
|
.filter(
|
||||||
( newAttr ) =>
|
( newAttr ) =>
|
||||||
|
@ -193,18 +118,7 @@ export const AttributeField: React.FC< AttributeFieldProps > = ( {
|
||||||
setShowAddAttributeModal( false );
|
setShowAddAttributeModal( false );
|
||||||
};
|
};
|
||||||
|
|
||||||
const filteredAttributes = value
|
if ( ! value.length ) {
|
||||||
? value.filter(
|
|
||||||
( attribute: ProductAttribute ) =>
|
|
||||||
attribute.variation === isOnlyForVariations
|
|
||||||
)
|
|
||||||
: false;
|
|
||||||
|
|
||||||
if (
|
|
||||||
! filteredAttributes ||
|
|
||||||
filteredAttributes.length === 0 ||
|
|
||||||
hydratedAttributes.length === 0
|
|
||||||
) {
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<AttributeEmptyState
|
<AttributeEmptyState
|
||||||
|
@ -232,9 +146,7 @@ export const AttributeField: React.FC< AttributeFieldProps > = ( {
|
||||||
setShowAddAttributeModal( false );
|
setShowAddAttributeModal( false );
|
||||||
} }
|
} }
|
||||||
onAdd={ onAddNewAttributes }
|
onAdd={ onAddNewAttributes }
|
||||||
selectedAttributeIds={ ( filteredAttributes || [] ).map(
|
selectedAttributeIds={ [] }
|
||||||
( attr ) => attr.id
|
|
||||||
) }
|
|
||||||
/>
|
/>
|
||||||
) }
|
) }
|
||||||
<SelectControlMenuSlot />
|
<SelectControlMenuSlot />
|
||||||
|
@ -242,9 +154,8 @@ export const AttributeField: React.FC< AttributeFieldProps > = ( {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const sortedAttributes = filteredAttributes.sort(
|
const sortedAttributes = value.sort( ( a, b ) => a.position - b.position );
|
||||||
( a, b ) => a.position - b.position
|
|
||||||
);
|
|
||||||
const attributeKeyValues = value.reduce(
|
const attributeKeyValues = value.reduce(
|
||||||
(
|
(
|
||||||
keyValue: Record< number | string, ProductAttribute >,
|
keyValue: Record< number | string, ProductAttribute >,
|
||||||
|
@ -256,9 +167,9 @@ export const AttributeField: React.FC< AttributeFieldProps > = ( {
|
||||||
{} as Record< number | string, ProductAttribute >
|
{} as Record< number | string, ProductAttribute >
|
||||||
);
|
);
|
||||||
|
|
||||||
const attribute = hydratedAttributes.find(
|
const editingAttribute = value.find(
|
||||||
( attr ) => fetchAttributeId( attr ) === editingAttributeId
|
( attr ) => fetchAttributeId( attr ) === editingAttributeId
|
||||||
) as HydratedAttributeType;
|
) as EnhancedProductAttribute;
|
||||||
|
|
||||||
const editAttributeCopy = isOnlyForVariations
|
const editAttributeCopy = isOnlyForVariations
|
||||||
? __(
|
? __(
|
||||||
|
@ -332,15 +243,13 @@ export const AttributeField: React.FC< AttributeFieldProps > = ( {
|
||||||
/>
|
/>
|
||||||
) }
|
) }
|
||||||
<SelectControlMenuSlot />
|
<SelectControlMenuSlot />
|
||||||
{ editingAttributeId && (
|
{ editingAttribute && (
|
||||||
<EditAttributeModal
|
<EditAttributeModal
|
||||||
title={
|
title={ sprintf(
|
||||||
/* translators: %s is the attribute name */
|
/* translators: %s is the attribute name */
|
||||||
sprintf(
|
|
||||||
__( 'Edit %s', 'woocommerce' ),
|
__( 'Edit %s', 'woocommerce' ),
|
||||||
attribute.name
|
editingAttribute.name
|
||||||
)
|
) }
|
||||||
}
|
|
||||||
globalAttributeHelperMessage={ interpolateComponents( {
|
globalAttributeHelperMessage={ interpolateComponents( {
|
||||||
mixedString: editAttributeCopy,
|
mixedString: editAttributeCopy,
|
||||||
components: {
|
components: {
|
||||||
|
@ -359,7 +268,7 @@ export const AttributeField: React.FC< AttributeFieldProps > = ( {
|
||||||
} ) }
|
} ) }
|
||||||
onCancel={ () => setEditingAttributeId( null ) }
|
onCancel={ () => setEditingAttributeId( null ) }
|
||||||
onEdit={ ( changedAttribute ) => {
|
onEdit={ ( changedAttribute ) => {
|
||||||
const newAttributesSet = [ ...hydratedAttributes ];
|
const newAttributesSet = [ ...value ];
|
||||||
const changedAttributeIndex: number =
|
const changedAttributeIndex: number =
|
||||||
newAttributesSet.findIndex( ( attr ) =>
|
newAttributesSet.findIndex( ( attr ) =>
|
||||||
attr.id !== 0
|
attr.id !== 0
|
||||||
|
@ -373,10 +282,10 @@ export const AttributeField: React.FC< AttributeFieldProps > = ( {
|
||||||
changedAttribute
|
changedAttribute
|
||||||
);
|
);
|
||||||
|
|
||||||
updateAttributes( newAttributesSet );
|
handleChange( newAttributesSet );
|
||||||
setEditingAttributeId( null );
|
setEditingAttributeId( null );
|
||||||
} }
|
} }
|
||||||
attribute={ attribute }
|
attribute={ editingAttribute }
|
||||||
/>
|
/>
|
||||||
) }
|
) }
|
||||||
</div>
|
</div>
|
|
@ -18,7 +18,7 @@ import {
|
||||||
AttributeTermInputField,
|
AttributeTermInputField,
|
||||||
CustomAttributeTermInputField,
|
CustomAttributeTermInputField,
|
||||||
} from '../attribute-term-input-field';
|
} from '../attribute-term-input-field';
|
||||||
import { HydratedAttributeType } from './attribute-field';
|
import { EnhancedProductAttribute } from '../../hooks/use-product-attributes';
|
||||||
|
|
||||||
import './edit-attribute-modal.scss';
|
import './edit-attribute-modal.scss';
|
||||||
|
|
||||||
|
@ -36,8 +36,8 @@ type EditAttributeModalProps = {
|
||||||
updateAccessibleLabel?: string;
|
updateAccessibleLabel?: string;
|
||||||
updateLabel?: string;
|
updateLabel?: string;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
onEdit: ( alteredAttribute: HydratedAttributeType ) => void;
|
onEdit: ( alteredAttribute: EnhancedProductAttribute ) => void;
|
||||||
attribute: HydratedAttributeType;
|
attribute: EnhancedProductAttribute;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const EditAttributeModal: React.FC< EditAttributeModalProps > = ( {
|
export const EditAttributeModal: React.FC< EditAttributeModalProps > = ( {
|
||||||
|
@ -64,7 +64,7 @@ export const EditAttributeModal: React.FC< EditAttributeModalProps > = ( {
|
||||||
attribute,
|
attribute,
|
||||||
} ) => {
|
} ) => {
|
||||||
const [ editableAttribute, setEditableAttribute ] = useState<
|
const [ editableAttribute, setEditableAttribute ] = useState<
|
||||||
HydratedAttributeType | undefined
|
EnhancedProductAttribute | undefined
|
||||||
>( { ...attribute } );
|
>( { ...attribute } );
|
||||||
|
|
||||||
const isCustomAttribute = editableAttribute?.id === 0;
|
const isCustomAttribute = editableAttribute?.id === 0;
|
||||||
|
@ -84,7 +84,7 @@ export const EditAttributeModal: React.FC< EditAttributeModalProps > = ( {
|
||||||
}
|
}
|
||||||
onChange={ ( val ) =>
|
onChange={ ( val ) =>
|
||||||
setEditableAttribute( {
|
setEditableAttribute( {
|
||||||
...( editableAttribute as HydratedAttributeType ),
|
...( editableAttribute as EnhancedProductAttribute ),
|
||||||
name: val,
|
name: val,
|
||||||
} )
|
} )
|
||||||
}
|
}
|
||||||
|
@ -102,7 +102,7 @@ export const EditAttributeModal: React.FC< EditAttributeModalProps > = ( {
|
||||||
attributeId={ editableAttribute?.id }
|
attributeId={ editableAttribute?.id }
|
||||||
onChange={ ( val ) => {
|
onChange={ ( val ) => {
|
||||||
setEditableAttribute( {
|
setEditableAttribute( {
|
||||||
...( editableAttribute as HydratedAttributeType ),
|
...( editableAttribute as EnhancedProductAttribute ),
|
||||||
terms: val,
|
terms: val,
|
||||||
} );
|
} );
|
||||||
} }
|
} }
|
||||||
|
@ -115,7 +115,7 @@ export const EditAttributeModal: React.FC< EditAttributeModalProps > = ( {
|
||||||
value={ editableAttribute?.options }
|
value={ editableAttribute?.options }
|
||||||
onChange={ ( val ) => {
|
onChange={ ( val ) => {
|
||||||
setEditableAttribute( {
|
setEditableAttribute( {
|
||||||
...( editableAttribute as HydratedAttributeType ),
|
...( editableAttribute as EnhancedProductAttribute ),
|
||||||
options: val,
|
options: val,
|
||||||
} );
|
} );
|
||||||
} }
|
} }
|
||||||
|
@ -126,7 +126,7 @@ export const EditAttributeModal: React.FC< EditAttributeModalProps > = ( {
|
||||||
<CheckboxControl
|
<CheckboxControl
|
||||||
onChange={ ( val ) =>
|
onChange={ ( val ) =>
|
||||||
setEditableAttribute( {
|
setEditableAttribute( {
|
||||||
...( editableAttribute as HydratedAttributeType ),
|
...( editableAttribute as EnhancedProductAttribute ),
|
||||||
visible: val,
|
visible: val,
|
||||||
} )
|
} )
|
||||||
}
|
}
|
||||||
|
@ -148,7 +148,7 @@ export const EditAttributeModal: React.FC< EditAttributeModalProps > = ( {
|
||||||
isPrimary
|
isPrimary
|
||||||
label={ updateAccessibleLabel }
|
label={ updateAccessibleLabel }
|
||||||
onClick={ () => {
|
onClick={ () => {
|
||||||
onEdit( editableAttribute as HydratedAttributeType );
|
onEdit( editableAttribute as EnhancedProductAttribute );
|
||||||
} }
|
} }
|
||||||
>
|
>
|
||||||
{ updateLabel }
|
{ updateLabel }
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue