diff --git a/.github/workflows/cot-pr-build-and-e2e-tests.yml b/.github/workflows/cot-pr-build-and-e2e-tests.yml index fd8abdaa596..02cfb498434 100644 --- a/.github/workflows/cot-pr-build-and-e2e-tests.yml +++ b/.github/workflows/cot-pr-build-and-e2e-tests.yml @@ -118,102 +118,3 @@ jobs: ${{ env.ALLURE_REPORT_DIR }} if-no-files-found: ignore retention-days: 5 - -# test-summary: -# name: Post test results -# if: | -# always() && -# ! github.event.pull_request.head.repo.fork && -# ( -# contains( needs.*.result, 'success' ) || -# contains( needs.*.result, 'failure' ) -# ) -# runs-on: ubuntu-20.04 -# permissions: -# contents: read -# needs: [cot-api-tests-run, cot-e2e-tests-run] -# steps: -# - name: Create dirs -# run: | -# mkdir -p repo -# mkdir -p artifacts/api -# mkdir -p artifacts/e2e -# mkdir -p output -# -# - name: Checkout code -# uses: actions/checkout@v3 -# with: -# path: repo -# -# - name: Download API test report artifact -# uses: actions/download-artifact@v3 -# with: -# name: api-test-report---pr-${{ github.event.number }} -# path: artifacts/api -# -# - name: Download Playwright E2E test report artifact -# uses: actions/download-artifact@v3 -# with: -# name: e2e-test-report---pr-${{ github.event.number }} -# path: artifacts/e2e -# -# - name: Prepare test summary -# id: prepare-test-summary -# uses: actions/github-script@v6 -# env: -# API_SUMMARY_PATH: ${{ github.workspace }}/artifacts/api/allure-report/widgets/summary.json -# E2E_PW_SUMMARY_PATH: ${{ github.workspace }}/artifacts/e2e/allure-report/widgets/summary.json -# PR_NUMBER: ${{ github.event.number }} -# SHA: ${{ github.event.pull_request.head.sha }} -# with: -# result-encoding: string -# script: | -# const script = require( './repo/.github/workflows/scripts/prepare-test-summary.js' ) -# return await script( { core } ) -# -# - name: Find PR comment by github-actions[bot] -# uses: peter-evans/find-comment@v2 -# id: find-comment -# with: -# issue-number: ${{ github.event.pull_request.number }} -# comment-author: 'github-actions[bot]' -# body-includes: Test Results Summary -# -# - name: Create or update PR comment -# uses: peter-evans/create-or-update-comment@v2 -# with: -# comment-id: ${{ steps.find-comment.outputs.comment-id }} -# issue-number: ${{ github.event.pull_request.number }} -# body: ${{ steps.prepare-test-summary.outputs.result }} -# edit-mode: replace -# -# publish-test-reports: -# name: Publish test reports -# if: | -# always() && -# ! github.event.pull_request.head.repo.fork && -# ( -# contains( needs.*.result, 'success' ) || -# contains( needs.*.result, 'failure' ) -# ) -# runs-on: ubuntu-20.04 -# needs: [cot-api-tests-run, cot-e2e-tests-run] -# env: -# GITHUB_TOKEN: ${{ secrets.REPORTS_TOKEN }} -# PR_NUMBER: ${{ github.event.number }} -# RUN_ID: ${{ github.run_id }} -# COMMIT_SHA: ${{ github.event.pull_request.head.sha }} -# steps: -# - name: Publish test reports -# env: -# API_ARTIFACT: api-test-report---pr-${{ github.event.number }} -# E2E_ARTIFACT: e2e-test-report---pr-${{ github.event.number }} -# run: | -# gh workflow run publish-test-reports-pr.yml \ -# -f run_id=$RUN_ID \ -# -f api_artifact=$API_ARTIFACT \ -# -f e2e_artifact=$E2E_ARTIFACT \ -# -f pr_number=$PR_NUMBER \ -# -f commit_sha=$COMMIT_SHA \ -# -f s3_root=public \ -# --repo woocommerce/woocommerce-test-reports diff --git a/.github/workflows/pr-lint-test-js.yml b/.github/workflows/pr-lint-test-js.yml index c5a58e444a6..972367f0abd 100644 --- a/.github/workflows/pr-lint-test-js.yml +++ b/.github/workflows/pr-lint-test-js.yml @@ -23,7 +23,7 @@ jobs: uses: ./.github/actions/setup-woocommerce-monorepo - name: Lint - run: pnpm run -r --filter='woocommerce/client/admin...' --filter='!@woocommerce/e2e*' --filter='!@woocommerce/api' --color lint + run: pnpm run -r --filter='release-posts' --filter='woocommerce/client/admin...' --filter='!@woocommerce/e2e*' --filter='!@woocommerce/api' --color lint - name: Test run: pnpm run test --filter='woocommerce/client/admin...' --filter='!@woocommerce/e2e*' --filter='!@woocommerce/api' --color diff --git a/.github/workflows/release-changelog.yml b/.github/workflows/release-changelog.yml index b8ce8d8572a..ab77d208efb 100644 --- a/.github/workflows/release-changelog.yml +++ b/.github/workflows/release-changelog.yml @@ -21,8 +21,8 @@ jobs: create-changelog-prs: runs-on: ubuntu-20.04 permissions: - contents: read - pull-requests: write + contents: write + pull-requests: write steps: - name: Checkout code uses: actions/checkout@v3 @@ -46,6 +46,9 @@ jobs: - name: 'Generate the changelog file' run: pnpm --filter=woocommerce run changelog write --add-pr-num -n -vvv --use-version ${{ inputs.releaseVersion }} + - name: Checkout pnpm-lock.yaml to prevent issues + run: git checkout pnpm-lock.yaml + - name: 'git rm deleted files' run: git rm $(git ls-files --deleted) diff --git a/.github/workflows/release-code-freeze.yml b/.github/workflows/release-code-freeze.yml index ffb31eea89c..1a73fddb62b 100644 --- a/.github/workflows/release-code-freeze.yml +++ b/.github/workflows/release-code-freeze.yml @@ -60,7 +60,8 @@ jobs: name: 'Maybe create next milestone and release branch' runs-on: ubuntu-20.04 permissions: - contents: read + contents: write + issues: write needs: verify-code-freeze if: needs.verify-code-freeze.outputs.freeze == 0 outputs: @@ -89,8 +90,8 @@ jobs: name: Preps trunk for next development cycle runs-on: ubuntu-20.04 permissions: - contents: read - pull-requests: write + contents: write + pull-requests: write needs: maybe-create-next-milestone-and-release-branch steps: - name: Checkout code @@ -159,7 +160,7 @@ jobs: name: 'Trigger changelog action' runs-on: ubuntu-20.04 permissions: - actions: write + actions: write needs: maybe-create-next-milestone-and-release-branch steps: - name: 'Trigger changelog action' diff --git a/.github/workflows/release-wc-beta-tester.yml b/.github/workflows/release-wc-beta-tester.yml new file mode 100644 index 00000000000..c2e6c7adf45 --- /dev/null +++ b/.github/workflows/release-wc-beta-tester.yml @@ -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 diff --git a/packages/js/components/changelog/add-35851-tree-control b/packages/js/components/changelog/add-35851-tree-control new file mode 100644 index 00000000000..24395c6622b --- /dev/null +++ b/packages/js/components/changelog/add-35851-tree-control @@ -0,0 +1,4 @@ +Significance: minor +Type: dev + +Create tree-control component diff --git a/packages/js/components/changelog/add-36074_basic_fields b/packages/js/components/changelog/add-36074_basic_fields new file mode 100644 index 00000000000..cc93a0ca6c4 --- /dev/null +++ b/packages/js/components/changelog/add-36074_basic_fields @@ -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. diff --git a/packages/js/components/changelog/add-input-props-to-exp-select b/packages/js/components/changelog/add-input-props-to-exp-select new file mode 100644 index 00000000000..a4123d1f6ea --- /dev/null +++ b/packages/js/components/changelog/add-input-props-to-exp-select @@ -0,0 +1,4 @@ +Significance: patch +Type: add + +Add an optional "InputProps" to experimental SelectControl component diff --git a/packages/js/components/changelog/dev-migrate-table-component-to-ts b/packages/js/components/changelog/dev-migrate-table-component-to-ts new file mode 100644 index 00000000000..0f3389fca67 --- /dev/null +++ b/packages/js/components/changelog/dev-migrate-table-component-to-ts @@ -0,0 +1,4 @@ +Significance: patch +Type: dev + +Migrate Table component to TS \ No newline at end of file diff --git a/packages/js/components/changelog/try-product-mvp-slotfill-experiments b/packages/js/components/changelog/try-product-mvp-slotfill-experiments new file mode 100644 index 00000000000..7df70dd246d --- /dev/null +++ b/packages/js/components/changelog/try-product-mvp-slotfill-experiments @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Adding experimental component SlotContext diff --git a/packages/js/components/changelog/update-36016-product-details-slotfill b/packages/js/components/changelog/update-36016-product-details-slotfill new file mode 100644 index 00000000000..d03cd2109ac --- /dev/null +++ b/packages/js/components/changelog/update-36016-product-details-slotfill @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Adding ProductSectionLayout component and changing default order for WooProductSectionItem component. diff --git a/packages/js/components/src/experimental-select-control/select-control.tsx b/packages/js/components/src/experimental-select-control/select-control.tsx index e811d1d3e4e..9fece1fa36d 100644 --- a/packages/js/components/src/experimental-select-control/select-control.tsx +++ b/packages/js/components/src/experimental-select-control/select-control.tsx @@ -7,6 +7,7 @@ import { UseComboboxState, UseComboboxStateChangeOptions, useMultipleSelection, + GetInputPropsOptions, } from 'downshift'; import { useState, @@ -65,6 +66,7 @@ export type SelectControlProps< ItemType > = { selected: ItemType | ItemType[] | null; className?: string; disabled?: boolean; + inputProps?: GetInputPropsOptions; suffix?: JSX.Element | null; /** * This is a feature already implemented in downshift@7.0.0 through the @@ -119,6 +121,7 @@ function SelectControl< ItemType = DefaultItemType >( { selected, className, disabled, + inputProps = {}, suffix = , __experimentalOpenMenuOnFocus = false, }: SelectControlProps< ItemType > ) { @@ -268,6 +271,7 @@ function SelectControl< ItemType = DefaultItemType >( { onBlur: () => setIsFocused( false ), placeholder, disabled, + ...inputProps, } ) } suffix={ suffix } > diff --git a/packages/js/components/src/experimental-tree-control/hooks/use-linked-tree.ts b/packages/js/components/src/experimental-tree-control/hooks/use-linked-tree.ts new file mode 100644 index 00000000000..94ff95706b8 --- /dev/null +++ b/packages/js/components/src/experimental-tree-control/hooks/use-linked-tree.ts @@ -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; +} diff --git a/packages/js/components/src/experimental-tree-control/hooks/use-tree-item.ts b/packages/js/components/src/experimental-tree-control/hooks/use-tree-item.ts new file mode 100644 index 00000000000..9043fe00f69 --- /dev/null +++ b/packages/js/components/src/experimental-tree-control/hooks/use-tree-item.ts @@ -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, + }, + }; +} diff --git a/packages/js/components/src/experimental-tree-control/hooks/use-tree.ts b/packages/js/components/src/experimental-tree-control/hooks/use-tree.ts new file mode 100644 index 00000000000..2ab6b889c58 --- /dev/null +++ b/packages/js/components/src/experimental-tree-control/hooks/use-tree.ts @@ -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, + }, + }; +} diff --git a/packages/js/components/src/experimental-tree-control/index.ts b/packages/js/components/src/experimental-tree-control/index.ts new file mode 100644 index 00000000000..bf4fdd7d970 --- /dev/null +++ b/packages/js/components/src/experimental-tree-control/index.ts @@ -0,0 +1,4 @@ +export * from './tree'; +export * from './tree-control'; +export * from './tree-item'; +export * from './types'; diff --git a/packages/js/components/src/experimental-tree-control/stories/index.tsx b/packages/js/components/src/experimental-tree-control/stories/index.tsx new file mode 100644 index 00000000000..d62a8ed1964 --- /dev/null +++ b/packages/js/components/src/experimental-tree-control/stories/index.tsx @@ -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 ( + + + + ); +}; + +export default { + title: 'WooCommerce Admin/experimental/TreeControl', + component: TreeControl, +}; diff --git a/packages/js/components/src/experimental-tree-control/tree-control.tsx b/packages/js/components/src/experimental-tree-control/tree-control.tsx new file mode 100644 index 00000000000..24a484a2995 --- /dev/null +++ b/packages/js/components/src/experimental-tree-control/tree-control.tsx @@ -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 ; +} ); diff --git a/packages/js/components/src/experimental-tree-control/tree-item.scss b/packages/js/components/src/experimental-tree-control/tree-item.scss new file mode 100644 index 00000000000..a62dbde0122 --- /dev/null +++ b/packages/js/components/src/experimental-tree-control/tree-item.scss @@ -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; + } + } +} diff --git a/packages/js/components/src/experimental-tree-control/tree-item.tsx b/packages/js/components/src/experimental-tree-control/tree-item.tsx new file mode 100644 index 00000000000..e8e7c407932 --- /dev/null +++ b/packages/js/components/src/experimental-tree-control/tree-item.tsx @@ -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 ( +
  • +
    +
    + { item.data.label } +
    +
    + + { Boolean( item.children.length ) && } +
  • + ); +} ); diff --git a/packages/js/components/src/experimental-tree-control/tree.scss b/packages/js/components/src/experimental-tree-control/tree.scss new file mode 100644 index 00000000000..221208658d5 --- /dev/null +++ b/packages/js/components/src/experimental-tree-control/tree.scss @@ -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; + } +} diff --git a/packages/js/components/src/experimental-tree-control/tree.tsx b/packages/js/components/src/experimental-tree-control/tree.tsx new file mode 100644 index 00000000000..da3a2200839 --- /dev/null +++ b/packages/js/components/src/experimental-tree-control/tree.tsx @@ -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 ( +
      + { items.map( ( child ) => ( + + ) ) } +
    + ); +} ); diff --git a/packages/js/components/src/experimental-tree-control/types.ts b/packages/js/components/src/experimental-tree-control/types.ts new file mode 100644 index 00000000000..c925bbb8574 --- /dev/null +++ b/packages/js/components/src/experimental-tree-control/types.ts @@ -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[]; +}; diff --git a/packages/js/components/src/index.ts b/packages/js/components/src/index.ts index 8330ed8b4f2..8cea1e13515 100644 --- a/packages/js/components/src/index.ts +++ b/packages/js/components/src/index.ts @@ -87,3 +87,14 @@ export { CollapsibleContent } from './collapsible-content'; export { createOrderedChildren, sortFillsByOrder } from './utils'; export { WooProductFieldItem as __experimentalWooProductFieldItem } from './woo-product-field-item'; export { WooProductSectionItem as __experimentalWooProductSectionItem } from './woo-product-section-item'; +export { + ProductSectionLayout as __experimentalProductSectionLayout, + ProductFieldSection as __experimentalProductFieldSection, +} from './product-section-layout'; +export * from './product-fields'; +export { + SlotContextProvider, + useSlotContext, + SlotContextType, + SlotContextHelpersType, +} from './slot-context'; diff --git a/packages/js/components/src/product-fields/api/render.tsx b/packages/js/components/src/product-fields/api/render.tsx index 5fc727f08af..4ef208cad63 100644 --- a/packages/js/components/src/product-fields/api/render.tsx +++ b/packages/js/components/src/product-fields/api/render.tsx @@ -3,6 +3,10 @@ */ import { select } from '@wordpress/data'; import { createElement } from '@wordpress/element'; +import { + // @ts-expect-error `__experimentalInputControl` does exist. + __experimentalInputControl as InputControl, +} from '@wordpress/components'; /** * Internal dependencies @@ -19,10 +23,7 @@ export function renderField( name: string, props: Record< string, any > ) { return ; } if ( fieldConfig.type ) { - return createElement( 'input', { - type: fieldConfig.type, - ...props, - } ); + return ; } return null; } diff --git a/packages/js/components/src/product-fields/fields/basic-select-control/index.ts b/packages/js/components/src/product-fields/fields/basic-select-control/index.ts new file mode 100644 index 00000000000..178d735e860 --- /dev/null +++ b/packages/js/components/src/product-fields/fields/basic-select-control/index.ts @@ -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, +}; diff --git a/packages/js/components/src/product-fields/fields/basic-select-control/render.tsx b/packages/js/components/src/product-fields/fields/basic-select-control/render.tsx new file mode 100644 index 00000000000..b108cfb9b3d --- /dev/null +++ b/packages/js/components/src/product-fields/fields/basic-select-control/render.tsx @@ -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 ( + + ); +}; + +export default SelectControlField; diff --git a/packages/js/components/src/product-fields/fields/checkbox/index.ts b/packages/js/components/src/product-fields/fields/checkbox/index.ts new file mode 100644 index 00000000000..b1f0d038176 --- /dev/null +++ b/packages/js/components/src/product-fields/fields/checkbox/index.ts @@ -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, +}; diff --git a/packages/js/components/src/product-fields/fields/checkbox/render.tsx b/packages/js/components/src/product-fields/fields/checkbox/render.tsx new file mode 100644 index 00000000000..4a82e8e3b19 --- /dev/null +++ b/packages/js/components/src/product-fields/fields/checkbox/render.tsx @@ -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 ( + + ); +}; + +export default CheckboxField; diff --git a/packages/js/components/src/product-fields/fields/index.ts b/packages/js/components/src/product-fields/fields/index.ts new file mode 100644 index 00000000000..2ccb3c83653 --- /dev/null +++ b/packages/js/components/src/product-fields/fields/index.ts @@ -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 ); + } ); +}; diff --git a/packages/js/components/src/product-fields/fields/radio/index.ts b/packages/js/components/src/product-fields/fields/radio/index.ts new file mode 100644 index 00000000000..db56c058126 --- /dev/null +++ b/packages/js/components/src/product-fields/fields/radio/index.ts @@ -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, +}; diff --git a/packages/js/components/src/product-fields/fields/radio/render.tsx b/packages/js/components/src/product-fields/fields/radio/render.tsx new file mode 100644 index 00000000000..0577f10fe44 --- /dev/null +++ b/packages/js/components/src/product-fields/fields/radio/render.tsx @@ -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 ( + + ); +}; + +export default RadioField; diff --git a/packages/js/components/src/product-fields/fields/text/index.ts b/packages/js/components/src/product-fields/fields/text/index.ts new file mode 100644 index 00000000000..5dd23abeb02 --- /dev/null +++ b/packages/js/components/src/product-fields/fields/text/index.ts @@ -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, +}; diff --git a/packages/js/components/src/product-fields/fields/text/render.tsx b/packages/js/components/src/product-fields/fields/text/render.tsx new file mode 100644 index 00000000000..ca441faeac5 --- /dev/null +++ b/packages/js/components/src/product-fields/fields/text/render.tsx @@ -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 ( + + ); +}; + +export default TextField; diff --git a/packages/js/components/src/product-fields/fields/toggle/index.ts b/packages/js/components/src/product-fields/fields/toggle/index.ts new file mode 100644 index 00000000000..22d2aa2dd0f --- /dev/null +++ b/packages/js/components/src/product-fields/fields/toggle/index.ts @@ -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, +}; diff --git a/packages/js/components/src/product-fields/fields/toggle/render.tsx b/packages/js/components/src/product-fields/fields/toggle/render.tsx new file mode 100644 index 00000000000..bbfa33c6e17 --- /dev/null +++ b/packages/js/components/src/product-fields/fields/toggle/render.tsx @@ -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 ( + + { label } + { tooltip && } + + } + checked={ value } + onChange={ onChange } + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore disabled prop exists + disabled={ disabled } + /> + ); +}; + +export default ToggleField; diff --git a/packages/js/components/src/product-fields/fields/types.ts b/packages/js/components/src/product-fields/fields/types.ts new file mode 100644 index 00000000000..c8ed482089b --- /dev/null +++ b/packages/js/components/src/product-fields/fields/types.ts @@ -0,0 +1,6 @@ +export type BaseProductFieldProps< T > = { + value: T; + onChange: ( value: T ) => void; + label: string; + disabled?: boolean; +}; diff --git a/packages/js/components/src/product-fields/index.ts b/packages/js/components/src/product-fields/index.ts index a28dcc4ff74..30d0c89835a 100644 --- a/packages/js/components/src/product-fields/index.ts +++ b/packages/js/components/src/product-fields/index.ts @@ -1,2 +1,3 @@ export { store } from './store'; export * from './api'; +export * from './fields'; diff --git a/packages/js/components/src/product-fields/store/types.ts b/packages/js/components/src/product-fields/store/types.ts index c5de06cb663..ebab49c0509 100644 --- a/packages/js/components/src/product-fields/store/types.ts +++ b/packages/js/components/src/product-fields/store/types.ts @@ -1,11 +1,11 @@ /** * External dependencies */ -import { ComponentType } from 'react'; +import { ComponentType, HTMLInputTypeAttribute } from 'react'; export type ProductFieldDefinition = { name: string; - type?: string; + type?: HTMLInputTypeAttribute; // eslint-disable-next-line @typescript-eslint/no-explicit-any render?: ComponentType; }; diff --git a/packages/js/components/src/product-fields/stories/index.tsx b/packages/js/components/src/product-fields/stories/index.tsx index 04496d889ca..4abf6631d54 100644 --- a/packages/js/components/src/product-fields/stories/index.tsx +++ b/packages/js/components/src/product-fields/stories/index.tsx @@ -3,56 +3,92 @@ */ import React from 'react'; import { useState, createElement } from '@wordpress/element'; -import { createRegistry, RegistryProvider, select } from '@wordpress/data'; -import { - // @ts-expect-error `__experimentalInputControl` does exist. - __experimentalInputControl as InputControl, -} from '@wordpress/components'; +import { createRegistry, RegistryProvider } from '@wordpress/data'; /** * Internal dependencies */ import { store } from '../store'; -import { registerProductField, renderField } from '../api'; +import { renderField } from '../api'; +import { registerCoreProductFields } from '../fields'; const registry = createRegistry(); // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore No types for this exist yet. registry.register( store ); -registerProductField( 'text', { - name: 'text', - render: ( props ) => { - return ; - }, -} ); +registerCoreProductFields(); -registerProductField( 'number', { - name: 'number', - render: () => { - return ; +const fieldConfigs = [ + { + name: 'text-field', + type: 'text', + label: 'Text field', }, -} ); + { + name: 'number-field', + type: 'number', + label: 'Number field', + }, + { + name: 'toggle-field', + type: 'toggle', + label: 'Toggle field', + }, + { + name: 'checkbox-field', + type: 'checkbox', + label: 'Checkbox field', + }, + { + name: 'radio-field', + type: 'radio', + label: 'Radio field', + options: [ + { label: 'Option', value: 'option' }, + { label: 'Option 2', value: 'option2' }, + { label: 'Option 3', value: 'option3' }, + ], + }, + { + name: 'basic-select-control-field', + type: 'basic-select-control', + label: 'Basic select control field', + options: [ + { label: 'Option', value: 'option' }, + { label: 'Option 2', value: 'option2' }, + { label: 'Option 3', value: 'option3' }, + ], + }, +]; const RenderField = () => { - const fields: string[] = select( store ).getRegisteredProductFields(); const [ selectedField, setSelectedField ] = useState( - fields ? fields[ 0 ] : undefined + fieldConfigs[ 0 ].name || undefined ); + const [ value, setValue ] = useState(); const handleChange = ( event ) => { setSelectedField( event.target.value ); }; + const selectedFieldConfig = fieldConfigs.find( + ( f ) => f.name === selectedField + ); return (
    - { selectedField && renderField( selectedField, { name: 'test' } ) } + { selectedFieldConfig && + renderField( selectedFieldConfig.type, { + value, + onChange: setValue, + ...selectedFieldConfig, + } ) }
    ); }; @@ -65,6 +101,21 @@ export const Basic: React.FC = () => { ); }; +export const ToggleWithTooltip: React.FC = () => { + const [ value, setValue ] = useState(); + return ( + + { renderField( 'toggle', { + value, + onChange: setValue, + name: 'toggle', + label: 'Toggle with Tooltip', + tooltip: 'This is a sample tooltip', + } ) } + + ); +}; + export default { title: 'WooCommerce Admin/experimental/product-fields', component: Basic, diff --git a/packages/js/components/src/product-section-layout/index.ts b/packages/js/components/src/product-section-layout/index.ts new file mode 100644 index 00000000000..9c670115867 --- /dev/null +++ b/packages/js/components/src/product-section-layout/index.ts @@ -0,0 +1,2 @@ +export * from './product-section-layout'; +export * from './product-field-section'; diff --git a/packages/js/components/src/product-section-layout/product-field-section.tsx b/packages/js/components/src/product-section-layout/product-field-section.tsx new file mode 100644 index 00000000000..5ae1d11b36c --- /dev/null +++ b/packages/js/components/src/product-section-layout/product-field-section.tsx @@ -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, +} ) => ( + + + + { children } + + + + +); diff --git a/packages/js/components/src/product-section-layout/product-section-layout.tsx b/packages/js/components/src/product-section-layout/product-section-layout.tsx new file mode 100644 index 00000000000..5c80ab9e426 --- /dev/null +++ b/packages/js/components/src/product-section-layout/product-section-layout.tsx @@ -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, +} ) => ( + + { Children.map( children, ( child ) => { + if ( isValidElement( child ) && child.props.onChange ) { + return
    { child }
    ; + } + return child; + } ) } +
    +); diff --git a/packages/js/components/src/product-section-layout/style.scss b/packages/js/components/src/product-section-layout/style.scss new file mode 100644 index 00000000000..acd31a35894 --- /dev/null +++ b/packages/js/components/src/product-section-layout/style.scss @@ -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; + } +} diff --git a/packages/js/components/src/slot-context/index.ts b/packages/js/components/src/slot-context/index.ts new file mode 100644 index 00000000000..86ac84d1fdf --- /dev/null +++ b/packages/js/components/src/slot-context/index.ts @@ -0,0 +1 @@ +export * from './slot-context'; diff --git a/packages/js/components/src/slot-context/slot-context.tsx b/packages/js/components/src/slot-context/slot-context.tsx new file mode 100644 index 00000000000..49b70ccb86a --- /dev/null +++ b/packages/js/components/src/slot-context/slot-context.tsx @@ -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 ( + + fills[ arr[ 0 ].props._id ]?.visible !== false + ); + }, + fills, + } } + > + { children } + + ); +}; + +export const useSlotContext = () => { + const slotContext = useContext( SlotContext ); + + if ( slotContext === undefined ) { + throw new Error( + 'useSlotContext must be used within a SlotContextProvider' + ); + } + + return slotContext; +}; diff --git a/packages/js/components/src/style.scss b/packages/js/components/src/style.scss index 6ee55cde07f..cf6478c655a 100644 --- a/packages/js/components/src/style.scss +++ b/packages/js/components/src/style.scss @@ -55,3 +55,4 @@ @import 'tour-kit/style.scss'; @import 'collapsible-content/style.scss'; @import 'form/style.scss'; +@import 'product-section-layout/style.scss'; diff --git a/packages/js/components/src/table/empty.js b/packages/js/components/src/table/empty.tsx similarity index 50% rename from packages/js/components/src/table/empty.js rename to packages/js/components/src/table/empty.tsx index 503ab1fc9a2..80550ced137 100644 --- a/packages/js/components/src/table/empty.js +++ b/packages/js/components/src/table/empty.tsx @@ -1,39 +1,32 @@ /** * External dependencies */ -import PropTypes from 'prop-types'; import { createElement } from '@wordpress/element'; +import React from 'react'; + +type EmptyTableProps = { + children: React.ReactNode; + + /** An integer with the number of rows the box should occupy. */ + numberOfRows?: number; +}; /** * `EmptyTable` displays a blank space with an optional message passed as a children node * with the purpose of replacing a table with no rows. * It mimics the same height a table would have according to the `numberOfRows` prop. - * - * @param {Object} props - * @param {Node} props.children - * @param {number} props.numberOfRows - * @return {Object} - */ -const EmptyTable = ( { children, numberOfRows } ) => { +const EmptyTable = ( { children, numberOfRows = 5 }: EmptyTableProps ) => { return (
    { children }
    ); }; -EmptyTable.propTypes = { - /** - * An integer with the number of rows the box should occupy. - */ - numberOfRows: PropTypes.number, -}; - -EmptyTable.defaultProps = { - numberOfRows: 5, -}; - export default EmptyTable; diff --git a/packages/js/components/src/table/index.js b/packages/js/components/src/table/index.js deleted file mode 100644 index 2723ceb6c32..00000000000 --- a/packages/js/components/src/table/index.js +++ /dev/null @@ -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, ``, ``, and ``. - * 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 ( - - - - { title } - -
    - { actions } -
    - { showMenu && ( - ( - - - { __( 'Columns:', 'woocommerce' ) } - - { allHeaders.map( - ( { key, label, required } ) => { - if ( required ) { - return null; - } - return ( - - { label } - - ); - } - ) } - - ) } - /> - ) } -
    - - { isLoading ? ( - - - { __( - 'Your requested data is loading', - 'woocommerce' - ) } - - - - ) : ( -
    - ) } - - - - { isLoading ? ( - - ) : ( - - - - { summary && } - - ) } - - - ); - } -} - -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; diff --git a/packages/js/components/src/table/index.tsx b/packages/js/components/src/table/index.tsx new file mode 100644 index 00000000000..6c12b11f810 --- /dev/null +++ b/packages/js/components/src/table/index.tsx @@ -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, `
    `, ``, and ``. + * 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 ( + + + + { title } + +
    { actions }
    + { showMenu && ( + ( + + { /* @ts-expect-error: Ignoring the error until we migrate ellipsis-menu to TS*/ } + + { /* @ts-expect-error: Allow string */ } + { __( 'Columns:', 'woocommerce' ) } + + { allHeaders.map( + ( { key, label, required } ) => { + if ( required ) { + return null; + } + return ( + + { label } + + ); + } + ) } + + ) } + /> + ) } +
    + { /* Ignoring the error to make it backward compatible for now. */ } + { /* @ts-expect-error: size must be one of small, medium, largel, xSmall, extraSmall. */ } + + { isLoading ? ( + + + { __( + 'Your requested data is loading', + 'woocommerce' + ) } + + + + ) : ( +
    void ) + } + rowKey={ rowKey } + emptyMessage={ emptyMessage } + /> + ) } + + + { /* @ts-expect-error: justify is missing from the latest @types/wordpress__components */ } + + { isLoading ? ( + + ) : ( + + + + { summary && } + + ) } + + + ); +}; + +export default TableCard; diff --git a/packages/js/components/src/table/placeholder.js b/packages/js/components/src/table/placeholder.js deleted file mode 100644 index cd6e2cbf999..00000000000 --- a/packages/js/components/src/table/placeholder.js +++ /dev/null @@ -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: , - } ) ) - ); - - return ( -
    - ); - } -} - -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; diff --git a/packages/js/components/src/table/placeholder.tsx b/packages/js/components/src/table/placeholder.tsx new file mode 100644 index 00000000000..fbc8ee916f1 --- /dev/null +++ b/packages/js/components/src/table/placeholder.tsx @@ -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: , + } ) ) + ); + const tableProps = { query, caption, headers, numberOfRows, ...props }; + return ( +
    + ); +}; + +export default TablePlaceholder; diff --git a/packages/js/components/src/table/stories/empty-table.js b/packages/js/components/src/table/stories/empty-table.tsx similarity index 83% rename from packages/js/components/src/table/stories/empty-table.js rename to packages/js/components/src/table/stories/empty-table.tsx index d7688261b34..cc2674a85f4 100644 --- a/packages/js/components/src/table/stories/empty-table.js +++ b/packages/js/components/src/table/stories/empty-table.tsx @@ -2,6 +2,7 @@ * External dependencies */ import { EmptyTable } from '@woocommerce/components'; +import { createElement } from '@wordpress/element'; export const Basic = () => There are no entries.; diff --git a/packages/js/components/src/table/stories/index.js b/packages/js/components/src/table/stories/index.ts similarity index 100% rename from packages/js/components/src/table/stories/index.js rename to packages/js/components/src/table/stories/index.ts diff --git a/packages/js/components/src/table/stories/table-card.js b/packages/js/components/src/table/stories/table-card.js deleted file mode 100644 index cc485146080..00000000000 --- a/packages/js/components/src/table/stories/table-card.js +++ /dev/null @@ -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 ( - ( value ) => - setState( { - query: { - [ param ]: value, - }, - } ) } - query={ query } - rowsPerPage={ 7 } - totalRows={ 10 } - summary={ summary } - /> - ); -}; - -export const Basic = () => ; - -export default { - title: 'WooCommerce Admin/components/TableCard', - component: TableCard, -}; diff --git a/packages/js/components/src/table/stories/table-card.tsx b/packages/js/components/src/table/stories/table-card.tsx new file mode 100644 index 00000000000..86aa3202426 --- /dev/null +++ b/packages/js/components/src/table/stories/table-card.tsx @@ -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 ( + ( 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 ( + { + setAction1Text( 'Action 1 Clicked' ); + } } + > + { action1Text } + , + , + ] } + 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 = () => ; +export const Actions = () => ; + +export default { + title: 'WooCommerce Admin/components/TableCard', + component: TableCard, +}; diff --git a/packages/js/components/src/table/stories/table-placeholder.js b/packages/js/components/src/table/stories/table-placeholder.tsx similarity index 52% rename from packages/js/components/src/table/stories/table-placeholder.js rename to packages/js/components/src/table/stories/table-placeholder.tsx index ea947bb0125..ea1dd40ac65 100644 --- a/packages/js/components/src/table/stories/table-placeholder.js +++ b/packages/js/components/src/table/stories/table-placeholder.tsx @@ -3,17 +3,21 @@ */ import { Card } from '@wordpress/components'; import { TablePlaceholder } from '@woocommerce/components'; +import { createElement } from '@wordpress/element'; /** * Internal dependencies */ import { headers } from './index'; -export const Basic = () => ( - - - -); +export const Basic = () => { + return ( + /* @ts-expect-error: size must be one of small, medium, largel, xSmall, extraSmall. */ + + + + ); +}; export default { title: 'WooCommerce Admin/components/TablePlaceholder', diff --git a/packages/js/components/src/table/stories/table-summary-placeholder.js b/packages/js/components/src/table/stories/table-summary-placeholder.tsx similarity index 50% rename from packages/js/components/src/table/stories/table-summary-placeholder.js rename to packages/js/components/src/table/stories/table-summary-placeholder.tsx index 6c5e15ed11d..6343c44aa73 100644 --- a/packages/js/components/src/table/stories/table-summary-placeholder.js +++ b/packages/js/components/src/table/stories/table-summary-placeholder.tsx @@ -3,14 +3,18 @@ */ import { Card, CardFooter } from '@wordpress/components'; import { TableSummaryPlaceholder } from '@woocommerce/components'; +import { createElement } from '@wordpress/element'; -export const Basic = () => ( - - - - - -); +export const Basic = () => { + return ( + + { /* @ts-expect-error: justify is missing from the latest type def. */ } + + + + + ); +}; export default { title: 'WooCommerce Admin/components/TableSummaryPlaceholder', diff --git a/packages/js/components/src/table/stories/table-summary.js b/packages/js/components/src/table/stories/table-summary.jsx similarity index 100% rename from packages/js/components/src/table/stories/table-summary.js rename to packages/js/components/src/table/stories/table-summary.jsx diff --git a/packages/js/components/src/table/stories/table.js b/packages/js/components/src/table/stories/table.tsx similarity index 54% rename from packages/js/components/src/table/stories/table.js rename to packages/js/components/src/table/stories/table.tsx index 1ea3ffb97c9..a7a68b6bce7 100644 --- a/packages/js/components/src/table/stories/table.js +++ b/packages/js/components/src/table/stories/table.tsx @@ -3,6 +3,7 @@ */ import { Card } from '@wordpress/components'; import { Table } from '@woocommerce/components'; +import { createElement } from '@wordpress/element'; /** * Internal dependencies @@ -20,17 +21,20 @@ export const Basic = () => ( ); -export const NoDataCustomMessage = () => ( - -
    row[ 0 ].value } - emptyMessage="Custom empty message" - /> - -); +export const NoDataCustomMessage = () => { + return ( + /* @ts-expect-error: size must be one of small, medium, largel, xSmall, extraSmall. */ + +
    row[ 0 ].value } + emptyMessage="Custom empty message" + /> + + ); +}; export default { title: 'WooCommerce Admin/components/Table', diff --git a/packages/js/components/src/table/summary.js b/packages/js/components/src/table/summary.tsx similarity index 76% rename from packages/js/components/src/table/summary.js rename to packages/js/components/src/table/summary.tsx index e3e85ca724f..784d47632da 100644 --- a/packages/js/components/src/table/summary.js +++ b/packages/js/components/src/table/summary.tsx @@ -1,17 +1,17 @@ /** * External dependencies */ -import PropTypes from 'prop-types'; import { createElement } from '@wordpress/element'; /** - * A component to display summarized table data - the list of data passed in on a single line. - * - * @param {Object} props - * @param {Array} props.data - * @return {Object} - + * Internal dependencies */ -const TableSummary = ( { data } ) => { +import { TableSummaryProps } from './types'; + +/** + * A component to display summarized table data - the list of data passed in on a single line. + */ +const TableSummary = ( { data }: TableSummaryProps ) => { return (
      { 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; /** diff --git a/packages/js/components/src/table/table.js b/packages/js/components/src/table/table.js deleted file mode 100644 index 0f484027b69..00000000000 --- a/packages/js/components/src/table/table.js +++ /dev/null @@ -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 ( -
      -
    - - - - { 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 = ( - - - { label } - - { screenReaderLabel && ( - - { screenReaderLabel } - - ) } - - ); - - return ( - - ); - } ) } - - { hasData ? ( - rows.map( ( 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 ( - - { getDisplay( cell ) } - - ); - } ) } - - ) ) - ) : ( - - - - ) } - -
    - { caption } - { tabIndex === '0' && ( - - { __( '(scroll to see more)', 'woocommerce' ) } - - ) } -
    - { isSortable ? ( - - - - { iconLabel } - - - ) : ( - textLabel - ) } -
    - { emptyMessage ?? - __( - 'No data to display', - 'woocommerce' - ) } -
    - - ); - } -} - -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 ); diff --git a/packages/js/components/src/table/table.tsx b/packages/js/components/src/table/table.tsx new file mode 100644 index 00000000000..6de75954e85 --- /dev/null +++ b/packages/js/components/src/table/table.tsx @@ -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 ( +
    + + + + + { 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 = ( + + + { label } + + { screenReaderLabel && ( + + { screenReaderLabel } + + ) } + + ); + + return ( + + ); + } ) } + + { hasData ? ( + rows.map( ( 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 ( + + { getDisplay( cell ) } + + ); + } ) } + + ) ) + ) : ( + + + + ) } + +
    + { caption } + { tabIndex === 0 && ( + + { __( '(scroll to see more)', 'woocommerce' ) } + + ) } +
    + { isSortable ? ( + + + + { iconLabel } + + + ) : ( + textLabel + ) } +
    + { emptyMessage ?? + __( 'No data to display', 'woocommerce' ) } +
    +
    + ); +}; + +export default withInstanceId( Table ); diff --git a/packages/js/components/src/table/types.ts b/packages/js/components/src/table/types.ts new file mode 100644 index 00000000000..79b61e92a3d --- /dev/null +++ b/packages/js/components/src/table/types.ts @@ -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; +}; diff --git a/packages/js/components/src/utils.tsx b/packages/js/components/src/utils.tsx index 682a1a0aded..34117864d5d 100644 --- a/packages/js/components/src/utils.tsx +++ b/packages/js/components/src/utils.tsx @@ -1,7 +1,7 @@ /** * External dependencies */ -import React, { isValidElement, Fragment } from 'react'; +import { isValidElement, Fragment } from 'react'; import { Slot, Fill } from '@wordpress/components'; import { cloneElement, createElement } from '@wordpress/element'; @@ -13,15 +13,16 @@ import { cloneElement, createElement } from '@wordpress/element'; * @param {Array} props - Fill props. * @return {Node} Node. */ -function createOrderedChildren< T = Fill.Props >( +function createOrderedChildren< T = Fill.Props, S = Record< string, unknown > >( children: React.ReactNode, order: number, - props: T + props: T, + injectProps?: S ) { if ( typeof children === 'function' ) { - return cloneElement( children( props ), { order } ); + return cloneElement( children( props ), { order, ...injectProps } ); } else if ( isValidElement( children ) ) { - return cloneElement( children, { ...props, order } ); + return cloneElement( children, { ...props, order, ...injectProps } ); } throw Error( 'Invalid children type' ); } diff --git a/packages/js/components/src/woo-product-field-item/woo-product-field-item.tsx b/packages/js/components/src/woo-product-field-item/woo-product-field-item.tsx index 93bbf4c066d..2db6a28165d 100644 --- a/packages/js/components/src/woo-product-field-item/woo-product-field-item.tsx +++ b/packages/js/components/src/woo-product-field-item/woo-product-field-item.tsx @@ -9,6 +9,7 @@ import { createElement, Children } from '@wordpress/element'; * Internal dependencies */ import { createOrderedChildren, sortFillsByOrder } from '../utils'; +import { useSlotContext, SlotContextHelpersType } from '../slot-context'; type WooProductFieldItemProps = { id: string; @@ -23,36 +24,55 @@ type WooProductFieldSlotProps = { export const WooProductFieldItem: React.FC< WooProductFieldItemProps > & { Slot: React.FC< Slot.Props & WooProductFieldSlotProps >; -} = ( { children, order = 1, section } ) => ( - - { ( fillProps: Fill.Props ) => { - return createOrderedChildren< Fill.Props >( - children, - order, - fillProps - ); - } } - -); +} = ( { children, order = 20, section, id } ) => { + const { registerFill, getFillHelpers } = useSlotContext(); -WooProductFieldItem.Slot = ( { fillProps, section } ) => ( - - { ( fills ) => { - if ( ! sortFillsByOrder ) { - return null; - } + registerFill( id ); - return Children.map( - sortFillsByOrder( fills )?.props.children, - ( child ) => ( -
    - { child } -
    - ) - ); - } } -
    -); + return ( + + { ( fillProps: Fill.Props ) => { + return createOrderedChildren< + Fill.Props & SlotContextHelpersType, + { _id: string } + >( + children, + order, + { + ...fillProps, + ...getFillHelpers(), + }, + { _id: id } + ); + } } + + ); +}; + +WooProductFieldItem.Slot = ( { fillProps, section } ) => { + // eslint-disable-next-line react-hooks/rules-of-hooks + const { filterRegisteredFills } = useSlotContext(); + + return ( + + { ( fills ) => { + if ( ! sortFillsByOrder ) { + return null; + } + + return Children.map( + sortFillsByOrder( filterRegisteredFills( fills ) )?.props + .children, + ( child ) => ( +
    + { child } +
    + ) + ); + } } +
    + ); +}; diff --git a/packages/js/components/src/woo-product-section-item/woo-product-section-item.tsx b/packages/js/components/src/woo-product-section-item/woo-product-section-item.tsx index ead5114a62c..5a79e5fa075 100644 --- a/packages/js/components/src/woo-product-section-item/woo-product-section-item.tsx +++ b/packages/js/components/src/woo-product-section-item/woo-product-section-item.tsx @@ -23,7 +23,7 @@ type WooProductFieldSlotProps = { export const WooProductSectionItem: React.FC< WooProductSectionItemProps > & { Slot: React.FC< Slot.Props & WooProductFieldSlotProps >; -} = ( { children, order = 1, location } ) => ( +} = ( { children, order = 20, location } ) => ( { ( fillProps: Fill.Props ) => { return createOrderedChildren< Fill.Props >( diff --git a/packages/js/data/changelog/add-36073-product-form-data-store b/packages/js/data/changelog/add-36073-product-form-data-store new file mode 100644 index 00000000000..801045b961d --- /dev/null +++ b/packages/js/data/changelog/add-36073-product-form-data-store @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Adding Product Form data store. diff --git a/packages/js/data/changelog/add-36075_render_fields_sections_from_php_config b/packages/js/data/changelog/add-36075_render_fields_sections_from_php_config new file mode 100644 index 00000000000..9c3dfc710c8 --- /dev/null +++ b/packages/js/data/changelog/add-36075_render_fields_sections_from_php_config @@ -0,0 +1,4 @@ +Significance: patch +Type: tweak + +Tweak the product form types and exports. diff --git a/packages/js/data/changelog/add-36189 b/packages/js/data/changelog/add-36189 new file mode 100644 index 00000000000..581419992cd --- /dev/null +++ b/packages/js/data/changelog/add-36189 @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Add ability to check CRUD dispatch action status diff --git a/packages/js/data/src/crud/action-types.ts b/packages/js/data/src/crud/action-types.ts index fac68896739..903d63535c0 100644 --- a/packages/js/data/src/crud/action-types.ts +++ b/packages/js/data/src/crud/action-types.ts @@ -1,13 +1,16 @@ export enum TYPES { CREATE_ITEM_ERROR = 'CREATE_ITEM_ERROR', + CREATE_ITEM_REQUEST = 'CREATE_ITEM_REQUEST', CREATE_ITEM_SUCCESS = 'CREATE_ITEM_SUCCESS', DELETE_ITEM_ERROR = 'DELETE_ITEM_ERROR', + DELETE_ITEM_REQUEST = 'DELETE_ITEM_REQUEST', DELETE_ITEM_SUCCESS = 'DELETE_ITEM_SUCCESS', GET_ITEM_ERROR = 'GET_ITEM_ERROR', GET_ITEM_SUCCESS = 'GET_ITEM_SUCCESS', GET_ITEMS_ERROR = 'GET_ITEMS_ERROR', GET_ITEMS_SUCCESS = 'GET_ITEMS_SUCCESS', UPDATE_ITEM_ERROR = 'UPDATE_ITEM_ERROR', + UPDATE_ITEM_REQUEST = 'UPDATE_ITEM_REQUEST', UPDATE_ITEM_SUCCESS = 'UPDATE_ITEM_SUCCESS', GET_ITEMS_TOTAL_COUNT_SUCCESS = 'GET_ITEMS_TOTAL_COUNT_SUCCESS', GET_ITEMS_TOTAL_COUNT_ERROR = 'GET_ITEMS_TOTAL_COUNT_ERROR', diff --git a/packages/js/data/src/crud/actions.ts b/packages/js/data/src/crud/actions.ts index 2743c329505..f708955f6a6 100644 --- a/packages/js/data/src/crud/actions.ts +++ b/packages/js/data/src/crud/actions.ts @@ -25,20 +25,41 @@ export function createItemError( query: Partial< ItemQuery >, error: unknown ) { }; } -export function createItemSuccess( key: IdType, item: Item ) { +export function createItemRequest( query: Partial< ItemQuery > ) { + return { + type: TYPES.CREATE_ITEM_REQUEST as const, + query, + }; +} + +export function createItemSuccess( + key: IdType, + item: Item, + query: Partial< ItemQuery > +) { return { type: TYPES.CREATE_ITEM_SUCCESS as const, key, item, + query, }; } -export function deleteItemError( key: IdType, error: unknown ) { +export function deleteItemError( key: IdType, error: unknown, force: boolean ) { return { type: TYPES.DELETE_ITEM_ERROR as const, key, error, errorType: CRUD_ACTIONS.DELETE_ITEM, + force, + }; +} + +export function deleteItemRequest( key: IdType, force: boolean ) { + return { + type: TYPES.DELETE_ITEM_REQUEST as const, + key, + force, }; } @@ -116,20 +137,38 @@ export function getItemsTotalCountError( }; } -export function updateItemError( key: IdType, error: unknown ) { +export function updateItemError( + key: IdType, + error: unknown, + query: Partial< ItemQuery > +) { return { type: TYPES.UPDATE_ITEM_ERROR as const, key, error, errorType: CRUD_ACTIONS.UPDATE_ITEM, + query, }; } -export function updateItemSuccess( key: IdType, item: Item ) { +export function updateItemRequest( key: IdType, query: Partial< ItemQuery > ) { + return { + type: TYPES.UPDATE_ITEM_REQUEST as const, + key, + query, + }; +} + +export function updateItemSuccess( + key: IdType, + item: Item, + query: Partial< ItemQuery > +) { return { type: TYPES.UPDATE_ITEM_SUCCESS as const, key, item, + query, }; } @@ -138,6 +177,7 @@ export const createDispatchActions = ( { resourceName, }: ResolverOptions ) => { const createItem = function* ( query: Partial< ItemQuery > ) { + yield createItemRequest( query ); const urlParameters = getUrlParameters( namespace, query ); try { @@ -151,7 +191,7 @@ export const createDispatchActions = ( { } ); const { key } = parseId( item.id, urlParameters ); - yield createItemSuccess( key, item ); + yield createItemSuccess( key, item, query ); return item; } catch ( error ) { yield createItemError( query, error ); @@ -162,6 +202,7 @@ export const createDispatchActions = ( { const deleteItem = function* ( idQuery: IdQuery, force = true ) { const urlParameters = getUrlParameters( namespace, idQuery ); const { id, key } = parseId( idQuery, urlParameters ); + yield deleteItemRequest( key, force ); try { const item: Item = yield apiFetch( { @@ -176,7 +217,7 @@ export const createDispatchActions = ( { yield deleteItemSuccess( key, force, item ); return item; } catch ( error ) { - yield deleteItemError( key, error ); + yield deleteItemError( key, error, force ); throw error; } }; @@ -187,6 +228,7 @@ export const createDispatchActions = ( { ) { const urlParameters = getUrlParameters( namespace, idQuery ); const { id, key } = parseId( idQuery, urlParameters ); + yield updateItemRequest( key, query ); try { const item: Item = yield apiFetch( { @@ -199,10 +241,10 @@ export const createDispatchActions = ( { data: query, } ); - yield updateItemSuccess( key, item ); + yield updateItemSuccess( key, item, query ); return item; } catch ( error ) { - yield updateItemError( key, error ); + yield updateItemError( key, error, query ); throw error; } }; @@ -216,8 +258,10 @@ export const createDispatchActions = ( { export type Actions = ReturnType< | typeof createItemError + | typeof createItemRequest | typeof createItemSuccess | typeof deleteItemError + | typeof deleteItemRequest | typeof deleteItemSuccess | typeof getItemError | typeof getItemSuccess @@ -226,5 +270,6 @@ export type Actions = ReturnType< | typeof getItemsTotalCountSuccess | typeof getItemsTotalCountError | typeof updateItemError + | typeof updateItemRequest | typeof updateItemSuccess >; diff --git a/packages/js/data/src/crud/reducer.ts b/packages/js/data/src/crud/reducer.ts index 87b12520c98..baa2de63c1f 100644 --- a/packages/js/data/src/crud/reducer.ts +++ b/packages/js/data/src/crud/reducer.ts @@ -8,8 +8,8 @@ import { Reducer } from 'redux'; */ import { Actions } from './actions'; import CRUD_ACTIONS from './crud-actions'; -import { getKey } from './utils'; -import { getResourceName, getTotalCountResourceName } from '../utils'; +import { getKey, getRequestIdentifier } from './utils'; +import { getTotalCountResourceName } from '../utils'; import { IdType, Item, ItemQuery } from './types'; import { TYPES } from './action-types'; @@ -24,6 +24,7 @@ export type ResourceState = { data: Data; itemsCount: Record< string, number >; errors: Record< string, unknown >; + requesting: Record< string, boolean >; }; export const createReducer = () => { @@ -33,19 +34,37 @@ export const createReducer = () => { data: {}, itemsCount: {}, errors: {}, + requesting: {}, }, payload ) => { + const itemData = state.data || {}; + if ( payload && 'type' in payload ) { switch ( payload.type ) { case TYPES.CREATE_ITEM_ERROR: + const createItemErrorRequestId = getRequestIdentifier( + payload.errorType, + payload.query || {} + ); + return { + ...state, + errors: { + ...state.errors, + [ createItemErrorRequestId ]: payload.error, + }, + requesting: { + ...state.requesting, + [ createItemErrorRequestId ]: false, + }, + }; case TYPES.GET_ITEMS_TOTAL_COUNT_ERROR: case TYPES.GET_ITEMS_ERROR: return { ...state, errors: { ...state.errors, - [ getResourceName( + [ getRequestIdentifier( payload.errorType, ( payload.query || {} ) as ItemQuery ) ]: payload.error, @@ -64,9 +83,27 @@ export const createReducer = () => { }; case TYPES.CREATE_ITEM_SUCCESS: + const createItemSuccessRequestId = getRequestIdentifier( + CRUD_ACTIONS.CREATE_ITEM, + payload.key, + payload.query + ); + return { + ...state, + data: { + ...itemData, + [ payload.key ]: { + ...( itemData[ payload.key ] || {} ), + ...payload.item, + }, + }, + requesting: { + ...state.requesting, + [ createItemSuccessRequestId ]: false, + }, + }; + case TYPES.GET_ITEM_SUCCESS: - case TYPES.UPDATE_ITEM_SUCCESS: - const itemData = state.data || {}; return { ...state, data: { @@ -78,7 +115,33 @@ export const createReducer = () => { }, }; + case TYPES.UPDATE_ITEM_SUCCESS: + const updateItemSuccessRequestId = getRequestIdentifier( + CRUD_ACTIONS.UPDATE_ITEM, + payload.key, + payload.query + ); + return { + ...state, + data: { + ...itemData, + [ payload.key ]: { + ...( itemData[ payload.key ] || {} ), + ...payload.item, + }, + }, + requesting: { + ...state.requesting, + [ updateItemSuccessRequestId ]: false, + }, + }; + case TYPES.DELETE_ITEM_SUCCESS: + const deleteItemSuccessRequestId = getRequestIdentifier( + CRUD_ACTIONS.DELETE_ITEM, + payload.key, + payload.force + ); const itemKeys = Object.keys( state.data ); const nextData = itemKeys.reduce< Data >( ( items: Data, key: string ) => { @@ -98,18 +161,57 @@ export const createReducer = () => { return { ...state, data: nextData, + requesting: { + ...state.requesting, + [ deleteItemSuccessRequestId ]: false, + }, }; case TYPES.DELETE_ITEM_ERROR: - case TYPES.GET_ITEM_ERROR: - case TYPES.UPDATE_ITEM_ERROR: + const deleteItemErrorRequestId = getRequestIdentifier( + payload.errorType, + payload.key, + payload.force + ); return { ...state, errors: { ...state.errors, - [ getResourceName( payload.errorType, { - key: payload.key, - } ) ]: payload.error, + [ deleteItemErrorRequestId ]: payload.error, + }, + requesting: { + ...state.requesting, + [ deleteItemErrorRequestId ]: false, + }, + }; + + case TYPES.GET_ITEM_ERROR: + return { + ...state, + errors: { + ...state.errors, + [ getRequestIdentifier( + payload.errorType, + payload.key + ) ]: payload.error, + }, + }; + + case TYPES.UPDATE_ITEM_ERROR: + const upateItemErrorRequestId = getRequestIdentifier( + payload.errorType, + payload.key, + payload.query + ); + return { + ...state, + errors: { + ...state.errors, + [ upateItemErrorRequestId ]: payload.error, + }, + requesting: { + ...state.requesting, + [ upateItemErrorRequestId ]: false, }, }; @@ -128,7 +230,7 @@ export const createReducer = () => { return result; }, {} ); - const itemQuery = getResourceName( + const itemQuery = getRequestIdentifier( CRUD_ACTIONS.GET_ITEMS, ( payload.query || {} ) as ItemQuery ); @@ -145,6 +247,44 @@ export const createReducer = () => { }, }; + case TYPES.CREATE_ITEM_REQUEST: + return { + ...state, + requesting: { + ...state.requesting, + [ getRequestIdentifier( + CRUD_ACTIONS.CREATE_ITEM, + payload.query + ) ]: true, + }, + }; + + case TYPES.DELETE_ITEM_REQUEST: + return { + ...state, + requesting: { + ...state.requesting, + [ getRequestIdentifier( + CRUD_ACTIONS.DELETE_ITEM, + payload.key, + payload.force + ) ]: true, + }, + }; + + case TYPES.UPDATE_ITEM_REQUEST: + return { + ...state, + requesting: { + ...state.requesting, + [ getRequestIdentifier( + CRUD_ACTIONS.UPDATE_ITEM, + payload.key, + payload.query + ) ]: true, + }, + }; + default: return state; } diff --git a/packages/js/data/src/crud/selectors.ts b/packages/js/data/src/crud/selectors.ts index 05d019a7b04..f95d0e31bc3 100644 --- a/packages/js/data/src/crud/selectors.ts +++ b/packages/js/data/src/crud/selectors.ts @@ -6,8 +6,15 @@ import createSelector from 'rememo'; /** * Internal dependencies */ -import { applyNamespace, getUrlParameters, parseId } from './utils'; -import { getResourceName, getTotalCountResourceName } from '../utils'; +import { + applyNamespace, + getGenericActionName, + getRequestIdentifier, + getUrlParameters, + maybeReplaceIdQuery, + parseId, +} from './utils'; +import { getTotalCountResourceName } from '../utils'; import { IdQuery, IdType, Item, ItemQuery } from './types'; import { ResourceState } from './reducer'; import CRUD_ACTIONS from './crud-actions'; @@ -22,7 +29,7 @@ export const getItemCreateError = ( state: ResourceState, query: ItemQuery ) => { - const itemQuery = getResourceName( CRUD_ACTIONS.CREATE_ITEM, query ); + const itemQuery = getRequestIdentifier( CRUD_ACTIONS.CREATE_ITEM, query ); return state.errors[ itemQuery ]; }; @@ -33,7 +40,7 @@ export const getItemDeleteError = ( ) => { const urlParameters = getUrlParameters( namespace, idQuery ); const { key } = parseId( idQuery, urlParameters ); - const itemQuery = getResourceName( CRUD_ACTIONS.DELETE_ITEM, { key } ); + const itemQuery = getRequestIdentifier( CRUD_ACTIONS.DELETE_ITEM, key ); return state.errors[ itemQuery ]; }; @@ -54,13 +61,13 @@ export const getItemError = ( ) => { const urlParameters = getUrlParameters( namespace, idQuery ); const { key } = parseId( idQuery, urlParameters ); - const itemQuery = getResourceName( CRUD_ACTIONS.GET_ITEM, { key } ); + const itemQuery = getRequestIdentifier( CRUD_ACTIONS.GET_ITEM, key ); return state.errors[ itemQuery ]; }; export const getItems = createSelector( ( state: ResourceState, query?: ItemQuery ) => { - const itemQuery = getResourceName( + const itemQuery = getRequestIdentifier( CRUD_ACTIONS.GET_ITEMS, query || {} ); @@ -96,7 +103,7 @@ export const getItems = createSelector( return data; }, ( state, query ) => { - const itemQuery = getResourceName( + const itemQuery = getRequestIdentifier( CRUD_ACTIONS.GET_ITEMS, query || {} ); @@ -129,7 +136,10 @@ export const getItemsTotalCount = ( }; export const getItemsError = ( state: ResourceState, query?: ItemQuery ) => { - const itemQuery = getResourceName( CRUD_ACTIONS.GET_ITEMS, query || {} ); + const itemQuery = getRequestIdentifier( + CRUD_ACTIONS.GET_ITEMS, + query || {} + ); return state.errors[ itemQuery ]; }; @@ -138,12 +148,8 @@ export const getItemUpdateError = ( idQuery: IdQuery, urlParameters: IdType[] ) => { - const params = parseId( idQuery, urlParameters ); - const { key } = params; - const itemQuery = getResourceName( CRUD_ACTIONS.UPDATE_ITEM, { - key, - params, - } ); + const { key } = parseId( idQuery, urlParameters ); + const itemQuery = getRequestIdentifier( CRUD_ACTIONS.UPDATE_ITEM, key ); return state.errors[ itemQuery ]; }; @@ -154,6 +160,32 @@ export const createSelectors = ( { pluralResourceName, namespace, }: SelectorOptions ) => { + const hasFinishedRequest = ( + state: ResourceState, + action: string, + args = [] + ) => { + const sanitizedArgs = maybeReplaceIdQuery( args, namespace ); + const actionName = getGenericActionName( action, resourceName ); + const requestId = getRequestIdentifier( actionName, ...sanitizedArgs ); + if ( action ) + return ( + state.requesting.hasOwnProperty( requestId ) && + ! state.requesting[ requestId ] + ); + }; + + const isRequesting = ( + state: ResourceState, + action: string, + args = [] + ) => { + const sanitizedArgs = maybeReplaceIdQuery( args, namespace ); + const actionName = getGenericActionName( action, resourceName ); + const requestId = getRequestIdentifier( actionName, ...sanitizedArgs ); + return state.requesting[ requestId ]; + }; + return { [ `get${ resourceName }` ]: applyNamespace( getItem, namespace ), [ `get${ resourceName }Error` ]: applyNamespace( @@ -184,5 +216,7 @@ export const createSelectors = ( { getItemUpdateError, namespace ), + hasFinishedRequest, + isRequesting, }; }; diff --git a/packages/js/data/src/crud/test/reducer.ts b/packages/js/data/src/crud/test/reducer.ts index d0d3391e60a..0b2ec44c7c7 100644 --- a/packages/js/data/src/crud/test/reducer.ts +++ b/packages/js/data/src/crud/test/reducer.ts @@ -5,6 +5,7 @@ import { Actions } from '../actions'; import { createReducer, ResourceState } from '../reducer'; import { CRUD_ACTIONS } from '../crud-actions'; import { getResourceName } from '../../utils'; +import { getRequestIdentifier } from '..//utils'; import { Item, ItemQuery } from '../types'; import TYPES from '../action-types'; @@ -13,6 +14,7 @@ const defaultState: ResourceState = { errors: {}, itemsCount: {}, data: {}, + requesting: {}, }; const reducer = createReducer(); @@ -38,6 +40,7 @@ describe( 'crud reducer', () => { 1: { id: 1, name: 'Donkey', status: 'draft' }, 2: { id: 2, name: 'Sauce', status: 'publish' }, }, + requesting: {}, }; const update: Item = { id: 2, @@ -72,7 +75,10 @@ describe( 'crud reducer', () => { urlParameters: [], } ); - const resourceName = getResourceName( CRUD_ACTIONS.GET_ITEMS, query ); + const resourceName = getRequestIdentifier( + CRUD_ACTIONS.GET_ITEMS, + query + ); expect( state.items[ resourceName ].data ).toHaveLength( 2 ); expect( state.items[ resourceName ].data.includes( 1 ) ).toBeTruthy(); @@ -108,7 +114,10 @@ describe( 'crud reducer', () => { urlParameters: [ 5 ], } ); - const resourceName = getResourceName( CRUD_ACTIONS.GET_ITEMS, query ); + const resourceName = getRequestIdentifier( + CRUD_ACTIONS.GET_ITEMS, + query + ); expect( state.items[ resourceName ].data ).toHaveLength( 2 ); expect( state.data[ '5/1' ] ).toEqual( items[ 0 ] ); @@ -141,7 +150,10 @@ describe( 'crud reducer', () => { urlParameters: [], } ); - const resourceName = getResourceName( CRUD_ACTIONS.GET_ITEMS, query ); + const resourceName = getRequestIdentifier( + CRUD_ACTIONS.GET_ITEMS, + query + ); expect( state.items[ resourceName ].data ).toHaveLength( 2 ); expect( state.items[ resourceName ].data.includes( 1 ) ).toBeTruthy(); @@ -157,7 +169,10 @@ describe( 'crud reducer', () => { it( 'should handle GET_ITEMS_ERROR', () => { const query: Partial< ItemQuery > = { status: 'draft' }; - const resourceName = getResourceName( CRUD_ACTIONS.GET_ITEMS, query ); + const resourceName = getRequestIdentifier( + CRUD_ACTIONS.GET_ITEMS, + query + ); const error = 'Baaam!'; const state = reducer( defaultState, { type: TYPES.GET_ITEMS_ERROR, @@ -171,7 +186,7 @@ describe( 'crud reducer', () => { it( 'should handle GET_ITEMS_TOTAL_COUNT_ERROR', () => { const query: Partial< ItemQuery > = { status: 'draft' }; - const resourceName = getResourceName( + const resourceName = getRequestIdentifier( CRUD_ACTIONS.GET_ITEMS_TOTAL_COUNT, query ); @@ -188,7 +203,7 @@ describe( 'crud reducer', () => { it( 'should handle GET_ITEM_ERROR', () => { const key = 3; - const resourceName = getResourceName( CRUD_ACTIONS.GET_ITEM, { key } ); + const resourceName = getRequestIdentifier( CRUD_ACTIONS.GET_ITEM, key ); const error = 'Baaam!'; const state = reducer( defaultState, { type: TYPES.GET_ITEM_ERROR, @@ -202,7 +217,7 @@ describe( 'crud reducer', () => { it( 'should handle GET_ITEM_ERROR', () => { const key = 3; - const resourceName = getResourceName( CRUD_ACTIONS.GET_ITEM, { key } ); + const resourceName = getRequestIdentifier( CRUD_ACTIONS.GET_ITEM, key ); const error = 'Baaam!'; const state = reducer( defaultState, { type: TYPES.GET_ITEM_ERROR, @@ -216,7 +231,10 @@ describe( 'crud reducer', () => { it( 'should handle CREATE_ITEM_ERROR', () => { const query = { name: 'Invalid product' }; - const resourceName = getResourceName( CRUD_ACTIONS.CREATE_ITEM, query ); + const resourceName = getRequestIdentifier( + CRUD_ACTIONS.CREATE_ITEM, + query + ); const error = 'Baaam!'; const state = reducer( defaultState, { type: TYPES.CREATE_ITEM_ERROR, @@ -226,22 +244,28 @@ describe( 'crud reducer', () => { } ); expect( state.errors[ resourceName ] ).toBe( error ); + expect( state.requesting[ resourceName ] ).toBe( false ); } ); it( 'should handle UPDATE_ITEM_ERROR', () => { const key = 2; - const resourceName = getResourceName( CRUD_ACTIONS.UPDATE_ITEM, { + const query = { property: 'value' }; + const resourceName = getRequestIdentifier( + CRUD_ACTIONS.UPDATE_ITEM, key, - } ); + query + ); const error = 'Baaam!'; const state = reducer( defaultState, { type: TYPES.UPDATE_ITEM_ERROR, key, error, errorType: CRUD_ACTIONS.UPDATE_ITEM, + query, } ); expect( state.errors[ resourceName ] ).toBe( error ); + expect( state.requesting[ resourceName ] ).toBe( false ); } ); it( 'should handle UPDATE_ITEM_SUCCESS', () => { @@ -258,17 +282,25 @@ describe( 'crud reducer', () => { 1: { id: 1, name: 'Donkey', status: 'draft' }, 2: { id: 2, name: 'Sauce', status: 'publish' }, }, + requesting: {}, }; const item: Item = { id: 2, name: 'Holy smokes!', status: 'draft', }; + const query = { property: 'value' }; + const requestId = getRequestIdentifier( + CRUD_ACTIONS.UPDATE_ITEM, + item.id, + query + ); const state = reducer( initialState, { type: TYPES.UPDATE_ITEM_SUCCESS, key: item.id, item, + query, } ); expect( state.items ).toEqual( initialState.items ); @@ -278,6 +310,7 @@ describe( 'crud reducer', () => { expect( state.data[ 2 ].id ).toEqual( initialState.data[ 2 ].id ); expect( state.data[ 2 ].title ).toEqual( initialState.data[ 2 ].title ); expect( state.data[ 2 ].name ).toEqual( item.name ); + expect( state.requesting[ requestId ] ).toEqual( false ); } ); it( 'should handle CREATE_ITEM_SUCCESS', () => { @@ -286,15 +319,26 @@ describe( 'crud reducer', () => { name: 'Off the hook!', status: 'draft', }; + const query = { + name: 'Off the hook!', + status: 'draft', + }; + const resourceName = getRequestIdentifier( + CRUD_ACTIONS.CREATE_ITEM, + item.id, + query + ); const state = reducer( defaultState, { type: TYPES.CREATE_ITEM_SUCCESS, key: item.id, item, + query, } ); expect( state.data[ 2 ].name ).toEqual( item.name ); expect( state.data[ 2 ].status ).toEqual( item.status ); + expect( state.requesting[ resourceName ] ).toEqual( false ); } ); it( 'should handle DELETE_ITEM_SUCCESS', () => { @@ -311,6 +355,7 @@ describe( 'crud reducer', () => { 1: { id: 1, name: 'Donkey', status: 'draft' }, 2: { id: 2, name: 'Sauce', status: 'publish' }, }, + requesting: {}, }; const item1Updated: Item = { id: 1, @@ -333,25 +378,35 @@ describe( 'crud reducer', () => { item: item2Updated, force: false, } ); + const resourceName = getRequestIdentifier( + CRUD_ACTIONS.DELETE_ITEM, + item1Updated.id, + true + ); expect( state.errors ).toEqual( initialState.errors ); expect( state.data[ 1 ] ).toEqual( undefined ); expect( state.data[ 2 ].status ).toEqual( 'trash' ); + expect( state.requesting[ resourceName ] ).toBe( false ); } ); it( 'should handle DELETE_ITEM_ERROR', () => { const key = 2; - const resourceName = getResourceName( CRUD_ACTIONS.DELETE_ITEM, { + const resourceName = getRequestIdentifier( + CRUD_ACTIONS.DELETE_ITEM, key, - } ); + false + ); const error = 'Baaam!'; const state = reducer( defaultState, { type: TYPES.DELETE_ITEM_ERROR, key, error, errorType: CRUD_ACTIONS.DELETE_ITEM, + force: false, } ); expect( state.errors[ resourceName ] ).toBe( error ); + expect( state.requesting[ resourceName ] ).toBe( false ); } ); } ); diff --git a/packages/js/data/src/crud/test/selectors.ts b/packages/js/data/src/crud/test/selectors.ts index d6f56cc67e9..d6a3ea0b7b4 100644 --- a/packages/js/data/src/crud/test/selectors.ts +++ b/packages/js/data/src/crud/test/selectors.ts @@ -11,7 +11,7 @@ const selectors = createSelectors( { describe( 'crud selectors', () => { it( 'should return methods for the default selectors', () => { - expect( Object.keys( selectors ).length ).toEqual( 8 ); + expect( Object.keys( selectors ).length ).toEqual( 10 ); expect( selectors ).toHaveProperty( 'getProduct' ); expect( selectors ).toHaveProperty( 'getProducts' ); expect( selectors ).toHaveProperty( 'getProductsTotalCount' ); @@ -20,5 +20,7 @@ describe( 'crud selectors', () => { expect( selectors ).toHaveProperty( 'getProductCreateError' ); expect( selectors ).toHaveProperty( 'getProductDeleteError' ); expect( selectors ).toHaveProperty( 'getProductUpdateError' ); + expect( selectors ).toHaveProperty( 'hasFinishedRequest' ); + expect( selectors ).toHaveProperty( 'isRequesting' ); } ); } ); diff --git a/packages/js/data/src/crud/test/utils.ts b/packages/js/data/src/crud/test/utils.ts index 1fb371c9369..d034c6fa69b 100644 --- a/packages/js/data/src/crud/test/utils.ts +++ b/packages/js/data/src/crud/test/utils.ts @@ -1,13 +1,18 @@ /** * Internal dependencies */ +import CRUD_ACTIONS from '../crud-actions'; import { applyNamespace, cleanQuery, + getGenericActionName, getKey, getNamespaceKeys, + getRequestIdentifier, getRestPath, getUrlParameters, + maybeReplaceIdQuery, + isValidIdQuery, parseId, } from '../utils'; @@ -113,4 +118,104 @@ describe( 'utils', () => { expect( params.other_attribute ).toBe( 'a' ); expect( params.my_attribute ).toBeUndefined(); } ); + + it( 'should get the request identifier with no arguments', () => { + const key = getRequestIdentifier( 'CREATE_ITEM' ); + expect( key ).toBe( 'CREATE_ITEM/[]' ); + } ); + + it( 'should get the request identifier with a single argument', () => { + const key = getRequestIdentifier( 'CREATE_ITEM', 'string_arg' ); + expect( key ).toBe( 'CREATE_ITEM/["string_arg"]' ); + } ); + + it( 'should get the request identifier with multiple arguments', () => { + const key = getRequestIdentifier( 'CREATE_ITEM', 'string_arg', { + object_property: 'object_value', + } ); + expect( key ).toBe( + 'CREATE_ITEM/["string_arg","{"object_property":"object_value"}"]' + ); + } ); + + it( 'should sort object properties in the request identifier', () => { + const key = getRequestIdentifier( 'CREATE_ITEM', 'string_arg', { + b: '2', + a: '1', + } ); + expect( key ).toBe( 'CREATE_ITEM/["string_arg","{"a":"1","b":"2"}"]' ); + } ); + + it( 'should directly return the action when the action does not match the resource name', () => { + const genercActionName = getGenericActionName( + 'createNonThing', + 'Thing' + ); + expect( genercActionName ).toBe( 'createNonThing' ); + } ); + + it( 'should get the generic create action name based on resource name', () => { + const genercActionName = getGenericActionName( 'createThing', 'Thing' ); + expect( genercActionName ).toBe( CRUD_ACTIONS.CREATE_ITEM ); + } ); + + it( 'should get the generic delete action name based on resource name', () => { + const genercActionName = getGenericActionName( 'deleteThing', 'Thing' ); + expect( genercActionName ).toBe( CRUD_ACTIONS.DELETE_ITEM ); + } ); + + it( 'should get the generic update action name based on resource name', () => { + const genercActionName = getGenericActionName( 'updateThing', 'Thing' ); + expect( genercActionName ).toBe( CRUD_ACTIONS.UPDATE_ITEM ); + } ); + + it( 'should return false when a valid ID query is not given', () => { + expect( isValidIdQuery( { some: 'data' }, '/my/namespace' ) ).toBe( + false + ); + } ); + + it( 'should return true when a valid ID is passed in an object', () => { + expect( isValidIdQuery( { id: 22 }, '/my/namespace' ) ).toBe( true ); + } ); + + it( 'should return true when a valid ID is passed directly', () => { + expect( isValidIdQuery( 22, '/my/namespace' ) ).toBe( true ); + } ); + + it( 'should return false when additional non-ID properties are provided', () => { + expect( isValidIdQuery( { id: 22, other: 88 }, '/my/namespace' ) ).toBe( + false + ); + } ); + + it( 'should return true when namespace ID properties are provided', () => { + expect( + isValidIdQuery( + { id: 22, parent_id: 88 }, + '/my/{parent_id}/namespace/' + ) + ).toBe( true ); + } ); + + it( 'should replace the first argument when a valid ID query exists', () => { + const args = [ { id: 22, parent_id: 88 }, 'second' ]; + const sanitizedArgs = maybeReplaceIdQuery( + args, + '/my/{parent_id}/namespace/' + ); + expect( sanitizedArgs ).toEqual( [ '88/22', 'second' ] ); + } ); + + it( 'should remain unchanged when the first argument is a string or number', () => { + const args = [ 'first', 'second' ]; + const sanitizedArgs = maybeReplaceIdQuery( args, '/my/namespace/' ); + expect( sanitizedArgs ).toEqual( args ); + } ); + + it( 'should remain unchanged when the first argument is not a valid ID query', () => { + const args = [ { id: 22, parent_id: 88 }, 'second' ]; + const sanitizedArgs = maybeReplaceIdQuery( args, '/my/namespace/' ); + expect( sanitizedArgs ).toEqual( args ); + } ); } ); diff --git a/packages/js/data/src/crud/utils.ts b/packages/js/data/src/crud/utils.ts index 09147c8b027..97e8d1e57d7 100644 --- a/packages/js/data/src/crud/utils.ts +++ b/packages/js/data/src/crud/utils.ts @@ -6,6 +6,7 @@ import { addQueryArgs } from '@wordpress/url'; /** * Internal dependencies */ +import CRUD_ACTIONS from './crud-actions'; import { IdQuery, IdType, ItemQuery } from './types'; /** @@ -148,6 +149,52 @@ export const getUrlParameters = ( return params; }; +/** + * Check to see if an argument is a valid type of ID query. + * + * @param arg Unknow argument to check. + * @param namespace The namespace string + * @return boolean + */ +export const isValidIdQuery = ( arg: unknown, namespace: string ) => { + if ( typeof arg === 'string' || typeof arg === 'number' ) { + return true; + } + + const validKeys = [ 'id', ...getNamespaceKeys( namespace ) ]; + + if ( + arg && + typeof arg === 'object' && + arg.hasOwnProperty( 'id' ) && + JSON.stringify( validKeys.sort() ) === + JSON.stringify( Object.keys( arg ).sort() ) + ) { + return true; + } + + return false; +}; + +/** + * Replace the initial argument with a key if it's a valid ID query. + * + * @param args Args to check. + * @param namespace Namespace. + * @return Sanitized arguments. + */ +export const maybeReplaceIdQuery = ( args: unknown[], namespace: string ) => { + const [ firstArgument, ...rest ] = args; + if ( ! firstArgument || ! isValidIdQuery( firstArgument, namespace ) ) { + return args; + } + + const urlParameters = getUrlParameters( namespace, firstArgument ); + const { key } = parseId( firstArgument as IdQuery, urlParameters ); + + return [ key, ...rest ]; +}; + /** * Clean a query of all namespaced params. * @@ -168,3 +215,46 @@ export const cleanQuery = ( return cleaned; }; + +/** + * Get the identifier for a request provided its arguments. + * + * @param name Name of action or selector. + * @param args Arguments for the request. + * @return Key to identify the request. + */ +export const getRequestIdentifier = ( name: string, ...args: unknown[] ) => { + const suffix = JSON.stringify( + args.map( ( arg ) => { + if ( typeof arg === 'object' && arg !== null ) { + return JSON.stringify( arg, Object.keys( arg ).sort() ); + } + return arg; + } ) + ).replace( /\\"/g, '"' ); + + return name + '/' + suffix; +}; + +/** + * Get a generic action name from a resource action name if one exists. + * + * @param action Action name to check. + * @param resourceName Resurce name. + * @return Generic action name if one exists, otherwise the passed action name. + */ +export const getGenericActionName = ( + action: string, + resourceName: string +) => { + switch ( action ) { + case `create${ resourceName }`: + return CRUD_ACTIONS.CREATE_ITEM; + case `delete${ resourceName }`: + return CRUD_ACTIONS.DELETE_ITEM; + case `update${ resourceName }`: + return CRUD_ACTIONS.UPDATE_ITEM; + } + + return action; +}; diff --git a/packages/js/data/src/index.ts b/packages/js/data/src/index.ts index 51c0d88a515..8fd66a820d8 100644 --- a/packages/js/data/src/index.ts +++ b/packages/js/data/src/index.ts @@ -25,6 +25,7 @@ export { EXPERIMENTAL_PRODUCT_TAGS_STORE_NAME } from './product-tags'; export { EXPERIMENTAL_PRODUCT_CATEGORIES_STORE_NAME } from './product-categories'; export { EXPERIMENTAL_PRODUCT_ATTRIBUTE_TERMS_STORE_NAME } from './product-attribute-terms'; export { EXPERIMENTAL_PRODUCT_VARIATIONS_STORE_NAME } from './product-variations'; +export { EXPERIMENTAL_PRODUCT_FORM_STORE_NAME } from './product-form'; export { EXPERIMENTAL_TAX_CLASSES_STORE_NAME } from './tax-classes'; export { PaymentGateway } from './payment-gateways/types'; @@ -75,6 +76,11 @@ export { // Export types export * from './types'; export * from './countries/types'; +export { + ProductForm, + ProductFormField, + ProductFormSection, +} from './product-form/types'; export * from './onboarding/types'; export * from './plugins/types'; export * from './products/types'; @@ -122,6 +128,7 @@ import type { EXPERIMENTAL_PRODUCT_SHIPPING_CLASSES_STORE_NAME } from './product import type { EXPERIMENTAL_SHIPPING_ZONES_STORE_NAME } from './shipping-zones'; import type { EXPERIMENTAL_PRODUCT_TAGS_STORE_NAME } from './product-tags'; import type { EXPERIMENTAL_PRODUCT_CATEGORIES_STORE_NAME } from './product-categories'; +import type { EXPERIMENTAL_PRODUCT_FORM_STORE_NAME } from './product-form'; import type { EXPERIMENTAL_PRODUCT_ATTRIBUTE_TERMS_STORE_NAME } from './product-attribute-terms'; import type { EXPERIMENTAL_PRODUCT_VARIATIONS_STORE_NAME } from './product-variations'; import type { EXPERIMENTAL_TAX_CLASSES_STORE_NAME } from './tax-classes'; @@ -148,7 +155,8 @@ export type WCDataStoreName = | typeof EXPERIMENTAL_PRODUCT_TAGS_STORE_NAME | typeof EXPERIMENTAL_PRODUCT_CATEGORIES_STORE_NAME | typeof EXPERIMENTAL_PRODUCT_VARIATIONS_STORE_NAME - | typeof EXPERIMENTAL_TAX_CLASSES_STORE_NAME; + | typeof EXPERIMENTAL_TAX_CLASSES_STORE_NAME + | typeof EXPERIMENTAL_PRODUCT_FORM_STORE_NAME; /** * Internal dependencies @@ -168,6 +176,7 @@ import { ProductCategorySelectors } from './product-categories/types'; import { ProductAttributeTermsSelectors } from './product-attribute-terms/types'; import { ProductVariationSelectors } from './product-variations/types'; import { TaxClassSelectors } from './tax-classes/types'; +import { ProductFormSelectors } from './product-form/selectors'; // As we add types to all the package selectors we can fill out these unknown types with real ones. See one // of the already typed selectors for an example of how you can do this. @@ -215,6 +224,8 @@ export type WCSelectorType< T > = T extends typeof REVIEWS_STORE_NAME ? ShippingZonesSelectors : T extends typeof EXPERIMENTAL_TAX_CLASSES_STORE_NAME ? TaxClassSelectors + : T extends typeof EXPERIMENTAL_PRODUCT_FORM_STORE_NAME + ? ProductFormSelectors : never; export interface WCDataSelector { diff --git a/packages/js/data/src/product-form/action-types.ts b/packages/js/data/src/product-form/action-types.ts new file mode 100644 index 00000000000..823b1e267f5 --- /dev/null +++ b/packages/js/data/src/product-form/action-types.ts @@ -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; diff --git a/packages/js/data/src/product-form/actions.ts b/packages/js/data/src/product-form/actions.ts new file mode 100644 index 00000000000..bba4057dc1c --- /dev/null +++ b/packages/js/data/src/product-form/actions.ts @@ -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 +>; diff --git a/packages/js/data/src/product-form/constants.ts b/packages/js/data/src/product-form/constants.ts new file mode 100644 index 00000000000..d295446042e --- /dev/null +++ b/packages/js/data/src/product-form/constants.ts @@ -0,0 +1 @@ +export const STORE_NAME = 'experimental/wc/admin/product-form'; diff --git a/packages/js/data/src/product-form/index.ts b/packages/js/data/src/product-form/index.ts new file mode 100644 index 00000000000..0a6dfbef68d --- /dev/null +++ b/packages/js/data/src/product-form/index.ts @@ -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; +} diff --git a/packages/js/data/src/product-form/reducer.ts b/packages/js/data/src/product-form/reducer.ts new file mode 100644 index 00000000000..6757619bd1a --- /dev/null +++ b/packages/js/data/src/product-form/reducer.ts @@ -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; diff --git a/packages/js/data/src/product-form/resolvers.ts b/packages/js/data/src/product-form/resolvers.ts new file mode 100644 index 00000000000..8f9073cd192 --- /dev/null +++ b/packages/js/data/src/product-form/resolvers.ts @@ -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 ); + } +} diff --git a/packages/js/data/src/product-form/selectors.ts b/packages/js/data/src/product-form/selectors.ts new file mode 100644 index 00000000000..bb1f0501bb9 --- /dev/null +++ b/packages/js/data/src/product-form/selectors.ts @@ -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; diff --git a/packages/js/data/src/product-form/types.ts b/packages/js/data/src/product-form/types.ts new file mode 100644 index 00000000000..e9b86eadaea --- /dev/null +++ b/packages/js/data/src/product-form/types.ts @@ -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; + }; +}; diff --git a/packages/js/internal-style-build/abstracts/_variables.scss b/packages/js/internal-style-build/abstracts/_variables.scss index 73155c89baf..812b09a7464 100644 --- a/packages/js/internal-style-build/abstracts/_variables.scss +++ b/packages/js/internal-style-build/abstracts/_variables.scss @@ -45,6 +45,7 @@ $alert-green: $valid-green; $adminbar-height: 32px; $adminbar-height-mobile: 46px; $admin-menu-width: 160px; +$admin-menu-width-collapsed: 36px; // wp-admin colors $wp-admin-background: #f1f1f1; diff --git a/plugins/woocommerce-admin/client/homescreen/header-banner-slot/header-banner-slot.tsx b/plugins/woocommerce-admin/client/homescreen/header-banner-slot/header-banner-slot.tsx new file mode 100644 index 00000000000..1504f14878d --- /dev/null +++ b/plugins/woocommerce-admin/client/homescreen/header-banner-slot/header-banner-slot.tsx @@ -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 ( +
    + +
    + ); +}; diff --git a/plugins/woocommerce-admin/client/homescreen/header-banner-slot/index.ts b/plugins/woocommerce-admin/client/homescreen/header-banner-slot/index.ts new file mode 100644 index 00000000000..652d2ba1b61 --- /dev/null +++ b/plugins/woocommerce-admin/client/homescreen/header-banner-slot/index.ts @@ -0,0 +1,2 @@ +export * from './header-banner-slot'; +export * from './utils'; diff --git a/plugins/woocommerce-admin/client/homescreen/header-banner-slot/utils.tsx b/plugins/woocommerce-admin/client/homescreen/header-banner-slot/utils.tsx new file mode 100644 index 00000000000..1bae78f7456 --- /dev/null +++ b/plugins/woocommerce-admin/client/homescreen/header-banner-slot/utils.tsx @@ -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 = () => ( + * My header item + * ); + * + * 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 ( + + { ( fillProps: Fill.Props ) => { + return createOrderedChildren( children, order, fillProps ); + } } + + ); +}; + +WooHomescreenHeaderBannerItem.Slot = ( { + fillProps, +}: { + fillProps?: Slot.Props; +} ) => ( + + { sortFillsByOrder } + +); diff --git a/plugins/woocommerce-admin/client/homescreen/layout.js b/plugins/woocommerce-admin/client/homescreen/layout.js index c33b10bd92d..6fc310ebdcd 100644 --- a/plugins/woocommerce-admin/client/homescreen/layout.js +++ b/plugins/woocommerce-admin/client/homescreen/layout.js @@ -43,6 +43,7 @@ import './style.scss'; import '../dashboard/style.scss'; import { getAdminSetting } from '~/utils/admin-settings'; import { ProgressTitle } from '../task-lists'; +import { WooHomescreenHeaderBanner } from './header-banner-slot'; const Tasks = lazy( () => import( /* webpackChunkName: "tasks" */ '../tasks' ).then( ( module ) => ( { @@ -126,7 +127,9 @@ export const Layout = ( { return ( }> { activeSetupTaskList && isDashboardShown && ( - + <> + + ) } @@ -135,6 +138,13 @@ export const Layout = ( { return ( <> + { isDashboardShown && ( + + ) }
    { + const { isLoading } = useSelect( ( select: WCDataSelector ) => { + const { hasFinishedResolution: hasProductFormFinishedResolution } = + select( EXPERIMENTAL_PRODUCT_FORM_STORE_NAME ); + return { + isLoading: ! hasProductFormFinishedResolution( 'getProductForm' ), + }; + } ); useEffect( () => { recordEvent( 'view_new_product_management_experience' ); }, [] ); return (
    - + { isLoading ? ( +
    + +
    + ) : ( + <> + + + + ) }
    ); }; diff --git a/plugins/woocommerce-admin/client/products/edit-product-page.tsx b/plugins/woocommerce-admin/client/products/edit-product-page.tsx index 6606da226db..d7e2bffc8dd 100644 --- a/plugins/woocommerce-admin/client/products/edit-product-page.tsx +++ b/plugins/woocommerce-admin/client/products/edit-product-page.tsx @@ -3,6 +3,7 @@ */ import { __ } from '@wordpress/i18n'; import { + EXPERIMENTAL_PRODUCT_FORM_STORE_NAME, EXPERIMENTAL_PRODUCT_VARIATIONS_STORE_NAME, PartialProduct, Product, @@ -21,6 +22,7 @@ import { ProductForm } from './product-form'; import { ProductFormLayout } from './layout/product-form-layout'; import { ProductVariationForm } from './product-variation-form'; import './product-page.scss'; +import './fills'; const EditProductPage: React.FC = () => { const { productId, variationId } = useParams(); @@ -35,6 +37,8 @@ const EditProductPage: React.FC = () => { isPending, getPermalinkParts, } = select( PRODUCTS_STORE_NAME ); + const { hasFinishedResolution: hasProductFormFinishedResolution } = + select( EXPERIMENTAL_PRODUCT_FORM_STORE_NAME ); const { getProductVariation, hasFinishedResolution: hasProductVariationFinishedResolution, @@ -71,7 +75,8 @@ const EditProductPage: React.FC = () => { 'getProductVariation', [ parseInt( variationId, 10 ) ] ) - ), + ) || + ! hasProductFormFinishedResolution( 'getProductForm' ), isPendingAction: isPending( 'createProduct' ) || isPending( diff --git a/plugins/woocommerce-admin/client/products/fields/attribute-field/add-attribute-modal.scss b/plugins/woocommerce-admin/client/products/fields/attribute-control/add-attribute-modal.scss similarity index 100% rename from plugins/woocommerce-admin/client/products/fields/attribute-field/add-attribute-modal.scss rename to plugins/woocommerce-admin/client/products/fields/attribute-control/add-attribute-modal.scss diff --git a/plugins/woocommerce-admin/client/products/fields/attribute-field/add-attribute-modal.tsx b/plugins/woocommerce-admin/client/products/fields/attribute-control/add-attribute-modal.tsx similarity index 94% rename from plugins/woocommerce-admin/client/products/fields/attribute-field/add-attribute-modal.tsx rename to plugins/woocommerce-admin/client/products/fields/attribute-control/add-attribute-modal.tsx index cf5c61288c8..64fd69e9457 100644 --- a/plugins/woocommerce-admin/client/products/fields/attribute-field/add-attribute-modal.tsx +++ b/plugins/woocommerce-admin/client/products/fields/attribute-control/add-attribute-modal.tsx @@ -26,7 +26,7 @@ import { AttributeTermInputField, CustomAttributeTermInputField, } from '../attribute-term-input-field'; -import { HydratedAttributeType } from '../attribute-field'; +import { EnhancedProductAttribute } from '~/products/hooks/use-product-attributes'; import { getProductAttributeObject } from './utils'; type AddAttributeModalProps = { @@ -46,12 +46,12 @@ type AddAttributeModalProps = { confirmCancelLabel?: string; confirmConfirmLabel?: string; onCancel: () => void; - onAdd: ( newCategories: HydratedAttributeType[] ) => void; + onAdd: ( newCategories: EnhancedProductAttribute[] ) => void; selectedAttributeIds?: number[]; }; type AttributeForm = { - attributes: Array< HydratedAttributeType | null >; + attributes: Array< EnhancedProductAttribute | null >; }; export const AddAttributeModal: React.FC< AddAttributeModalProps > = ( { @@ -80,6 +80,15 @@ export const AddAttributeModal: React.FC< AddAttributeModalProps > = ( { onAdd, selectedAttributeIds = [], } ) => { + const scrollAttributeIntoView = ( index: number ) => { + setTimeout( () => { + const attributeRow = document.querySelector( + `.woocommerce-add-attribute-modal__table-row-${ index }` + ); + attributeRow?.scrollIntoView( { behavior: 'smooth' } ); + }, 0 ); + }; + const [ showConfirmClose, setShowConfirmClose ] = useState( false ); const addAnother = ( values: AttributeForm, @@ -89,10 +98,11 @@ export const AddAttributeModal: React.FC< AddAttributeModalProps > = ( { ) => void ) => { setValue( 'attributes', [ ...values.attributes, null ] ); + scrollAttributeIntoView( values.attributes.length ); }; const onAddingAttributes = ( values: AttributeForm ) => { - const newAttributesToAdd: HydratedAttributeType[] = []; + const newAttributesToAdd: EnhancedProductAttribute[] = []; values.attributes.forEach( ( attr ) => { if ( attr !== null && @@ -105,7 +115,7 @@ export const AddAttributeModal: React.FC< AddAttributeModalProps > = ( { ? ( attr.terms || [] ).map( ( term ) => term.name ) : attr.options; newAttributesToAdd.push( { - ...( attr as HydratedAttributeType ), + ...( attr as EnhancedProductAttribute ), options, } ); } diff --git a/plugins/woocommerce-admin/client/products/fields/attribute-field/attribute-field.tsx b/plugins/woocommerce-admin/client/products/fields/attribute-control/attribute-control.tsx similarity index 65% rename from plugins/woocommerce-admin/client/products/fields/attribute-field/attribute-field.tsx rename to plugins/woocommerce-admin/client/products/fields/attribute-control/attribute-control.tsx index 7c867af4a0d..01e03c2f89b 100644 --- a/plugins/woocommerce-admin/client/products/fields/attribute-field/attribute-field.tsx +++ b/plugins/woocommerce-admin/client/products/fields/attribute-control/attribute-control.tsx @@ -2,13 +2,8 @@ * External dependencies */ import { __, sprintf } from '@wordpress/i18n'; -import { useState, useCallback, useEffect } from '@wordpress/element'; -import { - ProductAttribute, - EXPERIMENTAL_PRODUCT_ATTRIBUTE_TERMS_STORE_NAME, - ProductAttributeTerm, -} from '@woocommerce/data'; -import { resolveSelect } from '@wordpress/data'; +import { useState } from '@wordpress/element'; +import { ProductAttribute } from '@woocommerce/data'; import { Sortable, __experimentalSelectControlMenuSlot as SelectControlMenuSlot, @@ -24,42 +19,31 @@ import { getAdminLink } from '@woocommerce/settings'; import './attribute-field.scss'; import { AddAttributeModal } from './add-attribute-modal'; import { EditAttributeModal } from './edit-attribute-modal'; +import { EnhancedProductAttribute } from '~/products/hooks/use-product-attributes'; import { getAttributeKey, reorderSortableProductAttributePositions, } from './utils'; -import { sift } from '../../../utils'; import { AttributeEmptyState } from '../attribute-empty-state'; import { AddAttributeListItem, AttributeListItem, } from '../attribute-list-item'; -type AttributeFieldProps = { +type AttributeControlProps = { value: ProductAttribute[]; onChange: ( value: ProductAttribute[] ) => void; - productId?: number; // TODO: should we support an 'any' option to show all attributes? attributeType?: 'regular' | 'for-variations'; }; -export type HydratedAttributeType = Omit< ProductAttribute, 'options' > & { - options?: string[]; - terms?: ProductAttributeTerm[]; - visible?: boolean; -}; - -export const AttributeField: React.FC< AttributeFieldProps > = ( { +export const AttributeControl: React.FC< AttributeControlProps > = ( { value, - onChange, - productId, attributeType = 'regular', + onChange, } ) => { const [ showAddAttributeModal, setShowAddAttributeModal ] = useState( false ); - const [ hydratedAttributes, setHydratedAttributes ] = useState< - HydratedAttributeType[] - >( [] ); const [ editingAttributeId, setEditingAttributeId ] = useState< null | string >( null ); @@ -72,73 +56,12 @@ export const AttributeField: React.FC< AttributeFieldProps > = ( { ? 'product_add_options_modal_cancel_button_click' : 'product_add_attributes_modal_cancel_button_click'; - const fetchTerms = useCallback( - ( attributeId: number ) => { - return resolveSelect( - EXPERIMENTAL_PRODUCT_ATTRIBUTE_TERMS_STORE_NAME - ) - .getProductAttributeTerms< ProductAttributeTerm[] >( { - attribute_id: attributeId, - product: productId, - } ) - .then( - ( attributeTerms ) => { - return attributeTerms; - }, - ( error ) => { - return error; - } - ); - }, - [ productId ] - ); - - useEffect( () => { - // I think we'll need to move the hydration out of the individual component - // instance. To where, I do not yet know... maybe in the form context - // somewhere so that a single hydration source can be shared between multiple - // instances? Something like a simple key-value store in the form context - // would be handy. - if ( ! value || hydratedAttributes.length !== 0 ) { - return; - } - - const [ customAttributes, globalAttributes ]: ProductAttribute[][] = - sift( value, ( attr: ProductAttribute ) => attr.id === 0 ); - - Promise.all( - globalAttributes.map( ( attr ) => fetchTerms( attr.id ) ) - ).then( ( allResults ) => { - setHydratedAttributes( [ - ...globalAttributes.map( ( attr, index ) => { - const fetchedTerms = allResults[ index ]; - - const newAttr = { - ...attr, - // I'm not sure this is quite right for handling unpersisted terms, - // but this gets things kinda working for now - terms: - fetchedTerms.length > 0 ? fetchedTerms : undefined, - options: - fetchedTerms.length === 0 - ? attr.options - : undefined, - }; - - return newAttr; - } ), - ...customAttributes, - ] ); - } ); - }, [ fetchTerms, hydratedAttributes, value ] ); - const fetchAttributeId = ( attribute: { id: number; name: string } ) => `${ attribute.id }-${ attribute.name }`; - const updateAttributes = ( attributes: HydratedAttributeType[] ) => { - setHydratedAttributes( attributes ); + const handleChange = ( newAttributes: EnhancedProductAttribute[] ) => { onChange( - attributes.map( ( attr ) => { + newAttributes.map( ( attr ) => { return { ...attr, options: attr.terms @@ -157,8 +80,8 @@ export const AttributeField: React.FC< AttributeFieldProps > = ( { recordEvent( 'product_remove_attribute_confirmation_confirm_click' ); - updateAttributes( - hydratedAttributes.filter( + handleChange( + value.filter( ( attr ) => fetchAttributeId( attr ) !== fetchAttributeId( attribute ) @@ -169,9 +92,11 @@ export const AttributeField: React.FC< AttributeFieldProps > = ( { } }; - const onAddNewAttributes = ( newAttributes: HydratedAttributeType[] ) => { - updateAttributes( [ - ...( hydratedAttributes || [] ), + const onAddNewAttributes = ( + newAttributes: EnhancedProductAttribute[] + ) => { + handleChange( [ + ...( value || [] ), ...newAttributes .filter( ( newAttr ) => @@ -193,18 +118,7 @@ export const AttributeField: React.FC< AttributeFieldProps > = ( { setShowAddAttributeModal( false ); }; - const filteredAttributes = value - ? value.filter( - ( attribute: ProductAttribute ) => - attribute.variation === isOnlyForVariations - ) - : false; - - if ( - ! filteredAttributes || - filteredAttributes.length === 0 || - hydratedAttributes.length === 0 - ) { + if ( ! value.length ) { return ( <> = ( { setShowAddAttributeModal( false ); } } onAdd={ onAddNewAttributes } - selectedAttributeIds={ ( filteredAttributes || [] ).map( - ( attr ) => attr.id - ) } + selectedAttributeIds={ [] } /> ) } @@ -242,9 +154,8 @@ export const AttributeField: React.FC< AttributeFieldProps > = ( { ); } - const sortedAttributes = filteredAttributes.sort( - ( a, b ) => a.position - b.position - ); + const sortedAttributes = value.sort( ( a, b ) => a.position - b.position ); + const attributeKeyValues = value.reduce( ( keyValue: Record< number | string, ProductAttribute >, @@ -256,9 +167,9 @@ export const AttributeField: React.FC< AttributeFieldProps > = ( { {} as Record< number | string, ProductAttribute > ); - const attribute = hydratedAttributes.find( + const editingAttribute = value.find( ( attr ) => fetchAttributeId( attr ) === editingAttributeId - ) as HydratedAttributeType; + ) as EnhancedProductAttribute; const editAttributeCopy = isOnlyForVariations ? __( @@ -332,15 +243,13 @@ export const AttributeField: React.FC< AttributeFieldProps > = ( { /> ) } - { editingAttributeId && ( + { editingAttribute && ( = ( { } ) } onCancel={ () => setEditingAttributeId( null ) } onEdit={ ( changedAttribute ) => { - const newAttributesSet = [ ...hydratedAttributes ]; + const newAttributesSet = [ ...value ]; const changedAttributeIndex: number = newAttributesSet.findIndex( ( attr ) => attr.id !== 0 @@ -373,10 +282,10 @@ export const AttributeField: React.FC< AttributeFieldProps > = ( { changedAttribute ); - updateAttributes( newAttributesSet ); + handleChange( newAttributesSet ); setEditingAttributeId( null ); } } - attribute={ attribute } + attribute={ editingAttribute } /> ) }
    diff --git a/plugins/woocommerce-admin/client/products/fields/attribute-field/attribute-field.scss b/plugins/woocommerce-admin/client/products/fields/attribute-control/attribute-field.scss similarity index 100% rename from plugins/woocommerce-admin/client/products/fields/attribute-field/attribute-field.scss rename to plugins/woocommerce-admin/client/products/fields/attribute-control/attribute-field.scss diff --git a/plugins/woocommerce-admin/client/products/fields/attribute-field/edit-attribute-modal.scss b/plugins/woocommerce-admin/client/products/fields/attribute-control/edit-attribute-modal.scss similarity index 100% rename from plugins/woocommerce-admin/client/products/fields/attribute-field/edit-attribute-modal.scss rename to plugins/woocommerce-admin/client/products/fields/attribute-control/edit-attribute-modal.scss diff --git a/plugins/woocommerce-admin/client/products/fields/attribute-field/edit-attribute-modal.tsx b/plugins/woocommerce-admin/client/products/fields/attribute-control/edit-attribute-modal.tsx similarity index 87% rename from plugins/woocommerce-admin/client/products/fields/attribute-field/edit-attribute-modal.tsx rename to plugins/woocommerce-admin/client/products/fields/attribute-control/edit-attribute-modal.tsx index deaf03ee225..53da1cec567 100644 --- a/plugins/woocommerce-admin/client/products/fields/attribute-field/edit-attribute-modal.tsx +++ b/plugins/woocommerce-admin/client/products/fields/attribute-control/edit-attribute-modal.tsx @@ -18,7 +18,7 @@ import { AttributeTermInputField, CustomAttributeTermInputField, } from '../attribute-term-input-field'; -import { HydratedAttributeType } from './attribute-field'; +import { EnhancedProductAttribute } from '../../hooks/use-product-attributes'; import './edit-attribute-modal.scss'; @@ -36,8 +36,8 @@ type EditAttributeModalProps = { updateAccessibleLabel?: string; updateLabel?: string; onCancel: () => void; - onEdit: ( alteredAttribute: HydratedAttributeType ) => void; - attribute: HydratedAttributeType; + onEdit: ( alteredAttribute: EnhancedProductAttribute ) => void; + attribute: EnhancedProductAttribute; }; export const EditAttributeModal: React.FC< EditAttributeModalProps > = ( { @@ -64,7 +64,7 @@ export const EditAttributeModal: React.FC< EditAttributeModalProps > = ( { attribute, } ) => { const [ editableAttribute, setEditableAttribute ] = useState< - HydratedAttributeType | undefined + EnhancedProductAttribute | undefined >( { ...attribute } ); const isCustomAttribute = editableAttribute?.id === 0; @@ -84,7 +84,7 @@ export const EditAttributeModal: React.FC< EditAttributeModalProps > = ( { } onChange={ ( val ) => setEditableAttribute( { - ...( editableAttribute as HydratedAttributeType ), + ...( editableAttribute as EnhancedProductAttribute ), name: val, } ) } @@ -102,7 +102,7 @@ export const EditAttributeModal: React.FC< EditAttributeModalProps > = ( { attributeId={ editableAttribute?.id } onChange={ ( val ) => { setEditableAttribute( { - ...( editableAttribute as HydratedAttributeType ), + ...( editableAttribute as EnhancedProductAttribute ), terms: val, } ); } } @@ -115,7 +115,7 @@ export const EditAttributeModal: React.FC< EditAttributeModalProps > = ( { value={ editableAttribute?.options } onChange={ ( val ) => { setEditableAttribute( { - ...( editableAttribute as HydratedAttributeType ), + ...( editableAttribute as EnhancedProductAttribute ), options: val, } ); } } @@ -126,7 +126,7 @@ export const EditAttributeModal: React.FC< EditAttributeModalProps > = ( { setEditableAttribute( { - ...( editableAttribute as HydratedAttributeType ), + ...( editableAttribute as EnhancedProductAttribute ), visible: val, } ) } @@ -148,7 +148,7 @@ export const EditAttributeModal: React.FC< EditAttributeModalProps > = ( { isPrimary label={ updateAccessibleLabel } onClick={ () => { - onEdit( editableAttribute as HydratedAttributeType ); + onEdit( editableAttribute as EnhancedProductAttribute ); } } > { updateLabel } diff --git a/plugins/woocommerce-admin/client/products/fields/attribute-control/index.ts b/plugins/woocommerce-admin/client/products/fields/attribute-control/index.ts new file mode 100644 index 00000000000..3220152ee6e --- /dev/null +++ b/plugins/woocommerce-admin/client/products/fields/attribute-control/index.ts @@ -0,0 +1 @@ +export * from './attribute-control'; diff --git a/plugins/woocommerce-admin/client/products/fields/attribute-field/test/add-attribute-modal.spec.tsx b/plugins/woocommerce-admin/client/products/fields/attribute-control/test/add-attribute-modal.spec.tsx similarity index 100% rename from plugins/woocommerce-admin/client/products/fields/attribute-field/test/add-attribute-modal.spec.tsx rename to plugins/woocommerce-admin/client/products/fields/attribute-control/test/add-attribute-modal.spec.tsx diff --git a/plugins/woocommerce-admin/client/products/fields/attribute-field/test/attribute-field.spec.tsx b/plugins/woocommerce-admin/client/products/fields/attribute-control/test/attribute-field.spec.tsx similarity index 90% rename from plugins/woocommerce-admin/client/products/fields/attribute-field/test/attribute-field.spec.tsx rename to plugins/woocommerce-admin/client/products/fields/attribute-control/test/attribute-field.spec.tsx index f4a85ee1c7c..8ba9808e619 100644 --- a/plugins/woocommerce-admin/client/products/fields/attribute-field/test/attribute-field.spec.tsx +++ b/plugins/woocommerce-admin/client/products/fields/attribute-control/test/attribute-field.spec.tsx @@ -8,7 +8,7 @@ import { ProductAttribute } from '@woocommerce/data'; /** * Internal dependencies */ -import { AttributeField } from '../attribute-field'; +import { AttributeControl } from '../attribute-control'; const attributeList: ProductAttribute[] = [ { @@ -102,7 +102,7 @@ jest.mock( '@woocommerce/components', () => ( { }, } ) ); -describe( 'AttributeField', () => { +describe( 'AttributeControl', () => { beforeEach( () => { jest.clearAllMocks(); } ); @@ -110,17 +110,17 @@ describe( 'AttributeField', () => { describe( 'empty state', () => { it( 'should show subtitle and "Add first attribute" button', () => { const { queryByText } = render( - {} } /> + {} } /> ); expect( queryByText( 'No attributes yet' ) ).toBeInTheDocument(); expect( queryByText( 'Add first attribute' ) ).toBeInTheDocument(); } ); } ); - it( 'should render the list of existing attributes', async () => { + it( 'should render the list of all attributes', async () => { act( () => { render( - {} } /> @@ -128,20 +128,20 @@ describe( 'AttributeField', () => { } ); expect( - await screen.findByText( 'No attributes yet' ) + await screen.queryByText( 'No attributes yet' ) ).not.toBeInTheDocument(); expect( - await screen.findByText( attributeList[ 0 ].name ) + await screen.queryByText( attributeList[ 0 ].name ) ).toBeInTheDocument(); expect( await screen.queryByText( attributeList[ 1 ].name ) - ).not.toBeInTheDocument(); + ).toBeInTheDocument(); } ); it( 'should render the first two terms of each option, and show "+ n more" for the rest', async () => { act( () => { render( - {} } attributeType="for-variations" @@ -149,9 +149,6 @@ describe( 'AttributeField', () => { ); } ); - expect( - await screen.queryByText( attributeList[ 0 ].options[ 0 ] ) - ).not.toBeInTheDocument(); expect( await screen.findByText( attributeList[ 1 ].options[ 0 ] ) ).toBeInTheDocument(); @@ -173,7 +170,7 @@ describe( 'AttributeField', () => { jest.spyOn( global, 'confirm' ).mockReturnValueOnce( false ); act( () => { render( - {} } /> @@ -191,7 +188,7 @@ describe( 'AttributeField', () => { act( () => { render( - @@ -211,7 +208,7 @@ describe( 'AttributeField', () => { const onChange = jest.fn(); act( () => { render( - @@ -232,7 +229,7 @@ describe( 'AttributeField', () => { act( () => { render( - diff --git a/plugins/woocommerce-admin/client/products/fields/attribute-field/test/utils.spec.ts b/plugins/woocommerce-admin/client/products/fields/attribute-control/test/utils.spec.ts similarity index 100% rename from plugins/woocommerce-admin/client/products/fields/attribute-field/test/utils.spec.ts rename to plugins/woocommerce-admin/client/products/fields/attribute-control/test/utils.spec.ts diff --git a/plugins/woocommerce-admin/client/products/fields/attribute-field/utils.ts b/plugins/woocommerce-admin/client/products/fields/attribute-control/utils.ts similarity index 100% rename from plugins/woocommerce-admin/client/products/fields/attribute-field/utils.ts rename to plugins/woocommerce-admin/client/products/fields/attribute-control/utils.ts diff --git a/plugins/woocommerce-admin/client/products/fields/attribute-field/index.ts b/plugins/woocommerce-admin/client/products/fields/attribute-field/index.ts deleted file mode 100644 index 938913f453f..00000000000 --- a/plugins/woocommerce-admin/client/products/fields/attribute-field/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './attribute-field'; diff --git a/plugins/woocommerce-admin/client/products/fields/attribute-input-field/attribute-input-field.tsx b/plugins/woocommerce-admin/client/products/fields/attribute-input-field/attribute-input-field.tsx index 7f19b7cdff1..8d3eaf35402 100644 --- a/plugins/woocommerce-admin/client/products/fields/attribute-input-field/attribute-input-field.tsx +++ b/plugins/woocommerce-admin/client/products/fields/attribute-input-field/attribute-input-field.tsx @@ -22,12 +22,12 @@ import { * Internal dependencies */ import './attribute-input-field.scss'; -import { HydratedAttributeType } from '../attribute-field'; +import { EnhancedProductAttribute } from '~/products/hooks/use-product-attributes'; type NarrowedQueryAttribute = Pick< QueryProductAttribute, 'id' | 'name' >; type AttributeInputFieldProps = { - value?: HydratedAttributeType | null; + value?: EnhancedProductAttribute | null; onChange: ( value?: | Omit< ProductAttribute, 'position' | 'visible' | 'variation' > diff --git a/plugins/woocommerce-admin/client/products/fields/attributes/attributes.tsx b/plugins/woocommerce-admin/client/products/fields/attributes/attributes.tsx index 85f905ce06d..ff768ee26c4 100644 --- a/plugins/woocommerce-admin/client/products/fields/attributes/attributes.tsx +++ b/plugins/woocommerce-admin/client/products/fields/attributes/attributes.tsx @@ -6,7 +6,8 @@ import { ProductAttribute } from '@woocommerce/data'; /** * Internal dependencies */ -import { AttributeField } from '../attribute-field'; +import { AttributeControl } from '../attribute-control'; +import { useProductAttributes } from '~/products/hooks/use-product-attributes'; type AttributesProps = { value: ProductAttribute[]; @@ -19,12 +20,17 @@ export const Attributes: React.FC< AttributesProps > = ( { onChange, productId, } ) => { + const { attributes, handleChange } = useProductAttributes( { + allAttributes: value, + onChange, + productId, + } ); + return ( - ); }; diff --git a/plugins/woocommerce-admin/client/products/fields/options/options.tsx b/plugins/woocommerce-admin/client/products/fields/options/options.tsx index c35fa4e2695..bff9c0ff16a 100644 --- a/plugins/woocommerce-admin/client/products/fields/options/options.tsx +++ b/plugins/woocommerce-admin/client/products/fields/options/options.tsx @@ -7,7 +7,8 @@ import { useFormContext } from '@woocommerce/components'; /** * Internal dependencies */ -import { AttributeField } from '../attribute-field'; +import { AttributeControl } from '../attribute-control'; +import { useProductAttributes } from '~/products/hooks/use-product-attributes'; import { useProductVariationsHelper } from '../../hooks/use-product-variations-helper'; type OptionsProps = { @@ -24,17 +25,24 @@ export const Options: React.FC< OptionsProps > = ( { const { values } = useFormContext< Product >(); const { generateProductVariations } = useProductVariationsHelper(); - const handleChange = async ( attributes: ProductAttribute[] ) => { - onChange( attributes ); - generateProductVariations( { ...values, attributes } ); - }; + const { attributes, handleChange } = useProductAttributes( { + allAttributes: value, + isVariationAttributes: true, + onChange: ( newAttributes ) => { + onChange( newAttributes ); + generateProductVariations( { + ...values, + attributes: newAttributes, + } ); + }, + productId, + } ); return ( - ); }; diff --git a/plugins/woocommerce-admin/client/products/fills/constants.ts b/plugins/woocommerce-admin/client/products/fills/constants.ts new file mode 100644 index 00000000000..ad4ca43284e --- /dev/null +++ b/plugins/woocommerce-admin/client/products/fills/constants.ts @@ -0,0 +1,7 @@ +export const PRODUCT_DETAILS_SLUG = 'product-details'; + +export const DETAILS_SECTION_ID = 'general/details'; +export const IMAGES_SECTION_ID = 'general/images'; + +export const TAB_GENERAL_ID = 'tab/general'; +export const PLUGIN_ID = 'woocommerce'; diff --git a/plugins/woocommerce-admin/client/products/fills/details-section/details-field-categories.tsx b/plugins/woocommerce-admin/client/products/fills/details-section/details-field-categories.tsx new file mode 100644 index 00000000000..87e080ba5e3 --- /dev/null +++ b/plugins/woocommerce-admin/client/products/fills/details-section/details-field-categories.tsx @@ -0,0 +1,25 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +import { useFormContext } from '@woocommerce/components'; +import { Product, ProductCategory } from '@woocommerce/data'; + +/** + * Internal dependencies + */ +import { CategoryField } from '../../fields/category-field'; + +export const DetailsCategoriesField = () => { + const { getInputProps } = useFormContext< Product >(); + + return ( + [] >( + 'categories' + ) } + /> + ); +}; diff --git a/plugins/woocommerce-admin/client/products/fills/details-section/details-field-description.tsx b/plugins/woocommerce-admin/client/products/fills/details-section/details-field-description.tsx new file mode 100644 index 00000000000..36f445ab3c0 --- /dev/null +++ b/plugins/woocommerce-admin/client/products/fills/details-section/details-field-description.tsx @@ -0,0 +1,36 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +import { + useFormContext, + __experimentalRichTextEditor as RichTextEditor, +} from '@woocommerce/components'; +import { Product } from '@woocommerce/data'; +import { BlockInstance, serialize, parse } from '@wordpress/blocks'; +import { useState } from '@wordpress/element'; + +export const DetailsDescriptionField = () => { + const { setValue, values } = useFormContext< Product >(); + const [ descriptionBlocks, setDescriptionBlocks ] = useState< + BlockInstance[] + >( parse( values.description || '' ) ); + + return ( + { + setDescriptionBlocks( blocks ); + if ( ! descriptionBlocks.length ) { + return; + } + setValue( 'description', serialize( blocks ) ); + } } + placeholder={ __( + 'Describe this product. What makes it unique? What are its most important features?', + 'woocommerce' + ) } + /> + ); +}; diff --git a/plugins/woocommerce-admin/client/products/fills/details-section/details-field-feature.tsx b/plugins/woocommerce-admin/client/products/fills/details-section/details-field-feature.tsx new file mode 100644 index 00000000000..afbae52275d --- /dev/null +++ b/plugins/woocommerce-admin/client/products/fills/details-section/details-field-feature.tsx @@ -0,0 +1,65 @@ +/** + * External dependencies + */ +import { CheckboxControl } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +import { + useFormContext, + Link, + __experimentalTooltip as Tooltip, +} from '@woocommerce/components'; +import interpolateComponents from '@automattic/interpolate-components'; +import { Product } from '@woocommerce/data'; +import { recordEvent } from '@woocommerce/tracks'; + +/** + * Internal dependencies + */ +import { getCheckboxTracks } from '../../sections/utils'; +import { PRODUCT_DETAILS_SLUG } from '../constants'; + +export const DetailsFeatureField = () => { + const { getCheckboxControlProps } = useFormContext< Product >(); + + return ( + + { __( 'Feature this product', 'woocommerce' ) } + + recordEvent( + 'add_product_learn_more', + { + category: + PRODUCT_DETAILS_SLUG, + } + ) + } + > + { __( 'Learn more', 'woocommerce' ) } + + ), + }, + } ) } + /> + + } + { ...getCheckboxControlProps( + 'featured', + getCheckboxTracks( 'featured' ) + ) } + /> + ); +}; diff --git a/plugins/woocommerce-admin/client/products/fills/details-section/details-field-name.tsx b/plugins/woocommerce-admin/client/products/fills/details-section/details-field-name.tsx new file mode 100644 index 00000000000..0fb92254221 --- /dev/null +++ b/plugins/woocommerce-admin/client/products/fills/details-section/details-field-name.tsx @@ -0,0 +1,104 @@ +/** + * External dependencies + */ +import { Button, TextControl } from '@wordpress/components'; +import { useSelect } from '@wordpress/data'; +import { __ } from '@wordpress/i18n'; +import { cleanForSlug } from '@wordpress/url'; +import { useFormContext } from '@woocommerce/components'; +import interpolateComponents from '@automattic/interpolate-components'; +import { + Product, + PRODUCTS_STORE_NAME, + WCDataSelector, +} from '@woocommerce/data'; +import { useState } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { EditProductLinkModal } from '../../shared/edit-product-link-modal'; +import { PRODUCT_DETAILS_SLUG } from '../constants'; + +export const DetailsNameField = ( {} ) => { + const [ showProductLinkEditModal, setShowProductLinkEditModal ] = + useState( false ); + const { getInputProps, values, touched, errors, setValue } = + useFormContext< Product >(); + + const { permalinkPrefix, permalinkSuffix } = useSelect( + ( select: WCDataSelector ) => { + const { getPermalinkParts } = select( PRODUCTS_STORE_NAME ); + if ( values.id ) { + const parts = getPermalinkParts( values.id ); + return { + permalinkPrefix: parts?.prefix, + permalinkSuffix: parts?.suffix, + }; + } + return {}; + } + ); + + const hasNameError = () => { + return Boolean( touched.name ) && Boolean( errors.name ); + }; + + const setSkuIfEmpty = () => { + if ( values.sku || ! values.name?.length ) { + return; + } + setValue( 'sku', cleanForSlug( values.name ) ); + }; + return ( +
    + + { __( '(required)', 'woocommerce' ) } + + ), + }, + } ) } + name={ `${ PRODUCT_DETAILS_SLUG }-name` } + placeholder={ __( 'e.g. 12 oz Coffee Mug', 'woocommerce' ) } + { ...getInputProps( 'name', { + onBlur: setSkuIfEmpty, + } ) } + /> + { values.id && ! hasNameError() && permalinkPrefix && ( + + { __( 'Product link', 'woocommerce' ) } + :  + + { permalinkPrefix } + { values.slug || cleanForSlug( values.name ) } + { permalinkSuffix } + + + + ) } + { showProductLinkEditModal && ( + setShowProductLinkEditModal( false ) } + onSaved={ () => setShowProductLinkEditModal( false ) } + /> + ) } +
    + ); +}; diff --git a/plugins/woocommerce-admin/client/products/fills/details-section/details-field-summary.tsx b/plugins/woocommerce-admin/client/products/fills/details-section/details-field-summary.tsx new file mode 100644 index 00000000000..aa1304e5d36 --- /dev/null +++ b/plugins/woocommerce-admin/client/products/fills/details-section/details-field-summary.tsx @@ -0,0 +1,36 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +import { + useFormContext, + __experimentalRichTextEditor as RichTextEditor, +} from '@woocommerce/components'; +import { Product } from '@woocommerce/data'; +import { BlockInstance, serialize, parse } from '@wordpress/blocks'; +import { useState } from '@wordpress/element'; + +export const DetailsSummaryField = () => { + const { setValue, values } = useFormContext< Product >(); + const [ summaryBlocks, setSummaryBlocks ] = useState< BlockInstance[] >( + parse( values.short_description || '' ) + ); + + return ( + { + setSummaryBlocks( blocks ); + if ( ! summaryBlocks.length ) { + return; + } + setValue( 'short_description', serialize( blocks ) ); + } } + placeholder={ __( + "Summarize this product in 1-2 short sentences. We'll show it at the top of the page.", + 'woocommerce' + ) } + /> + ); +}; diff --git a/plugins/woocommerce-admin/client/products/fills/details-section/details-section-fills.tsx b/plugins/woocommerce-admin/client/products/fills/details-section/details-section-fills.tsx new file mode 100644 index 00000000000..9eefdae7b0d --- /dev/null +++ b/plugins/woocommerce-admin/client/products/fills/details-section/details-section-fills.tsx @@ -0,0 +1,91 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +import { + __experimentalWooProductSectionItem as WooProductSectionItem, + __experimentalWooProductFieldItem as WooProductFieldItem, + __experimentalProductFieldSection as ProductFieldSection, +} from '@woocommerce/components'; +import { registerPlugin } from '@wordpress/plugins'; + +/** + * Internal dependencies + */ +import { + DetailsNameField, + DetailsCategoriesField, + DetailsFeatureField, + DetailsSummaryField, + DetailsDescriptionField, +} from './index'; + +import { DETAILS_SECTION_ID, PLUGIN_ID, TAB_GENERAL_ID } from '../constants'; + +import './product-details-section.scss'; + +const DetailsSection = () => ( + <> + + + + + + + + + + + + + + + + + + + +); + +registerPlugin( 'wc-admin-product-editor-details-section', { + // @ts-expect-error 'scope' does exist. @types/wordpress__plugins is outdated. + scope: 'woocommerce-product-editor', + render: () => , +} ); diff --git a/plugins/woocommerce-admin/client/products/fills/details-section/index.ts b/plugins/woocommerce-admin/client/products/fills/details-section/index.ts new file mode 100644 index 00000000000..1e342c4c1c1 --- /dev/null +++ b/plugins/woocommerce-admin/client/products/fills/details-section/index.ts @@ -0,0 +1,5 @@ +export * from './details-field-name'; +export * from './details-field-categories'; +export * from './details-field-feature'; +export * from './details-field-summary'; +export * from './details-field-description'; diff --git a/plugins/woocommerce-admin/client/products/sections/product-details-section.scss b/plugins/woocommerce-admin/client/products/fills/details-section/product-details-section.scss similarity index 100% rename from plugins/woocommerce-admin/client/products/sections/product-details-section.scss rename to plugins/woocommerce-admin/client/products/fills/details-section/product-details-section.scss diff --git a/plugins/woocommerce-admin/client/products/fills/images-section/images-field-gallery.tsx b/plugins/woocommerce-admin/client/products/fills/images-section/images-field-gallery.tsx new file mode 100644 index 00000000000..1a9ba1f2ebd --- /dev/null +++ b/plugins/woocommerce-admin/client/products/fills/images-section/images-field-gallery.tsx @@ -0,0 +1,201 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +import { + useFormContext, + MediaUploader, + ImageGallery, + ImageGalleryItem, +} from '@woocommerce/components'; +import { CardBody, DropZone } from '@wordpress/components'; +import { recordEvent } from '@woocommerce/tracks'; +import { useState } from '@wordpress/element'; +import { Product } from '@woocommerce/data'; +import { Icon, trash } from '@wordpress/icons'; +import { MediaItem } from '@wordpress/media-utils'; +import classnames from 'classnames'; + +/** + * Internal dependencies + */ +import DragAndDrop from '../../images/drag-and-drop.svg'; + +type Image = MediaItem & { + src: string; +}; + +export const ImagesGalleryField = () => { + const { getInputProps, setValue } = useFormContext< Product >(); + const images = ( getInputProps( 'images' ).value as Image[] ) || []; + const [ isRemovingZoneVisible, setIsRemovingZoneVisible ] = + useState< boolean >( false ); + const [ isRemoving, setIsRemoving ] = useState< boolean >( false ); + const [ draggedImageId, setDraggedImageId ] = useState< number | null >( + null + ); + + const toggleRemoveZone = () => { + setIsRemovingZoneVisible( ! isRemovingZoneVisible ); + }; + + const orderImages = ( newOrder: JSX.Element[] ) => { + const orderedImages = newOrder.map( ( image ) => { + return images.find( + ( file ) => file.id === parseInt( image?.props?.id, 10 ) + ); + } ); + recordEvent( 'product_images_change_image_order_via_image_gallery' ); + setValue( 'images', orderedImages ); + }; + const onFileUpload = ( files: MediaItem[] ) => { + if ( files[ 0 ].id ) { + recordEvent( 'product_images_add_via_file_upload_area' ); + setValue( 'images', [ ...images, ...files ] ); + } + }; + + return ( +
    0, + } ) } + > + { + const { id: imageId, dataset } = + event.target as HTMLElement; + if ( imageId ) { + setDraggedImageId( parseInt( imageId, 10 ) ); + } else { + const index = dataset?.index; + if ( index ) { + setDraggedImageId( + images[ parseInt( index, 10 ) ]?.id + ); + } + } + toggleRemoveZone(); + } } + onDragEnd={ () => { + if ( isRemoving && draggedImageId ) { + recordEvent( + 'product_images_remove_image_button_click' + ); + setValue( + 'images', + images.filter( + ( img ) => img.id !== draggedImageId + ) + ); + setIsRemoving( false ); + setDraggedImageId( null ); + } + toggleRemoveZone(); + } } + onOrderChange={ orderImages } + onReplace={ ( { replaceIndex, media } ) => { + if ( + images.find( ( img ) => media.id === img.id ) === + undefined + ) { + images[ replaceIndex ] = media as Image; + recordEvent( + 'product_images_replace_image_button_click' + ); + setValue( 'images', images ); + } + } } + onSelectAsCover={ () => + recordEvent( + 'product_images_select_image_as_cover_button_click' + ) + } + > + { images.map( ( image ) => ( + + ) ) } + +
    + { isRemovingZoneVisible ? ( + +
    + + + { __( 'Drop here to remove', 'woocommerce' ) } + + setIsRemoving( true ) } + onDrop={ () => setIsRemoving( true ) } + label={ __( + 'Drop here to remove', + 'woocommerce' + ) } + /> +
    +
    + ) : ( + + null } + onFileUploadChange={ onFileUpload } + onSelect={ ( files ) => { + const newImages = files.filter( + ( img: Image ) => + ! images.find( + ( image ) => image.id === img.id + ) + ); + if ( newImages.length > 0 ) { + recordEvent( + 'product_images_add_via_media_library' + ); + setValue( 'images', [ + ...images, + ...newImages, + ] ); + } + } } + onUpload={ ( files ) => { + if ( files[ 0 ].id ) { + recordEvent( + 'product_images_add_via_drag_and_drop_upload' + ); + setValue( 'images', [ + ...images, + ...files, + ] ); + } + } } + label={ + <> + { + + { __( + 'Drag images here or click to upload', + 'woocommerce' + ) } + + + } + /> + + ) } +
    +
    + ); +}; diff --git a/plugins/woocommerce-admin/client/products/fills/images-section/images-section-fills.tsx b/plugins/woocommerce-admin/client/products/fills/images-section/images-section-fills.tsx new file mode 100644 index 00000000000..01b735d0627 --- /dev/null +++ b/plugins/woocommerce-admin/client/products/fills/images-section/images-section-fills.tsx @@ -0,0 +1,74 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +import { + __experimentalWooProductSectionItem as WooProductSectionItem, + __experimentalWooProductFieldItem as WooProductFieldItem, + __experimentalProductFieldSection as ProductFieldSection, + Link, +} from '@woocommerce/components'; +import { registerPlugin } from '@wordpress/plugins'; +import { recordEvent } from '@woocommerce/tracks'; + +/** + * Internal dependencies + */ +import { ImagesGalleryField } from './index'; +import { IMAGES_SECTION_ID, TAB_GENERAL_ID, PLUGIN_ID } from '../constants'; + +import './images-section.scss'; + +const ImagesSection = () => ( + <> + + + + { __( + 'For best results, use JPEG files that are 1000 by 1000 pixels or larger.', + 'woocommerce' + ) } + + { + recordEvent( 'prepare_images_help' ); + } } + > + { __( + 'How should I prepare images?', + 'woocommerce' + ) } + + + } + /> + + + + + +); + +registerPlugin( 'wc-admin-product-editor-images-section', { + // @ts-expect-error 'scope' does exist. @types/wordpress__plugins is outdated. + scope: 'woocommerce-product-editor', + render: () => , +} ); diff --git a/plugins/woocommerce-admin/client/products/sections/images-section.scss b/plugins/woocommerce-admin/client/products/fills/images-section/images-section.scss similarity index 100% rename from plugins/woocommerce-admin/client/products/sections/images-section.scss rename to plugins/woocommerce-admin/client/products/fills/images-section/images-section.scss diff --git a/plugins/woocommerce-admin/client/products/fills/images-section/index.ts b/plugins/woocommerce-admin/client/products/fills/images-section/index.ts new file mode 100644 index 00000000000..9d67f229c29 --- /dev/null +++ b/plugins/woocommerce-admin/client/products/fills/images-section/index.ts @@ -0,0 +1 @@ +export * from './images-field-gallery'; diff --git a/plugins/woocommerce-admin/client/products/fills/index.ts b/plugins/woocommerce-admin/client/products/fills/index.ts new file mode 100644 index 00000000000..f1cc184b6f7 --- /dev/null +++ b/plugins/woocommerce-admin/client/products/fills/index.ts @@ -0,0 +1,7 @@ +/** + * Internal dependencies + */ +import './product-form-fills'; + +export * from './details-section/details-section-fills'; +export * from './images-section/images-section-fills'; diff --git a/plugins/woocommerce-admin/client/products/fills/product-form-field-fills.tsx b/plugins/woocommerce-admin/client/products/fills/product-form-field-fills.tsx new file mode 100644 index 00000000000..e9b1bc64efc --- /dev/null +++ b/plugins/woocommerce-admin/client/products/fills/product-form-field-fills.tsx @@ -0,0 +1,37 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +import { + __experimentalWooProductFieldItem as WooProductFieldItem, + renderField, + useFormContext, +} from '@woocommerce/components'; +import { Product, ProductFormField } from '@woocommerce/data'; + +export const Fields: React.FC< { fields: ProductFormField[] } > = ( { + fields, +} ) => { + const { getInputProps } = useFormContext< Product >(); + + return ( + <> + { fields.map( ( field ) => ( + + <> + { renderField( field.type, { + ...getInputProps( field.properties.name ), + ...field.properties, + } ) } + + + ) ) }{ ' ' } + + ); +}; diff --git a/plugins/woocommerce-admin/client/products/fills/product-form-fills.tsx b/plugins/woocommerce-admin/client/products/fills/product-form-fills.tsx new file mode 100644 index 00000000000..0710edfd2fd --- /dev/null +++ b/plugins/woocommerce-admin/client/products/fills/product-form-fills.tsx @@ -0,0 +1,52 @@ +/** + * External dependencies + */ +import { registerPlugin } from '@wordpress/plugins'; +import { useSelect, resolveSelect } from '@wordpress/data'; +import { + EXPERIMENTAL_PRODUCT_FORM_STORE_NAME, + WCDataSelector, +} from '@woocommerce/data'; +import { registerCoreProductFields } from '@woocommerce/components'; + +registerCoreProductFields(); + +/** + * Internal dependencies + */ +import { Fields } from './product-form-field-fills'; +import { Sections } from './product-form-section-fills'; + +const Form = () => { + const { formData } = useSelect( ( select: WCDataSelector ) => { + return { + formData: select( + EXPERIMENTAL_PRODUCT_FORM_STORE_NAME + ).getProductForm(), + }; + } ); + + return ( + <> + { formData && ( + <> + + + + ) } + + ); +}; + +/** + * Preloading product form data, as product pages are waiting on this to be resolved. + * The above Form component won't get rendered until the getProductForm is resolved. + */ +resolveSelect( EXPERIMENTAL_PRODUCT_FORM_STORE_NAME ).getProductForm(); +registerPlugin( 'wc-admin-product-editor-form-fills', { + // @ts-expect-error 'scope' does exist. @types/wordpress__plugins is outdated. + scope: 'woocommerce-product-editor', + render: () => { + return
    ; + }, +} ); diff --git a/plugins/woocommerce-admin/client/products/fills/product-form-section-fills.tsx b/plugins/woocommerce-admin/client/products/fills/product-form-section-fills.tsx new file mode 100644 index 00000000000..87026629a88 --- /dev/null +++ b/plugins/woocommerce-admin/client/products/fills/product-form-section-fills.tsx @@ -0,0 +1,34 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +import { Card, CardBody } from '@wordpress/components'; +import { + __experimentalWooProductSectionItem as WooProductSectionItem, + __experimentalProductFieldSection as ProductFieldSection, +} from '@woocommerce/components'; +import { ProductFormSection } from '@woocommerce/data'; + +export const Sections: React.FC< { sections: ProductFormSection[] } > = ( { + sections, +} ) => { + return ( + <> + { sections.map( ( section ) => ( + + + + ) ) } + + ); +}; diff --git a/plugins/woocommerce-admin/client/products/hooks/use-product-attributes.ts b/plugins/woocommerce-admin/client/products/hooks/use-product-attributes.ts new file mode 100644 index 00000000000..f05dc993994 --- /dev/null +++ b/plugins/woocommerce-admin/client/products/hooks/use-product-attributes.ts @@ -0,0 +1,111 @@ +/** + * External dependencies + */ +import { + EXPERIMENTAL_PRODUCT_ATTRIBUTE_TERMS_STORE_NAME, + ProductAttribute, + ProductAttributeTerm, +} from '@woocommerce/data'; +import { resolveSelect } from '@wordpress/data'; +import { useCallback, useEffect, useState } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { sift } from '../../utils'; + +type useProductAttributesProps = { + allAttributes: ProductAttribute[]; + isVariationAttributes?: boolean; + onChange: ( attributes: ProductAttribute[] ) => void; + productId?: number; +}; + +export type EnhancedProductAttribute = ProductAttribute & { + terms?: ProductAttributeTerm[]; + visible?: boolean; +}; + +export function useProductAttributes( { + allAttributes = [], + isVariationAttributes = false, + onChange, + productId, +}: useProductAttributesProps ) { + const getFilteredAttributes = () => { + return isVariationAttributes + ? allAttributes.filter( ( attribute ) => !! attribute.variation ) + : allAttributes.filter( ( attribute ) => ! attribute.variation ); + }; + + const [ attributes, setAttributes ] = useState< + EnhancedProductAttribute[] + >( getFilteredAttributes() ); + const [ localAttributes, globalAttributes ]: ProductAttribute[][] = sift( + attributes, + ( attr: ProductAttribute ) => attr.id === 0 + ); + + 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 ] + ); + + const enhanceAttribute = ( + globalAttribute: ProductAttribute, + terms: ProductAttributeTerm[] + ) => { + return { + ...globalAttribute, + terms: terms.length > 0 ? terms : undefined, + options: terms.length === 0 ? globalAttribute.options : [], + }; + }; + + const handleChange = ( newAttributes: ProductAttribute[] ) => { + const otherAttributes = isVariationAttributes + ? allAttributes.filter( ( attribute ) => ! attribute.variation ) + : allAttributes.filter( ( attribute ) => !! attribute.variation ); + setAttributes( newAttributes ); + onChange( [ ...otherAttributes, ...newAttributes ] ); + }; + + useEffect( () => { + if ( ! getFilteredAttributes().length || attributes.length ) { + return; + } + + Promise.all( + globalAttributes.map( ( attr ) => fetchTerms( attr.id ) ) + ).then( ( termData ) => { + setAttributes( [ + ...globalAttributes.map( ( attr, index ) => + enhanceAttribute( attr, termData[ index ] ) + ), + ...localAttributes, + ] ); + } ); + }, [ allAttributes, attributes, fetchTerms ] ); + + return { + attributes, + handleChange, + setAttributes, + }; +} diff --git a/plugins/woocommerce-admin/client/products/layout/product-section-layout.scss b/plugins/woocommerce-admin/client/products/layout/product-section-layout.scss index acd31a35894..83d72682e05 100644 --- a/plugins/woocommerce-admin/client/products/layout/product-section-layout.scss +++ b/plugins/woocommerce-admin/client/products/layout/product-section-layout.scss @@ -23,7 +23,7 @@ } .woocommerce-product-form__field:not(:first-child) { - margin-top: $gap-large; + margin-top: $gap-large - $gap-smaller; > .components-base-control { margin-bottom: 0; diff --git a/plugins/woocommerce-admin/client/products/product-form.tsx b/plugins/woocommerce-admin/client/products/product-form.tsx index 6be4974c9e8..302c532e18c 100644 --- a/plugins/woocommerce-admin/client/products/product-form.tsx +++ b/plugins/woocommerce-admin/client/products/product-form.tsx @@ -5,8 +5,10 @@ import { Form, FormRef, __experimentalWooProductSectionItem as WooProductSectionItem, + SlotContextProvider, } from '@woocommerce/components'; import { PartialProduct, Product } from '@woocommerce/data'; +import { PluginArea } from '@wordpress/plugins'; import { Ref } from 'react'; /** @@ -14,76 +16,81 @@ import { Ref } from 'react'; */ import { ProductFormHeader } from './layout/product-form-header'; import { ProductFormLayout } from './layout/product-form-layout'; -import { ProductDetailsSection } from './sections/product-details-section'; import { ProductInventorySection } from './sections/product-inventory-section'; import { PricingSection } from './sections/pricing-section'; import { ProductShippingSection } from './sections/product-shipping-section'; import { ProductVariationsSection } from './sections/product-variations-section'; -import { ImagesSection } from './sections/images-section'; import { validate } from './product-validation'; import { AttributesSection } from './sections/attributes-section'; import { OptionsSection } from './sections/options-section'; import { ProductFormFooter } from './layout/product-form-footer'; import { ProductFormTab } from './product-form-tab'; +import { TAB_GENERAL_ID } from './fills/constants'; export const ProductForm: React.FC< { product?: PartialProduct; formRef?: Ref< FormRef< Partial< Product > > >; } > = ( { product, formRef } ) => { return ( - > - initialValues={ - product || { - reviews_allowed: true, - name: '', - sku: '', - stock_quantity: 0, - stock_status: 'instock', + + > + initialValues={ + product || { + reviews_allowed: true, + name: '', + sku: '', + stock_quantity: 0, + stock_status: 'instock', + } } - } - ref={ formRef } - errors={ {} } - validate={ validate } - > - - - - - - - - - - - - - - - - - - { window.wcAdminFeatures[ 'product-variation-management' ] ? ( - - - + ref={ formRef } + errors={ {} } + validate={ validate } + > + + + + + - ) : ( - <> - ) } - - - + + + + + + + + + + { window.wcAdminFeatures[ + 'product-variation-management' + ] ? ( + + + + + ) : ( + <> + ) } + + + { /* @ts-expect-error 'scope' does exist. @types/wordpress__plugins is outdated. */ } + + + ); }; diff --git a/plugins/woocommerce-admin/client/products/sections/images-section.tsx b/plugins/woocommerce-admin/client/products/sections/images-section.tsx deleted file mode 100644 index d52fc39ee20..00000000000 --- a/plugins/woocommerce-admin/client/products/sections/images-section.tsx +++ /dev/null @@ -1,241 +0,0 @@ -/** - * External dependencies - */ -import { __ } from '@wordpress/i18n'; -import { - Link, - useFormContext, - MediaUploader, - ImageGallery, - ImageGalleryItem, -} from '@woocommerce/components'; -import { Card, CardBody, DropZone } from '@wordpress/components'; -import { recordEvent } from '@woocommerce/tracks'; -import { useState } from '@wordpress/element'; -import { Product } from '@woocommerce/data'; -import classnames from 'classnames'; -import { Icon, trash } from '@wordpress/icons'; -import { MediaItem } from '@wordpress/media-utils'; - -/** - * Internal dependencies - */ -import { ProductSectionLayout } from '../layout/product-section-layout'; -import DragAndDrop from '../images/drag-and-drop.svg'; -import './images-section.scss'; - -type Image = MediaItem & { - src: string; -}; - -export const ImagesSection: React.FC = () => { - const { getInputProps, setValue } = useFormContext< Product >(); - const images = ( getInputProps( 'images' ).value as Image[] ) || []; - const [ isRemovingZoneVisible, setIsRemovingZoneVisible ] = - useState< boolean >( false ); - const [ isRemoving, setIsRemoving ] = useState< boolean >( false ); - const [ draggedImageId, setDraggedImageId ] = useState< number | null >( - null - ); - - const toggleRemoveZone = () => { - setIsRemovingZoneVisible( ! isRemovingZoneVisible ); - }; - - const orderImages = ( newOrder: JSX.Element[] ) => { - const orderedImages = newOrder.map( ( image ) => { - return images.find( - ( file ) => file.id === parseInt( image?.props?.id, 10 ) - ); - } ); - recordEvent( 'product_images_change_image_order_via_image_gallery' ); - setValue( 'images', orderedImages ); - }; - const onFileUpload = ( files: MediaItem[] ) => { - if ( files[ 0 ].id ) { - recordEvent( 'product_images_add_via_file_upload_area' ); - setValue( 'images', [ ...images, ...files ] ); - } - }; - - return ( - - - { __( - 'For best results, use JPEG files that are 1000 by 1000 pixels or larger.', - 'woocommerce' - ) } - - { - recordEvent( 'prepare_images_help' ); - } } - > - { __( 'How should I prepare images?', 'woocommerce' ) } - - - } - > - 0, - } ) } - > - - { - const { id: imageId, dataset } = - event.target as HTMLElement; - if ( imageId ) { - setDraggedImageId( parseInt( imageId, 10 ) ); - } else { - const index = dataset?.index; - if ( index ) { - setDraggedImageId( - images[ parseInt( index, 10 ) ]?.id - ); - } - } - toggleRemoveZone(); - } } - onDragEnd={ () => { - if ( isRemoving && draggedImageId ) { - recordEvent( - 'product_images_remove_image_button_click' - ); - setValue( - 'images', - images.filter( - ( img ) => img.id !== draggedImageId - ) - ); - setIsRemoving( false ); - setDraggedImageId( null ); - } - toggleRemoveZone(); - } } - onOrderChange={ orderImages } - onReplace={ ( { replaceIndex, media } ) => { - if ( - images.find( - ( img ) => media.id === img.id - ) === undefined - ) { - images[ replaceIndex ] = media as Image; - recordEvent( - 'product_images_replace_image_button_click' - ); - setValue( 'images', images ); - } - } } - onSelectAsCover={ () => - recordEvent( - 'product_images_select_image_as_cover_button_click' - ) - } - > - { images.map( ( image ) => ( - - ) ) } - -
    - { isRemovingZoneVisible ? ( - -
    - - - { __( - 'Drop here to remove', - 'woocommerce' - ) } - - - setIsRemoving( true ) - } - onDrop={ () => setIsRemoving( true ) } - label={ __( - 'Drop here to remove', - 'woocommerce' - ) } - /> -
    -
    - ) : ( - - null } - onFileUploadChange={ onFileUpload } - onSelect={ ( files ) => { - const newImages = files.filter( - ( img: Image ) => - ! images.find( - ( image ) => - image.id === img.id - ) - ); - if ( newImages.length > 0 ) { - recordEvent( - 'product_images_add_via_media_library' - ); - setValue( 'images', [ - ...images, - ...newImages, - ] ); - } - } } - onUpload={ ( files ) => { - if ( files[ 0 ].id ) { - recordEvent( - 'product_images_add_via_drag_and_drop_upload' - ); - setValue( 'images', [ - ...images, - ...files, - ] ); - } - } } - label={ - <> - { - - { __( - 'Drag images here or click to upload', - 'woocommerce' - ) } - - - } - /> - - ) } -
    -
    -
    -
    - ); -}; diff --git a/plugins/woocommerce-admin/client/products/sections/product-details-section.tsx b/plugins/woocommerce-admin/client/products/sections/product-details-section.tsx deleted file mode 100644 index db2abe0848f..00000000000 --- a/plugins/woocommerce-admin/client/products/sections/product-details-section.tsx +++ /dev/null @@ -1,250 +0,0 @@ -/** - * External dependencies - */ -import { - CheckboxControl, - Button, - TextControl, - Card, - CardBody, -} from '@wordpress/components'; -import { useSelect } from '@wordpress/data'; -import { __ } from '@wordpress/i18n'; -import { useState } from '@wordpress/element'; -import { cleanForSlug } from '@wordpress/url'; -import { - Link, - useFormContext, - __experimentalRichTextEditor as RichTextEditor, - __experimentalTooltip as Tooltip, - __experimentalWooProductFieldItem as WooProductFieldItem, -} from '@woocommerce/components'; -import interpolateComponents from '@automattic/interpolate-components'; -import { - Product, - ProductCategory, - PRODUCTS_STORE_NAME, - WCDataSelector, -} from '@woocommerce/data'; -import { recordEvent } from '@woocommerce/tracks'; -import { BlockInstance, serialize, parse } from '@wordpress/blocks'; - -/** - * Internal dependencies - */ -import './product-details-section.scss'; -import { CategoryField } from '../fields/category-field'; -import { EditProductLinkModal } from '../shared/edit-product-link-modal'; -import { getCheckboxTracks } from './utils'; -import { ProductSectionLayout } from '../layout/product-section-layout'; - -const PRODUCT_DETAILS_SLUG = 'product-details'; - -export const ProductDetailsSection: React.FC = () => { - const { - getCheckboxControlProps, - getInputProps, - values, - touched, - errors, - setValue, - } = useFormContext< Product >(); - const [ showProductLinkEditModal, setShowProductLinkEditModal ] = - useState( false ); - const [ descriptionBlocks, setDescriptionBlocks ] = useState< - BlockInstance[] - >( parse( values.description || '' ) ); - const [ summaryBlocks, setSummaryBlocks ] = useState< BlockInstance[] >( - parse( values.short_description || '' ) - ); - const { permalinkPrefix, permalinkSuffix } = useSelect( - ( select: WCDataSelector ) => { - const { getPermalinkParts } = select( PRODUCTS_STORE_NAME ); - if ( values.id ) { - const parts = getPermalinkParts( values.id ); - return { - permalinkPrefix: parts?.prefix, - permalinkSuffix: parts?.suffix, - }; - } - return {}; - } - ); - - const hasNameError = () => { - return Boolean( touched.name ) && Boolean( errors.name ); - }; - - const setSkuIfEmpty = () => { - if ( values.sku || ! values.name?.length ) { - return; - } - setValue( 'sku', cleanForSlug( values.name ) ); - }; - - return ( - - - -
    - - { __( - '(required)', - 'woocommerce' - ) } - - ), - }, - } ) } - name={ `${ PRODUCT_DETAILS_SLUG }-name` } - placeholder={ __( - 'e.g. 12 oz Coffee Mug', - 'woocommerce' - ) } - { ...getInputProps( 'name', { - onBlur: setSkuIfEmpty, - } ) } - /> - { values.id && ! hasNameError() && permalinkPrefix && ( - - { __( 'Product link', 'woocommerce' ) } - :  - - { permalinkPrefix } - { values.slug || - cleanForSlug( values.name ) } - { permalinkSuffix } - - - - ) } -
    - [] - >( 'categories' ) } - /> - - { __( 'Feature this product', 'woocommerce' ) } - - recordEvent( - 'add_product_learn_more', - { - category: - PRODUCT_DETAILS_SLUG, - } - ) - } - > - { __( - 'Learn more', - 'woocommerce' - ) } - - ), - }, - } ) } - /> - - } - { ...getCheckboxControlProps( - 'featured', - getCheckboxTracks( 'featured' ) - ) } - /> - { showProductLinkEditModal && ( - - setShowProductLinkEditModal( false ) - } - onSaved={ () => - setShowProductLinkEditModal( false ) - } - /> - ) } - { - setSummaryBlocks( blocks ); - if ( ! summaryBlocks.length ) { - return; - } - setValue( - 'short_description', - serialize( blocks ) - ); - } } - placeholder={ __( - "Summarize this product in 1-2 short sentences. We'll show it at the top of the page.", - 'woocommerce' - ) } - /> - { - setDescriptionBlocks( blocks ); - if ( ! descriptionBlocks.length ) { - return; - } - setValue( 'description', serialize( blocks ) ); - } } - placeholder={ __( - 'Describe this product. What makes it unique? What are its most important features?', - 'woocommerce' - ) } - /> - -
    -
    -
    - ); -}; diff --git a/plugins/woocommerce-admin/client/products/sections/test/product-details-section.spec.tsx b/plugins/woocommerce-admin/client/products/sections/test/product-details-section.spec.tsx deleted file mode 100644 index fed5d6a532a..00000000000 --- a/plugins/woocommerce-admin/client/products/sections/test/product-details-section.spec.tsx +++ /dev/null @@ -1,89 +0,0 @@ -/** - * External dependencies - */ -import { createRegistry, RegistryProvider, useSelect } from '@wordpress/data'; -import { Form } from '@woocommerce/components'; -import { Product } from '@woocommerce/data'; -import { render, screen } from '@testing-library/react'; -// eslint-disable-next-line @typescript-eslint/ban-ts-comment -// @ts-ignore No types for this exist yet. -// eslint-disable-next-line @woocommerce/dependency-group -import { store as blockEditorStore } from '@wordpress/block-editor'; -// eslint-disable-next-line @typescript-eslint/ban-ts-comment -// @ts-ignore No types for this exist yet. -// eslint-disable-next-line @woocommerce/dependency-group -import { store as coreDataStore } from '@wordpress/core-data'; -// eslint-disable-next-line @woocommerce/dependency-group -import userEvent from '@testing-library/user-event'; - -/** - * Internal dependencies - */ -import { ProductDetailsSection } from '../product-details-section'; -import { validate } from '../../product-validation'; - -jest.mock( '@woocommerce/tracks', () => ( { recordEvent: jest.fn() } ) ); -jest.mock( '@wordpress/data', () => ( { - ...jest.requireActual( '@wordpress/data' ), - useSelect: jest.fn(), -} ) ); - -const registry = createRegistry(); -// eslint-disable-next-line @typescript-eslint/ban-ts-comment -// @ts-ignore No types for this exist yet. -registry.register( coreDataStore ); -// eslint-disable-next-line @typescript-eslint/ban-ts-comment -// @ts-ignore No types for this exist yet. -registry.register( blockEditorStore ); - -describe( 'ProductDetailsSection', () => { - const useSelectMock = useSelect as jest.Mock; - - beforeEach( () => { - jest.clearAllMocks(); - } ); - - describe( 'when editing a product', () => { - const product: Partial< Product > = { - id: 1, - name: 'Lorem', - slug: 'lorem', - }; - const permalinkPrefix = 'http://localhost/'; - const linkUrl = permalinkPrefix + product.slug; - - beforeEach( () => { - useSelectMock.mockReturnValue( { - permalinkPrefix, - } ); - } ); - - it( 'should render the product link', () => { - render( - -
    - - -
    - ); - - expect( screen.queryByText( linkUrl ) ).toBeInTheDocument(); - } ); - - it( 'should hide the product link if field name has errors', () => { - render( - -
    - - -
    - ); - userEvent.clear( - screen.getByLabelText( 'Name', { exact: false } ) - ); - userEvent.tab(); - - expect( screen.queryByText( linkUrl ) ).not.toBeInTheDocument(); - } ); - } ); -} ); diff --git a/plugins/woocommerce-admin/client/products/tour/index.tsx b/plugins/woocommerce-admin/client/products/tour/index.tsx new file mode 100644 index 00000000000..4aaf84f546c --- /dev/null +++ b/plugins/woocommerce-admin/client/products/tour/index.tsx @@ -0,0 +1 @@ +export * from './product-tour-container'; diff --git a/plugins/woocommerce-admin/client/products/tour/product-tour-container.tsx b/plugins/woocommerce-admin/client/products/tour/product-tour-container.tsx new file mode 100644 index 00000000000..86bec8ccbc8 --- /dev/null +++ b/plugins/woocommerce-admin/client/products/tour/product-tour-container.tsx @@ -0,0 +1,21 @@ +/** + * Internal dependencies + */ +import { ProductTour } from './product-tour'; +import { ProductTourModal } from './product-tour-modal'; +import { useProductTour } from './use-product-tour'; + +export const ProductTourContainer: React.FC = () => { + const { dismissModal, endTour, isModalHidden, isTouring, startTour } = + useProductTour(); + + if ( isTouring ) { + return ; + } + + if ( isModalHidden ) { + return null; + } + + return ; +}; diff --git a/plugins/woocommerce-admin/client/products/tour/product-tour-modal.scss b/plugins/woocommerce-admin/client/products/tour/product-tour-modal.scss new file mode 100644 index 00000000000..c515f50e2f4 --- /dev/null +++ b/plugins/woocommerce-admin/client/products/tour/product-tour-modal.scss @@ -0,0 +1,76 @@ +.woocommerce-product-tour-modal { + max-width: 400px; + position: fixed; + left: $admin-menu-width + $gap-large; + bottom: $gap-large; + // This puts the modal on top of the RichTextEditor toolbars. + z-index: 31; + + @include breakpoint( '<960px' ) { + left: $admin-menu-width-collapsed + $gap-large; + } + + @include breakpoint( '<782px' ) { + display: none; + } + + .components-modal__content { + display: flex; + flex-direction: column; + margin: 0; + padding: 0; + } + + .components-modal__header { + position: static; + padding-top: $gap; + padding-left: $gap; + padding-right: $gap; + height: auto; + + .components-button { + position: absolute; + top: $gap; + right: $gap; + left: auto; + } + + .components-modal__header-heading { + font-size: 16px; + line-height: 24px; + } + } + + .woocommerce-product-tour-modal__header-img { + background: #c5d9ed; + order: -1; + padding: 28px 28px 0 28px; + + img { + max-width: 286px; + display: block; + margin: 0 auto; + } + } + + .woocommerce-product-tour-modal__content { + padding: $gap-smaller $gap $gap $gap; + + > p:first-child { + margin-top: 0; + } + } + + .woocommerce-product-tour-modal__actions { + text-align: right; + margin-top: 28px; + + button { + margin-left: $gap-smaller; + } + } +} + +.woocommerce-product-tour-modal__overlay { + position: static; +} diff --git a/plugins/woocommerce-admin/client/products/tour/product-tour-modal.tsx b/plugins/woocommerce-admin/client/products/tour/product-tour-modal.tsx new file mode 100644 index 00000000000..95a60b2997a --- /dev/null +++ b/plugins/woocommerce-admin/client/products/tour/product-tour-modal.tsx @@ -0,0 +1,60 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +import { Button, Modal } from '@wordpress/components'; + +/** + * Internal dependencies + */ +import ProductTourImage from './product-tour.png'; +import './product-tour-modal.scss'; + +type ProductTourModalProps = { + onClose: () => void; + onStart: () => void; +}; + +export const ProductTourModal: React.FC< ProductTourModalProps > = ( { + onClose, + onStart, +} ) => { + return ( + onClose() } + overlayClassName="woocommerce-product-tour-modal__overlay" + shouldCloseOnClickOutside={ false } + title={ __( 'Meet the product editing form', 'woocommerce' ) } + > +
    + { +
    +
    +

    + { __( + 'Let us show you how to navigate the form and create this product from start to finish in no time.', + 'woocommerce' + ) } +

    +
    + + +
    +
    +
    + ); +}; diff --git a/plugins/woocommerce-admin/client/products/tour/product-tour.png b/plugins/woocommerce-admin/client/products/tour/product-tour.png new file mode 100644 index 00000000000..858df4b9322 Binary files /dev/null and b/plugins/woocommerce-admin/client/products/tour/product-tour.png differ diff --git a/plugins/woocommerce-admin/client/products/tour/product-tour.tsx b/plugins/woocommerce-admin/client/products/tour/product-tour.tsx new file mode 100644 index 00000000000..3d6d272acc6 --- /dev/null +++ b/plugins/woocommerce-admin/client/products/tour/product-tour.tsx @@ -0,0 +1,96 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +import { TourKit, TourKitTypes } from '@woocommerce/components'; + +type ProductTourProps = { + onClose: () => void; +}; + +export const ProductTour: React.FC< ProductTourProps > = ( { onClose } ) => { + const tourConfig: TourKitTypes.WooConfig = { + placement: 'auto', + options: { + effects: { + spotlight: { + interactivity: { + enabled: false, + }, + }, + liveResize: { + mutation: true, + resize: true, + }, + }, + }, + steps: [ + { + referenceElements: { + desktop: `.woocommerce-product-form-tab__general .woocommerce-form-section__content`, + }, + meta: { + name: 'story', + heading: __( + '📣 Tell a story about your product', + 'woocommerce' + ), + descriptions: { + desktop: __( + 'The product form will help you describe your product field by field—from basic details like name and description to attributes the customers can use to find it on your store.', + 'woocommerce' + ), + }, + }, + }, + { + referenceElements: { + desktop: `#tab-panel-0-pricing`, + }, + meta: { + name: 'tabs', + heading: __( '✍️ Set up pricing & more', 'woocommerce' ), + descriptions: { + desktop: __( + 'When done, use the tabs to switch between other details and settings. In the future, you’ll also find here extensions and plugins.', + 'woocommerce' + ), + }, + }, + }, + { + referenceElements: { + desktop: `.woocommerce-product-form-actions`, + }, + meta: { + name: 'actions', + heading: __( '🔍 Preview and publish', 'woocommerce' ), + descriptions: { + desktop: __( + 'With all the details in place, use the buttons at the top to easily preview and publish your product. Click the arrow button for more options.', + 'woocommerce' + ), + }, + }, + }, + { + referenceElements: { + desktop: `.woocommerce-product-form-more-menu`, + }, + meta: { + name: 'more', + heading: __( '⚙️ Looking for more?', 'woocommerce' ), + descriptions: { + desktop: __( + 'If the form doesn’t yet have all the feautures you need—it’s still in development—you can switch to the classic editor anytime.', + 'woocommerce' + ), + }, + }, + }, + ], + closeHandler: onClose, + }; + + return ; +}; diff --git a/plugins/woocommerce-admin/client/products/tour/test/product-tour-container.spec.tsx b/plugins/woocommerce-admin/client/products/tour/test/product-tour-container.spec.tsx new file mode 100644 index 00000000000..17d236ae1fe --- /dev/null +++ b/plugins/woocommerce-admin/client/products/tour/test/product-tour-container.spec.tsx @@ -0,0 +1,94 @@ +/** + * External dependencies + */ +import userEvent from '@testing-library/user-event'; +import { render } from '@testing-library/react'; + +/** + * Internal dependencies + */ +import { ProductTourContainer } from '../'; +import { useProductTour } from '../use-product-tour'; + +const dismissModal = jest.fn(); +const endTour = jest.fn(); +const startTour = jest.fn(); + +const defaultValues = { + dismissModal, + endTour, + isModalHidden: false, + isTouring: false, + startTour, +}; + +jest.mock( '../use-product-tour', () => { + return { + useProductTour: jest.fn(), + }; +} ); + +const mockedUseProductTour = useProductTour as jest.Mock; + +describe( 'ProductTourContainer', () => { + it( 'should render the modal initially if not already hidden', () => { + mockedUseProductTour.mockImplementation( () => defaultValues ); + const { getByText } = render( ); + expect( + getByText( 'Meet the product editing form' ) + ).toBeInTheDocument(); + } ); + + it( 'should not render the modal when the tour has already started', () => { + mockedUseProductTour.mockImplementation( () => ( { + ...defaultValues, + isTouring: true, + } ) ); + const { queryByText } = render( ); + expect( + queryByText( 'Meet the product editing form' ) + ).not.toBeInTheDocument(); + expect( + queryByText( 'Tell a story about your product' ) + ).not.toBeInTheDocument(); + } ); + + it( 'should call startTour after clicking the button to begin the tour', () => { + mockedUseProductTour.mockImplementation( () => defaultValues ); + const { getByText } = render( ); + userEvent.click( getByText( 'Show me around (10s)' ) ); + expect( startTour ).toBeCalled(); + } ); + + it( 'should call dismissModal after closing the modal', () => { + mockedUseProductTour.mockImplementation( () => defaultValues ); + const { getByText } = render( ); + userEvent.click( getByText( "I'll explore on my own" ) ); + expect( dismissModal ).toBeCalled(); + } ); + + it( 'should call endTour after closing the tour', () => { + mockedUseProductTour.mockImplementation( () => ( { + ...defaultValues, + isTouring: true, + } ) ); + const { getByLabelText } = render( ); + userEvent.click( getByLabelText( 'Close Tour' ) ); + expect( endTour ).toBeCalled(); + } ); + + it( 'should not show tour or modal once tour is complete', () => { + mockedUseProductTour.mockImplementation( () => ( { + ...defaultValues, + isTouring: false, + isModalHidden: true, + } ) ); + const { queryByText } = render( ); + expect( + queryByText( 'Meet the product editing form' ) + ).not.toBeInTheDocument(); + expect( + queryByText( 'Tell a story about your product' ) + ).not.toBeInTheDocument(); + } ); +} ); diff --git a/plugins/woocommerce-admin/client/products/tour/test/use-product-tour.spec.tsx b/plugins/woocommerce-admin/client/products/tour/test/use-product-tour.spec.tsx new file mode 100644 index 00000000000..6fc011e3f01 --- /dev/null +++ b/plugins/woocommerce-admin/client/products/tour/test/use-product-tour.spec.tsx @@ -0,0 +1,101 @@ +/** + * External dependencies + */ +import { act, renderHook } from '@testing-library/react-hooks'; +import { useDispatch, useSelect } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import { PRODUCT_TOUR_MODAL_HIDDEN, useProductTour } from '../use-product-tour'; + +jest.mock( '@wordpress/data', () => { + // Require the original module to not be mocked... + const originalModule = jest.requireActual( '@wordpress/data' ); + + return { + __esModule: true, // Use it when dealing with esModules + ...originalModule, + useDispatch: jest.fn().mockReturnValue( {} ), + useSelect: jest.fn().mockReturnValue( {} ), + }; +} ); + +const mockedUseDispatch = useDispatch as jest.Mock; +const mockedUseSelect = useSelect as jest.Mock; + +describe( 'useProductTour', () => { + it( 'should initially set the tour to hidden', () => { + const { result } = renderHook( () => useProductTour() ); + expect( result.current.isTouring ).toBeFalsy(); + } ); + + it( 'should update the tour state when starting the tour', () => { + const updateOptions = jest.fn(); + mockedUseDispatch.mockImplementation( () => ( { + updateOptions, + } ) ); + const { result } = renderHook( () => useProductTour() ); + act( () => { + result.current.startTour(); + } ); + expect( result.current.isTouring ).toBeTruthy(); + } ); + + it( 'should dismiss the modal when starting the tour', () => { + const updateOptions = jest.fn(); + mockedUseDispatch.mockImplementation( () => ( { + updateOptions, + } ) ); + const { result } = renderHook( () => useProductTour() ); + + act( () => { + result.current.startTour(); + } ); + + expect( updateOptions ).toHaveBeenCalledWith( { + [ PRODUCT_TOUR_MODAL_HIDDEN ]: 'yes', + } ); + } ); + + it( 'should update the tour state when ending the tour', () => { + const { result } = renderHook( () => useProductTour() ); + act( () => { + result.current.startTour(); + result.current.endTour(); + } ); + expect( result.current.isTouring ).toBeFalsy(); + } ); + + it( 'should return true when the modal is hidden', () => { + mockedUseSelect.mockImplementation( () => ( { + isModalHidden: true, + } ) ); + const { result } = renderHook( () => useProductTour() ); + expect( result.current.isModalHidden ).toBeTruthy(); + } ); + + it( 'should return false when the modal is hidden', () => { + mockedUseSelect.mockImplementation( () => ( { + isModalHidden: false, + } ) ); + const { result } = renderHook( () => useProductTour() ); + expect( result.current.isModalHidden ).toBeFalsy(); + } ); + + it( 'should dismiss the modal when manually called', () => { + const updateOptions = jest.fn(); + mockedUseDispatch.mockImplementation( () => ( { + updateOptions, + } ) ); + const { result } = renderHook( () => useProductTour() ); + + act( () => { + result.current.dismissModal(); + } ); + + expect( updateOptions ).toHaveBeenCalledWith( { + [ PRODUCT_TOUR_MODAL_HIDDEN ]: 'yes', + } ); + } ); +} ); diff --git a/plugins/woocommerce-admin/client/products/tour/use-product-tour.ts b/plugins/woocommerce-admin/client/products/tour/use-product-tour.ts new file mode 100644 index 00000000000..c8bd7dbec3b --- /dev/null +++ b/plugins/woocommerce-admin/client/products/tour/use-product-tour.ts @@ -0,0 +1,50 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +import { OPTIONS_STORE_NAME } from '@woocommerce/data'; +import { useSelect, useDispatch } from '@wordpress/data'; +import { useState } from '@wordpress/element'; + +export const PRODUCT_TOUR_MODAL_HIDDEN = + 'woocommerce_product_tour_modal_hidden'; + +export const useProductTour = () => { + const [ isTouring, setIsTouring ] = useState( false ); + const { updateOptions } = useDispatch( OPTIONS_STORE_NAME ); + const { isModalHidden } = useSelect( ( select ) => { + const { getOption, hasFinishedResolution } = + select( OPTIONS_STORE_NAME ); + + return { + isModalHidden: + getOption( PRODUCT_TOUR_MODAL_HIDDEN ) === 'yes' || + ! hasFinishedResolution( 'getOption', [ + PRODUCT_TOUR_MODAL_HIDDEN, + ] ), + }; + } ); + + const dismissModal = () => { + updateOptions( { + [ PRODUCT_TOUR_MODAL_HIDDEN ]: 'yes', + } ); + }; + + const endTour = () => { + setIsTouring( false ); + }; + + const startTour = () => { + dismissModal(); + setIsTouring( true ); + }; + + return { + dismissModal, + endTour, + isModalHidden, + isTouring, + startTour, + }; +}; diff --git a/plugins/woocommerce-admin/client/task-lists/progress-header/default-progress-header.tsx b/plugins/woocommerce-admin/client/task-lists/progress-header/default-progress-header.tsx new file mode 100644 index 00000000000..1980bdc4d55 --- /dev/null +++ b/plugins/woocommerce-admin/client/task-lists/progress-header/default-progress-header.tsx @@ -0,0 +1,72 @@ +/** + * External dependencies + */ +import { __, sprintf } from '@wordpress/i18n'; +import { useSelect } from '@wordpress/data'; +import { getVisibleTasks, ONBOARDING_STORE_NAME } from '@woocommerce/data'; + +/** + * Internal dependencies + */ +import './progress-header.scss'; +import { TaskListMenu } from '~/tasks/task-list-menu'; + +export type DefaultProgressHeaderProps = { + taskListId: string; +}; + +export const DefaultProgressHeader: React.FC< DefaultProgressHeaderProps > = ( { + taskListId, +} ) => { + const { loading, tasksCount, completedCount } = useSelect( ( select ) => { + const taskList = select( ONBOARDING_STORE_NAME ).getTaskList( + taskListId + ); + const finishedResolution = select( + ONBOARDING_STORE_NAME + ).hasFinishedResolution( 'getTaskList', [ taskListId ] ); + const visibleTasks = getVisibleTasks( taskList?.tasks || [] ); + + return { + loading: ! finishedResolution, + tasksCount: visibleTasks?.length, + completedCount: visibleTasks?.filter( ( task ) => task.isComplete ) + .length, + }; + } ); + + if ( loading ) { + return null; + } + + return ( +
    + +
    + { completedCount !== tasksCount ? ( + <> +

    + { sprintf( + /* translators: 1: completed tasks, 2: total tasks */ + __( + 'Follow these steps to start selling quickly. %1$d out of %2$d complete.', + 'woocommerce' + ), + completedCount, + tasksCount + ) } +

    + + + ) : null } +
    +
    + ); +}; diff --git a/plugins/woocommerce-admin/client/task-lists/progress-header/progress-header.tsx b/plugins/woocommerce-admin/client/task-lists/progress-header/progress-header.tsx index e68a7e240ea..e7fadef3fcb 100644 --- a/plugins/woocommerce-admin/client/task-lists/progress-header/progress-header.tsx +++ b/plugins/woocommerce-admin/client/task-lists/progress-header/progress-header.tsx @@ -1,72 +1,29 @@ /** * External dependencies */ -import { __, sprintf } from '@wordpress/i18n'; -import { useSelect } from '@wordpress/data'; -import { getVisibleTasks, ONBOARDING_STORE_NAME } from '@woocommerce/data'; +import { useSlot } from '@woocommerce/experimental'; /** * Internal dependencies */ import './progress-header.scss'; -import { TaskListMenu } from '~/tasks/task-list-menu'; +import { + WC_TASKLIST_EXPERIMENTAL_PROGRESS_HEADER_SLOT_NAME, + WooTaskListProgressHeaderItem, +} from './utils'; +import { + DefaultProgressHeader, + DefaultProgressHeaderProps, +} from './default-progress-header'; -type ProgressHeaderProps = { - taskListId: string; -}; - -export const ProgressHeader: React.FC< ProgressHeaderProps > = ( { +export const ProgressHeader: React.FC< DefaultProgressHeaderProps > = ( { taskListId, } ) => { - const { loading, tasksCount, completedCount } = useSelect( ( select ) => { - const taskList = select( ONBOARDING_STORE_NAME ).getTaskList( - taskListId - ); - const finishedResolution = select( - ONBOARDING_STORE_NAME - ).hasFinishedResolution( 'getTaskList', [ taskListId ] ); - const visibleTasks = getVisibleTasks( taskList?.tasks || [] ); + const slot = useSlot( WC_TASKLIST_EXPERIMENTAL_PROGRESS_HEADER_SLOT_NAME ); - return { - loading: ! finishedResolution, - tasksCount: visibleTasks?.length, - completedCount: visibleTasks?.filter( ( task ) => task.isComplete ) - .length, - }; - } ); - - if ( loading ) { - return null; - } - - return ( -
    - -
    - { completedCount !== tasksCount ? ( - <> -

    - { sprintf( - /* translators: 1: completed tasks, 2: total tasks */ - __( - 'Follow these steps to start selling quickly. %1$d out of %2$d complete.', - 'woocommerce' - ), - completedCount, - tasksCount - ) } -

    - - - ) : null } -
    -
    + return Boolean( slot?.fills?.length ) ? ( + + ) : ( + ); }; diff --git a/plugins/woocommerce-admin/client/task-lists/progress-header/utils.tsx b/plugins/woocommerce-admin/client/task-lists/progress-header/utils.tsx new file mode 100644 index 00000000000..d0f0fc42002 --- /dev/null +++ b/plugins/woocommerce-admin/client/task-lists/progress-header/utils.tsx @@ -0,0 +1,38 @@ +/* eslint-disable no-console */ +/** + * External dependencies + */ +import { Slot, Fill } from '@wordpress/components'; + +/** + * Internal dependencies + */ +import { createOrderedChildren, sortFillsByOrder } from '../../utils'; + +export const WC_TASKLIST_EXPERIMENTAL_PROGRESS_HEADER_SLOT_NAME = + 'woocommerce_tasklist_experimental_progress_header_item'; + +export const WooTaskListProgressHeaderItem: React.FC< { + order?: number; +} > & { + Slot: React.FC< Slot.Props >; +} = ( { children, order = 1 } ) => { + return ( + + { ( fillProps: Fill.Props ) => { + return createOrderedChildren( children, order, fillProps ); + } } + + ); +}; + +WooTaskListProgressHeaderItem.Slot = ( { fillProps } ) => { + return ( + + { sortFillsByOrder } + + ); +}; diff --git a/plugins/woocommerce-admin/client/task-lists/progress-title/default-progress-title.tsx b/plugins/woocommerce-admin/client/task-lists/progress-title/default-progress-title.tsx new file mode 100644 index 00000000000..6d3b0b06c33 --- /dev/null +++ b/plugins/woocommerce-admin/client/task-lists/progress-title/default-progress-title.tsx @@ -0,0 +1,69 @@ +/** + * External dependencies + */ +import { __, sprintf } from '@wordpress/i18n'; +import { useMemo } from '@wordpress/element'; +import { useSelect } from '@wordpress/data'; +import { getVisibleTasks, ONBOARDING_STORE_NAME } from '@woocommerce/data'; +import { getSetting } from '@woocommerce/settings'; + +export type DefaultProgressTitleProps = { + taskListId: string; +}; + +export const DefaultProgressTitle: React.FC< DefaultProgressTitleProps > = ( { + taskListId, +} ) => { + const { loading, tasksCount, completedCount, hasVisitedTasks } = useSelect( + ( select ) => { + const taskList = select( ONBOARDING_STORE_NAME ).getTaskList( + taskListId + ); + const finishedResolution = select( + ONBOARDING_STORE_NAME + ).hasFinishedResolution( 'getTaskList', [ taskListId ] ); + const visibleTasks = getVisibleTasks( taskList?.tasks || [] ); + + return { + loading: ! finishedResolution, + tasksCount: visibleTasks?.length, + completedCount: visibleTasks?.filter( + ( task ) => task.isComplete + ).length, + hasVisitedTasks: + visibleTasks?.filter( + ( task ) => + task.isVisited && task.id !== 'store_details' + ).length > 0, + }; + } + ); + + const title = useMemo( () => { + if ( ! hasVisitedTasks || completedCount === tasksCount ) { + const siteTitle = getSetting( 'siteTitle' ); + return siteTitle + ? sprintf( + /* translators: %s = site title */ + __( 'Welcome to %s', 'woocommerce' ), + siteTitle + ) + : __( 'Welcome to your store', 'woocommerce' ); + } + if ( completedCount > 0 && completedCount < 4 ) { + return __( "Let's get you started", 'woocommerce' ) + ' 🚀'; + } + if ( completedCount > 3 && completedCount < 6 ) { + return __( 'You are on the right track', 'woocommerce' ); + } + return __( 'You are almost there', 'woocommerce' ); + }, [ completedCount, hasVisitedTasks, tasksCount ] ); + + if ( loading ) { + return null; + } + + return ( +

    { title }

    + ); +}; diff --git a/plugins/woocommerce-admin/client/task-lists/progress-title/progress-title.tsx b/plugins/woocommerce-admin/client/task-lists/progress-title/progress-title.tsx index bad9271c4fc..96d4588ed1d 100644 --- a/plugins/woocommerce-admin/client/task-lists/progress-title/progress-title.tsx +++ b/plugins/woocommerce-admin/client/task-lists/progress-title/progress-title.tsx @@ -1,69 +1,29 @@ /** * External dependencies */ -import { __, sprintf } from '@wordpress/i18n'; -import { useMemo } from '@wordpress/element'; -import { useSelect } from '@wordpress/data'; -import { getVisibleTasks, ONBOARDING_STORE_NAME } from '@woocommerce/data'; -import { getSetting } from '@woocommerce/settings'; +import { useSlot } from '@woocommerce/experimental'; -type ProgressTitleProps = { - taskListId: string; -}; +/** + * Internal dependencies + */ +import { + WC_TASKLIST_EXPERIMENTAL_PROGRESS_TITLE_SLOT_NAME, + WooTaskListProgressTitleItem, +} from './utils'; -export const ProgressTitle: React.FC< ProgressTitleProps > = ( { +import { + DefaultProgressTitle, + DefaultProgressTitleProps, +} from './default-progress-title'; + +export const ProgressTitle: React.FC< DefaultProgressTitleProps > = ( { taskListId, } ) => { - const { loading, tasksCount, completedCount, hasVisitedTasks } = useSelect( - ( select ) => { - const taskList = select( ONBOARDING_STORE_NAME ).getTaskList( - taskListId - ); - const finishedResolution = select( - ONBOARDING_STORE_NAME - ).hasFinishedResolution( 'getTaskList', [ taskListId ] ); - const visibleTasks = getVisibleTasks( taskList?.tasks || [] ); + const slot = useSlot( WC_TASKLIST_EXPERIMENTAL_PROGRESS_TITLE_SLOT_NAME ); - return { - loading: ! finishedResolution, - tasksCount: visibleTasks?.length, - completedCount: visibleTasks?.filter( - ( task ) => task.isComplete - ).length, - hasVisitedTasks: - visibleTasks?.filter( - ( task ) => - task.isVisited && task.id !== 'store_details' - ).length > 0, - }; - } - ); - - const title = useMemo( () => { - if ( ! hasVisitedTasks || completedCount === tasksCount ) { - const siteTitle = getSetting( 'siteTitle' ); - return siteTitle - ? sprintf( - /* translators: %s = site title */ - __( 'Welcome to %s', 'woocommerce' ), - siteTitle - ) - : __( 'Welcome to your store', 'woocommerce' ); - } - if ( completedCount > 0 && completedCount < 4 ) { - return __( "Let's get you started", 'woocommerce' ) + ' 🚀'; - } - if ( completedCount > 3 && completedCount < 6 ) { - return __( 'You are on the right track', 'woocommerce' ); - } - return __( 'You are almost there', 'woocommerce' ); - }, [ completedCount, hasVisitedTasks, tasksCount ] ); - - if ( loading ) { - return null; - } - - return ( -

    { title }

    + return Boolean( slot?.fills?.length ) ? ( + + ) : ( + ); }; diff --git a/plugins/woocommerce-admin/client/task-lists/progress-title/utils.tsx b/plugins/woocommerce-admin/client/task-lists/progress-title/utils.tsx new file mode 100644 index 00000000000..e06002eafa6 --- /dev/null +++ b/plugins/woocommerce-admin/client/task-lists/progress-title/utils.tsx @@ -0,0 +1,38 @@ +/* eslint-disable no-console */ +/** + * External dependencies + */ +import { Slot, Fill } from '@wordpress/components'; + +/** + * Internal dependencies + */ +import { createOrderedChildren, sortFillsByOrder } from '../../utils'; + +export const WC_TASKLIST_EXPERIMENTAL_PROGRESS_TITLE_SLOT_NAME = + 'woocommerce_tasklist_experimental_progress_title_item'; + +export const WooTaskListProgressTitleItem: React.FC< { + order?: number; +} > & { + Slot: React.FC< Slot.Props >; +} = ( { children, order = 1 } ) => { + return ( + + { ( fillProps: Fill.Props ) => { + return createOrderedChildren( children, order, fillProps ); + } } + + ); +}; + +WooTaskListProgressTitleItem.Slot = ( { fillProps } ) => { + return ( + + { sortFillsByOrder } + + ); +}; diff --git a/plugins/woocommerce-admin/client/tasks/fills/tax/index.tsx b/plugins/woocommerce-admin/client/tasks/fills/tax/index.tsx index b1b86ea9c7c..b42d2d6bf08 100644 --- a/plugins/woocommerce-admin/client/tasks/fills/tax/index.tsx +++ b/plugins/woocommerce-admin/client/tasks/fills/tax/index.tsx @@ -92,20 +92,34 @@ const Tax: React.FC< TaxProps > = ( { onComplete, query, task } ) => { } }, [] ); - const onAutomate = useCallback( () => { + const onAutomate = useCallback( async () => { setIsPending( true ); - updateAndPersistSettingsForGroup( 'tax', { - tax: { - ...taxSettings, - wc_connect_taxes_enabled: 'yes', - }, - } ); - updateAndPersistSettingsForGroup( 'general', { - general: { - ...generalSettings, - woocommerce_calc_taxes: 'yes', - }, - } ); + try { + await Promise.all( [ + updateAndPersistSettingsForGroup( 'tax', { + tax: { + ...taxSettings, + wc_connect_taxes_enabled: 'yes', + }, + } ), + updateAndPersistSettingsForGroup( 'general', { + general: { + ...generalSettings, + woocommerce_calc_taxes: 'yes', + }, + } ), + ] ); + } catch ( error: unknown ) { + setIsPending( false ); + createNotice( + 'error', + __( + 'There was a problem setting up automated taxes. Please try again.', + 'woocommerce' + ) + ); + return; + } createNotice( 'success', diff --git a/plugins/woocommerce/bin/composer/mozart/composer.lock b/plugins/woocommerce/bin/composer/mozart/composer.lock index 5291be463e6..1554fb40d8b 100644 --- a/plugins/woocommerce/bin/composer/mozart/composer.lock +++ b/plugins/woocommerce/bin/composer/mozart/composer.lock @@ -1169,5 +1169,5 @@ "platform-overrides": { "php": "7.3" }, - "plugin-api-version": "2.0.0" + "plugin-api-version": "2.3.0" } diff --git a/plugins/woocommerce/bin/composer/phpcs/composer.lock b/plugins/woocommerce/bin/composer/phpcs/composer.lock index 29bcf29ff1d..0e1a6def19c 100644 --- a/plugins/woocommerce/bin/composer/phpcs/composer.lock +++ b/plugins/woocommerce/bin/composer/phpcs/composer.lock @@ -475,5 +475,5 @@ "platform-overrides": { "php": "7.2" }, - "plugin-api-version": "2.0.0" + "plugin-api-version": "2.3.0" } diff --git a/plugins/woocommerce/bin/composer/phpunit/composer.lock b/plugins/woocommerce/bin/composer/phpunit/composer.lock index c80d9739bc5..1983c0a3a0b 100644 --- a/plugins/woocommerce/bin/composer/phpunit/composer.lock +++ b/plugins/woocommerce/bin/composer/phpunit/composer.lock @@ -1697,5 +1697,5 @@ "platform-overrides": { "php": "7.0" }, - "plugin-api-version": "2.0.0" + "plugin-api-version": "2.3.0" } diff --git a/plugins/woocommerce/bin/composer/wp/composer.lock b/plugins/woocommerce/bin/composer/wp/composer.lock index 84e38397103..d949742c29f 100644 --- a/plugins/woocommerce/bin/composer/wp/composer.lock +++ b/plugins/woocommerce/bin/composer/wp/composer.lock @@ -553,22 +553,31 @@ }, { "name": "wp-cli/php-cli-tools", - "version": "v0.11.16", + "version": "v0.11.17", "source": { "type": "git", "url": "https://github.com/wp-cli/php-cli-tools.git", - "reference": "c32e51a5c9993ad40591bc426b21f5422a5ed293" + "reference": "f6be76b7c4ee2ef93c9531b8a37bdb7ce42c3728" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/wp-cli/php-cli-tools/zipball/c32e51a5c9993ad40591bc426b21f5422a5ed293", - "reference": "c32e51a5c9993ad40591bc426b21f5422a5ed293", + "url": "https://api.github.com/repos/wp-cli/php-cli-tools/zipball/f6be76b7c4ee2ef93c9531b8a37bdb7ce42c3728", + "reference": "f6be76b7c4ee2ef93c9531b8a37bdb7ce42c3728", "shasum": "" }, "require": { "php": ">= 5.3.0" }, + "require-dev": { + "roave/security-advisories": "dev-latest", + "wp-cli/wp-cli-tests": "^3.1.6" + }, "type": "library", + "extra": { + "branch-alias": { + "dev-master": "0.11.x-dev" + } + }, "autoload": { "files": [ "lib/cli/cli.php" @@ -601,9 +610,9 @@ ], "support": { "issues": "https://github.com/wp-cli/php-cli-tools/issues", - "source": "https://github.com/wp-cli/php-cli-tools/tree/v0.11.16" + "source": "https://github.com/wp-cli/php-cli-tools/tree/v0.11.17" }, - "time": "2022-11-03T15:19:26+00:00" + "time": "2023-01-12T01:18:21+00:00" }, { "name": "wp-cli/wp-cli", @@ -687,5 +696,5 @@ "platform-overrides": { "php": "7.0" }, - "plugin-api-version": "2.0.0" + "plugin-api-version": "2.3.0" } diff --git a/plugins/woocommerce/changelog/34071-alt-text-handling b/plugins/woocommerce/changelog/34071-alt-text-handling new file mode 100644 index 00000000000..fea6c96482b --- /dev/null +++ b/plugins/woocommerce/changelog/34071-alt-text-handling @@ -0,0 +1,4 @@ +Significance: patch +Type: fix + +Improve the way we retrieve the alt text property for product attachments. diff --git a/plugins/woocommerce/changelog/CHANGELOG.md b/plugins/woocommerce/changelog/CHANGELOG.md new file mode 100644 index 00000000000..70b9206c4f4 --- /dev/null +++ b/plugins/woocommerce/changelog/CHANGELOG.md @@ -0,0 +1,4 @@ +Significance: minor +Type: tweak + +Fix units in function doc comment diff --git a/plugins/woocommerce/changelog/add-36073-product-form-data-store b/plugins/woocommerce/changelog/add-36073-product-form-data-store new file mode 100644 index 00000000000..bb0f8989152 --- /dev/null +++ b/plugins/woocommerce/changelog/add-36073-product-form-data-store @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Adding JS data store for ProductForm. diff --git a/plugins/woocommerce/changelog/add-36075_render_fields_sections_from_php_config b/plugins/woocommerce/changelog/add-36075_render_fields_sections_from_php_config new file mode 100644 index 00000000000..4642e24d58f --- /dev/null +++ b/plugins/woocommerce/changelog/add-36075_render_fields_sections_from_php_config @@ -0,0 +1,4 @@ +Significance: patch +Type: tweak + +Minor adjustments to the ProductForm API diff --git a/plugins/woocommerce/changelog/add-36322 b/plugins/woocommerce/changelog/add-36322 new file mode 100644 index 00000000000..0f45f7d8cb3 --- /dev/null +++ b/plugins/woocommerce/changelog/add-36322 @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Add product tour to new product management experience diff --git a/plugins/woocommerce/changelog/add-36417-mvp-images-slotfill b/plugins/woocommerce/changelog/add-36417-mvp-images-slotfill new file mode 100644 index 00000000000..d4b8e282cca --- /dev/null +++ b/plugins/woocommerce/changelog/add-36417-mvp-images-slotfill @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Using slotfill to insert images section in product editor. diff --git a/plugins/woocommerce/changelog/add-new-variation-report-filter b/plugins/woocommerce/changelog/add-new-variation-report-filter new file mode 100644 index 00000000000..27f636fb3d3 --- /dev/null +++ b/plugins/woocommerce/changelog/add-new-variation-report-filter @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Add new filter to add additional clauses for SQL statement in Variations report diff --git a/plugins/woocommerce/changelog/add-php-73-version-bump-notice b/plugins/woocommerce/changelog/add-php-73-version-bump-notice new file mode 100644 index 00000000000..09a544979f4 --- /dev/null +++ b/plugins/woocommerce/changelog/add-php-73-version-bump-notice @@ -0,0 +1,4 @@ +Significance: patch +Type: add + +Add an admin notice about the upcoming PHP version requirement change for PHP 7.2 users diff --git a/plugins/woocommerce/changelog/add-product-permalink-template-rest-api b/plugins/woocommerce/changelog/add-product-permalink-template-rest-api new file mode 100644 index 00000000000..e2647c948bc --- /dev/null +++ b/plugins/woocommerce/changelog/add-product-permalink-template-rest-api @@ -0,0 +1,4 @@ +Significance: minor +Type: update + +Add permalink_template and generated_slug to products REST API response. diff --git a/plugins/woocommerce/changelog/add-scroll-attribute b/plugins/woocommerce/changelog/add-scroll-attribute new file mode 100644 index 00000000000..70045c4e008 --- /dev/null +++ b/plugins/woocommerce/changelog/add-scroll-attribute @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Scroll newly added product attribute into view in new product management experience diff --git a/plugins/woocommerce/changelog/add-wcadmin-home-header-banner-slotfill b/plugins/woocommerce/changelog/add-wcadmin-home-header-banner-slotfill new file mode 100644 index 00000000000..b7b6c727504 --- /dev/null +++ b/plugins/woocommerce/changelog/add-wcadmin-home-header-banner-slotfill @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Added a slot for extending the app with a homescreen header banner \ No newline at end of file diff --git a/plugins/woocommerce/changelog/add-wcadmin-progress-header-and-title-slot b/plugins/woocommerce/changelog/add-wcadmin-progress-header-and-title-slot new file mode 100644 index 00000000000..1be291b98e9 --- /dev/null +++ b/plugins/woocommerce/changelog/add-wcadmin-progress-header-and-title-slot @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Added a slot for ProgressHeader and ProgressTitle component \ No newline at end of file diff --git a/plugins/woocommerce/changelog/countries-shipping-to-prefix-context b/plugins/woocommerce/changelog/countries-shipping-to-prefix-context new file mode 100644 index 00000000000..2caa9243557 --- /dev/null +++ b/plugins/woocommerce/changelog/countries-shipping-to-prefix-context @@ -0,0 +1,4 @@ +Significance: patch +Type: enhancement + +Add context to countries shipping to prefix diff --git a/plugins/woocommerce/changelog/csv-export-error-logging b/plugins/woocommerce/changelog/csv-export-error-logging new file mode 100644 index 00000000000..9108587b6c0 --- /dev/null +++ b/plugins/woocommerce/changelog/csv-export-error-logging @@ -0,0 +1,4 @@ +Significance: patch +Type: add + +Additional error logging within the CSV Exporter framework. diff --git a/plugins/woocommerce/changelog/fix-31760 b/plugins/woocommerce/changelog/fix-31760 new file mode 100644 index 00000000000..e389925f1a9 --- /dev/null +++ b/plugins/woocommerce/changelog/fix-31760 @@ -0,0 +1,4 @@ +Significance: patch +Type: fix + +Make sure the tracking shortcode only operates in orders with billing information. diff --git a/plugins/woocommerce/changelog/fix-35561-wc-get-order b/plugins/woocommerce/changelog/fix-35561-wc-get-order new file mode 100644 index 00000000000..6d3132e2a9a --- /dev/null +++ b/plugins/woocommerce/changelog/fix-35561-wc-get-order @@ -0,0 +1,4 @@ +Significance: patch +Type: fix + +Ensure wc_get_order() works without arguments when HPOS is enabled. diff --git a/plugins/woocommerce/changelog/fix-36007-quantity-selector b/plugins/woocommerce/changelog/fix-36007-quantity-selector new file mode 100644 index 00000000000..e3d2f700ea1 --- /dev/null +++ b/plugins/woocommerce/changelog/fix-36007-quantity-selector @@ -0,0 +1,4 @@ +Significance: patch +Type: fix + +Restore the pre-7.2.0 behavior for single product quantity inputs. diff --git a/plugins/woocommerce/changelog/fix-36007-sold-individually b/plugins/woocommerce/changelog/fix-36007-sold-individually deleted file mode 100644 index 727cdd0ccc2..00000000000 --- a/plugins/woocommerce/changelog/fix-36007-sold-individually +++ /dev/null @@ -1,4 +0,0 @@ -Significance: patch -Type: tweak - -By default, hide the quantity selector within the single product page if a product is sold individually. diff --git a/plugins/woocommerce/changelog/fix-36007-sold-individually-amendment b/plugins/woocommerce/changelog/fix-36007-sold-individually-amendment deleted file mode 100644 index 6b14c30ea53..00000000000 --- a/plugins/woocommerce/changelog/fix-36007-sold-individually-amendment +++ /dev/null @@ -1,5 +0,0 @@ -Significance: patch -Type: tweak -Comment: We're tweaking an unreleased change which is already covered by a changelog entry added in PR#36350. - - diff --git a/plugins/woocommerce/changelog/fix-36325-download-permissions b/plugins/woocommerce/changelog/fix-36325-download-permissions new file mode 100644 index 00000000000..c8e65c7fc0d --- /dev/null +++ b/plugins/woocommerce/changelog/fix-36325-download-permissions @@ -0,0 +1,4 @@ +Significance: patch +Type: fix + +When adjusting download permissions, confirm the child products have not been removed. diff --git a/plugins/woocommerce/changelog/fix-36841 b/plugins/woocommerce/changelog/fix-36841 new file mode 100644 index 00000000000..7533f21c5a4 --- /dev/null +++ b/plugins/woocommerce/changelog/fix-36841 @@ -0,0 +1,4 @@ +Significance: patch +Type: fix + +Make order edit messages compatible with both posts and theorder object. diff --git a/plugins/woocommerce/changelog/fix-deprecated-usage-of-var-syntax-in-strings b/plugins/woocommerce/changelog/fix-deprecated-usage-of-var-syntax-in-strings new file mode 100644 index 00000000000..16c76f1a5dc --- /dev/null +++ b/plugins/woocommerce/changelog/fix-deprecated-usage-of-var-syntax-in-strings @@ -0,0 +1,4 @@ +Significance: patch +Type: fix + +Fix deprecated usage of ${var} in strings diff --git a/plugins/woocommerce/changelog/fix-edit-reply-reviews b/plugins/woocommerce/changelog/fix-edit-reply-reviews new file mode 100644 index 00000000000..14a1f5cbb62 --- /dev/null +++ b/plugins/woocommerce/changelog/fix-edit-reply-reviews @@ -0,0 +1,4 @@ +Significance: patch +Type: fix + +Fixes editing of child product reviews. diff --git a/plugins/woocommerce/changelog/fix-i18n-states-comment-typo b/plugins/woocommerce/changelog/fix-i18n-states-comment-typo new file mode 100644 index 00000000000..835d4d724bd --- /dev/null +++ b/plugins/woocommerce/changelog/fix-i18n-states-comment-typo @@ -0,0 +1,4 @@ +Significance: patch +Type: tweak + +Corrects a typo in the i18n/states.php file, relating to our list of Iranian states. diff --git a/plugins/woocommerce/changelog/fix-missed-wc-empty-cart b/plugins/woocommerce/changelog/fix-missed-wc-empty-cart new file mode 100644 index 00000000000..cb2d0b4ecb1 --- /dev/null +++ b/plugins/woocommerce/changelog/fix-missed-wc-empty-cart @@ -0,0 +1,4 @@ +Significance: patch +Type: fix + +Load wc_empty_cart function for REST API calls. diff --git a/plugins/woocommerce/changelog/fix-notices-styling-tt3 b/plugins/woocommerce/changelog/fix-notices-styling-tt3 new file mode 100644 index 00000000000..1b5d1d635fd --- /dev/null +++ b/plugins/woocommerce/changelog/fix-notices-styling-tt3 @@ -0,0 +1,4 @@ +Significance: minor +Type: fix + +Fix notices styling in Twenty Twenty-Three diff --git a/plugins/woocommerce/changelog/fix-remove-button-styles-when-custom-block-theme b/plugins/woocommerce/changelog/fix-remove-button-styles-when-custom-block-theme new file mode 100644 index 00000000000..88297cd396a --- /dev/null +++ b/plugins/woocommerce/changelog/fix-remove-button-styles-when-custom-block-theme @@ -0,0 +1,4 @@ +Significance: minor +Type: enhancement + +Remove default WooCommerce button styles if using a block theme which adds button styles in theme.json diff --git a/plugins/woocommerce/changelog/fix-tax-task-not-completed b/plugins/woocommerce/changelog/fix-tax-task-not-completed new file mode 100644 index 00000000000..e93ef2656c9 --- /dev/null +++ b/plugins/woocommerce/changelog/fix-tax-task-not-completed @@ -0,0 +1,4 @@ +Significance: patch +Type: fix + +Fix tax task showing as not completed after setting up tax diff --git a/plugins/woocommerce/changelog/patch-15 b/plugins/woocommerce/changelog/patch-15 new file mode 100644 index 00000000000..b9b0ae147f0 --- /dev/null +++ b/plugins/woocommerce/changelog/patch-15 @@ -0,0 +1,4 @@ +Significance: patch +Type: tweak + +Make related products check more robust against wrong transients. diff --git a/plugins/woocommerce/changelog/pr-35107 b/plugins/woocommerce/changelog/pr-35107 new file mode 100644 index 00000000000..4a03f11bfc9 --- /dev/null +++ b/plugins/woocommerce/changelog/pr-35107 @@ -0,0 +1,5 @@ +Significance: patch +Type: fix +Comment: Only a minor text change. + + diff --git a/plugins/woocommerce/changelog/remove-comment_workflow b/plugins/woocommerce/changelog/remove-comment_workflow new file mode 100644 index 00000000000..bc03365ccc6 --- /dev/null +++ b/plugins/woocommerce/changelog/remove-comment_workflow @@ -0,0 +1,5 @@ +Significance: patch +Type: dev +Comment: Build process change. + + diff --git a/plugins/woocommerce/changelog/try-product-mvp-slotfill-experiments b/plugins/woocommerce/changelog/try-product-mvp-slotfill-experiments new file mode 100644 index 00000000000..735fdc59890 --- /dev/null +++ b/plugins/woocommerce/changelog/try-product-mvp-slotfill-experiments @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Trying experimental slot context with product editor fills. diff --git a/plugins/woocommerce/changelog/update-36016-product-details-slotfill b/plugins/woocommerce/changelog/update-36016-product-details-slotfill new file mode 100644 index 00000000000..aa1c65b371e --- /dev/null +++ b/plugins/woocommerce/changelog/update-36016-product-details-slotfill @@ -0,0 +1,4 @@ +Significance: minor +Type: update + +Reimplementing product details fields in product editor as slot fills. diff --git a/plugins/woocommerce/changelog/update-36304 b/plugins/woocommerce/changelog/update-36304 new file mode 100644 index 00000000000..695487b46d0 --- /dev/null +++ b/plugins/woocommerce/changelog/update-36304 @@ -0,0 +1,4 @@ +Significance: minor +Type: dev + +Move product attribute fetching logic into a separate hook diff --git a/plugins/woocommerce/changelog/update-36363 b/plugins/woocommerce/changelog/update-36363 new file mode 100644 index 00000000000..a6c35857b21 --- /dev/null +++ b/plugins/woocommerce/changelog/update-36363 @@ -0,0 +1,4 @@ +Significance: minor +Type: tweak + +Update product links when new product management experience is enabled diff --git a/plugins/woocommerce/changelog/update-action-scheduler-3-5-3 b/plugins/woocommerce/changelog/update-action-scheduler-3-5-3 new file mode 100644 index 00000000000..ed7b1e941bc --- /dev/null +++ b/plugins/woocommerce/changelog/update-action-scheduler-3-5-3 @@ -0,0 +1,4 @@ +Significance: patch +Type: update + +Bundled version of Action Scheduler updated to 3.5.4. diff --git a/plugins/woocommerce/changelog/update-woocommerce-blocks-9.4.0 b/plugins/woocommerce/changelog/update-woocommerce-blocks-9.4.0 new file mode 100644 index 00000000000..ec26ff384d0 --- /dev/null +++ b/plugins/woocommerce/changelog/update-woocommerce-blocks-9.4.0 @@ -0,0 +1,4 @@ +Significance: minor +Type: update + +Update WooCommerce Blocks to 9.4.0 diff --git a/plugins/woocommerce/client/legacy/css/_common.scss b/plugins/woocommerce/client/legacy/css/_common.scss deleted file mode 100644 index 9737c39c898..00000000000 --- a/plugins/woocommerce/client/legacy/css/_common.scss +++ /dev/null @@ -1,8 +0,0 @@ -/** - * Contains rules common to all supported frontend themes. - */ - -/* We do not wish to display the quantity selector (within single product pages) if the product is sold individually. */ -.woocommerce.single-product .product.sold-individually .quantity { - display: none; -} diff --git a/plugins/woocommerce/client/legacy/css/twenty-nineteen.scss b/plugins/woocommerce/client/legacy/css/twenty-nineteen.scss index 41630ae0b43..fd257184f74 100644 --- a/plugins/woocommerce/client/legacy/css/twenty-nineteen.scss +++ b/plugins/woocommerce/client/legacy/css/twenty-nineteen.scss @@ -1,4 +1,3 @@ -@import "common"; @import 'mixins'; /** diff --git a/plugins/woocommerce/client/legacy/css/twenty-seventeen.scss b/plugins/woocommerce/client/legacy/css/twenty-seventeen.scss index 7e77f25c820..f2c0af5bb9f 100644 --- a/plugins/woocommerce/client/legacy/css/twenty-seventeen.scss +++ b/plugins/woocommerce/client/legacy/css/twenty-seventeen.scss @@ -1,7 +1,6 @@ /** * Twenty Seventeen integration styles */ -@import "common"; @import "mixins"; @import "animation"; diff --git a/plugins/woocommerce/client/legacy/css/twenty-twenty-one.scss b/plugins/woocommerce/client/legacy/css/twenty-twenty-one.scss index e7bbab55a2f..199deae3fc8 100644 --- a/plugins/woocommerce/client/legacy/css/twenty-twenty-one.scss +++ b/plugins/woocommerce/client/legacy/css/twenty-twenty-one.scss @@ -1,4 +1,3 @@ -@import "common"; @import "mixins"; /** diff --git a/plugins/woocommerce/client/legacy/css/twenty-twenty-three.scss b/plugins/woocommerce/client/legacy/css/twenty-twenty-three.scss index 6c6a8fa48d9..514389c4515 100644 --- a/plugins/woocommerce/client/legacy/css/twenty-twenty-three.scss +++ b/plugins/woocommerce/client/legacy/css/twenty-twenty-three.scss @@ -25,7 +25,6 @@ font-style: normal; } -@import "common"; @import "mixins"; @import "animation"; @@ -1116,60 +1115,57 @@ } /* - Notice messages (like 'Added to cart', 'Billing address needs to be filled in', etc. - */ - .woocommerce-message, - .woocommerce-error, - .woocommerce-info { - background-color: rgba( 176, 176, 176, 0.6 ); - color: #222; - border-top-color: var( --wp--preset--color--primary ); - border-top-style: solid; - border-top-width: 2px; - padding: 1rem 1.5rem; - margin-bottom: 2rem; - list-style: none; - font-size: var( --wp--preset--font-size--small ); - display: flow-root; +Notice messages (like 'Added to cart', 'Billing address needs to be filled in', etc. +*/ +.woocommerce-message, +.woocommerce-error, +.woocommerce-info { + background-color: rgba( 176, 176, 176, 0.6 ); + color: #222; + border-top-color: var( --wp--preset--color--primary ); + border-top-style: solid; + border-top-width: 2px; + padding: 1rem 1.5rem 1rem 3.5rem; + margin-bottom: 2rem; + list-style: none; + font-size: var( --wp--preset--font-size--small ); + position: relative; - &[role='alert']::before { - background: #d5d5d5; - color: black; - border-radius: 5rem; - font-size: 1rem; - padding-left: 3px; - padding-right: 3px; - margin-right: 1rem; - } + @include clearfix(); - a { - color: var( --wp--preset--color--contrast ); + &[role='alert']::before { + background: #d5d5d5; + color: black; + border-radius: 5rem; + font-size: 1rem; + content: "\e028"; + display: inline-block; + margin-right: 1rem; + height: 1.5em; + line-height: 1.5em; + text-align: center; + width: 1.5em; + position: absolute; + top: 1em; + left: 1.5em; + } - .button { - margin-top: -0.5rem; - border: none; - padding: 0.5rem 1rem; - } - } - } + a.button { + margin-bottom: -0.5rem; + margin-top: -0.5rem; + border: none; + padding: 0.5rem 1rem; + } +} - .woocommerce-error[role='alert'] { - margin: 0; - - &::before { - content: 'X'; - padding-right: 4px; - padding-left: 4px; - } - - li { - display: inline-block; - } - } - - .woocommerce-message { - &[role='alert']::before { - content: '\2713'; - } - } +.woocommerce-error { + &[role='alert']::before { + content: 'X'; + } +} +.woocommerce-message { + &[role='alert']::before { + content: '\2713'; + } +} diff --git a/plugins/woocommerce/client/legacy/css/twenty-twenty-two.scss b/plugins/woocommerce/client/legacy/css/twenty-twenty-two.scss index 7a54fe2318d..173a75b8d2e 100644 --- a/plugins/woocommerce/client/legacy/css/twenty-twenty-two.scss +++ b/plugins/woocommerce/client/legacy/css/twenty-twenty-two.scss @@ -25,7 +25,6 @@ font-style: normal; } -@import "common"; @import "mixins"; @import "animation"; @import "variables"; @@ -770,7 +769,6 @@ $tt2-gray: #f7f7f7; #coupon_code, .actions .button { - height: auto; margin-right: 0; } diff --git a/plugins/woocommerce/client/legacy/css/twenty-twenty.scss b/plugins/woocommerce/client/legacy/css/twenty-twenty.scss index 0e758990c06..6f91033da6c 100644 --- a/plugins/woocommerce/client/legacy/css/twenty-twenty.scss +++ b/plugins/woocommerce/client/legacy/css/twenty-twenty.scss @@ -1,4 +1,3 @@ -@import "common"; @import "mixins"; /** diff --git a/plugins/woocommerce/client/legacy/css/woocommerce-blocktheme.scss b/plugins/woocommerce/client/legacy/css/woocommerce-blocktheme.scss index 41b17b64088..137756a3488 100644 --- a/plugins/woocommerce/client/legacy/css/woocommerce-blocktheme.scss +++ b/plugins/woocommerce/client/legacy/css/woocommerce-blocktheme.scss @@ -2,7 +2,6 @@ * woocommerce-blocktheme.scss * Block theme default styles to ensure WooCommerce looks better out of the box with block themes that are not optimised for WooCommerce specifically. */ -@import "common"; @import "fonts"; @import "variables"; @@ -122,6 +121,11 @@ + .single_add_to_cart_button { min-height: 51px; + // We need to remove top and bottom padding because we are setting a fixed + // height. This is to prevent the button from being cut off. !important is + // needed to override the Elements API styles, which also use !important. + padding-top: 0 !important; + padding-bottom: 0 !important; } } @@ -307,8 +311,14 @@ #coupon_code, .actions .button { height: 50px; - padding: 0.9rem 1.1rem; font-size: var(--wp--preset--font-size--small); + padding-left: 1.1rem; + padding-right: 1.1rem; + // We need to remove top and bottom padding because we are setting a fixed + // height. This is to prevent the button from being cut off. !important is + // needed to override the Elements API styles, which also use !important. + padding-top: 0 !important; + padding-bottom: 0 !important; } @media only screen and ( max-width: 768px ) { diff --git a/plugins/woocommerce/client/legacy/css/woocommerce.scss b/plugins/woocommerce/client/legacy/css/woocommerce.scss index 9b475942929..cfd87abad71 100644 --- a/plugins/woocommerce/client/legacy/css/woocommerce.scss +++ b/plugins/woocommerce/client/legacy/css/woocommerce.scss @@ -7,7 +7,6 @@ /** * Imports */ -@import "common"; @import "mixins"; @import "variables"; @import "animation"; @@ -601,6 +600,7 @@ p.demo_store, } .button { + display: inline-block; margin-top: 1em; } @@ -687,98 +687,6 @@ p.demo_store, } } - /** - * Buttons - */ - a.button, - button.button, - input.button, - #respond input#submit { - font-size: 100%; - margin: 0; - line-height: 1; - cursor: pointer; - position: relative; - text-decoration: none; - overflow: visible; - padding: 0.618em 1em; - font-weight: 700; - border-radius: 3px; - left: auto; - color: $secondarytext; - background-color: $secondary; - border: 0; - display: inline-block; - background-image: none; - box-shadow: none; - text-shadow: none; - - &.loading { - opacity: 0.25; - padding-right: 2.618em; - - &::after { - font-family: "WooCommerce"; - content: "\e01c"; - vertical-align: top; - font-weight: 400; - position: absolute; - top: 0.618em; - right: 1em; - animation: spin 2s linear infinite; - } - } - - &.added::after { - font-family: "WooCommerce"; - content: "\e017"; - margin-left: 0.53em; - vertical-align: bottom; - } - - &:hover { - background-color: darken($secondary, 5%); - text-decoration: none; - background-image: none; - color: $secondarytext; - } - - &.alt { - background-color: $primary; - color: $primarytext; - -webkit-font-smoothing: antialiased; - - &:hover { - background-color: darken($primary, 5%); - color: $primarytext; - } - - &.disabled, - &:disabled, - &:disabled[disabled], - &.disabled:hover, - &:disabled:hover, - &:disabled[disabled]:hover { - background-color: $primary; - color: $primarytext; - } - } - - &:disabled, - &.disabled, - &:disabled[disabled] { - color: inherit; - cursor: not-allowed; - opacity: 0.5; - padding: 0.618em 1em; - - &:hover { - color: inherit; - background-color: $secondary; - } - } - } - .cart .button, .cart input.button { float: none; @@ -1742,6 +1650,102 @@ p.demo_store, } } + +/** + * Buttons + */ +.woocommerce:where(body:not(.woocommerce-block-theme-has-button-styles)), +:where(body:not(.woocommerce-block-theme-has-button-styles)) .woocommerce { + a.button, + button.button, + input.button, + #respond input#submit { + font-size: 100%; + margin: 0; + line-height: 1; + cursor: pointer; + position: relative; + text-decoration: none; + overflow: visible; + padding: 0.618em 1em; + font-weight: 700; + border-radius: 3px; + left: auto; + color: $secondarytext; + background-color: $secondary; + border: 0; + display: inline-block; + background-image: none; + box-shadow: none; + text-shadow: none; + + &.loading { + opacity: 0.25; + padding-right: 2.618em; + + &::after { + font-family: "WooCommerce"; + content: "\e01c"; + vertical-align: top; + font-weight: 400; + position: absolute; + top: 0.618em; + right: 1em; + animation: spin 2s linear infinite; + } + } + + &.added::after { + font-family: "WooCommerce"; + content: "\e017"; + margin-left: 0.53em; + vertical-align: bottom; + } + + &:hover { + background-color: darken($secondary, 5%); + text-decoration: none; + background-image: none; + color: $secondarytext; + } + + &.alt { + background-color: $primary; + color: $primarytext; + -webkit-font-smoothing: antialiased; + + &:hover { + background-color: darken($primary, 5%); + color: $primarytext; + } + + &.disabled, + &:disabled, + &:disabled[disabled], + &.disabled:hover, + &:disabled:hover, + &:disabled[disabled]:hover { + background-color: $primary; + color: $primarytext; + } + } + + &:disabled, + &.disabled, + &:disabled[disabled] { + color: inherit; + cursor: not-allowed; + opacity: 0.5; + padding: 0.618em 1em; + + &:hover { + color: inherit; + background-color: $secondary; + } + } + } +} + .woocommerce-no-js { form.woocommerce-form-login, diff --git a/plugins/woocommerce/client/legacy/js/admin/woocommerce_admin.js b/plugins/woocommerce/client/legacy/js/admin/woocommerce_admin.js index 4b34a0d1302..542f2e9cc4a 100644 --- a/plugins/woocommerce/client/legacy/js/admin/woocommerce_admin.js +++ b/plugins/woocommerce/client/legacy/js/admin/woocommerce_admin.js @@ -11,6 +11,11 @@ $blankslate = $product_screen.find( '.woocommerce-BlankState' ); if ( 0 === $blankslate.length ) { + if ( woocommerce_admin.urls.add_product ) { + $title_action + .first() + .attr( 'href', woocommerce_admin.urls.add_product ); + } if ( woocommerce_admin.urls.export_products ) { $title_action.after( ' __( 'Lakshadeep', 'woocommerce' ), 'PY' => __( 'Pondicherry (Puducherry)', 'woocommerce' ), ), - 'IR' => array( // Irania states. + 'IR' => array( // Iranian states. 'KHZ' => __( 'Khuzestan (خوزستان)', 'woocommerce' ), 'THR' => __( 'Tehran (تهران)', 'woocommerce' ), 'ILM' => __( 'Ilaam (ایلام)', 'woocommerce' ), diff --git a/plugins/woocommerce/includes/admin/class-wc-admin-assets.php b/plugins/woocommerce/includes/admin/class-wc-admin-assets.php index f8cffe518eb..1f981c36313 100644 --- a/plugins/woocommerce/includes/admin/class-wc-admin-assets.php +++ b/plugins/woocommerce/includes/admin/class-wc-admin-assets.php @@ -7,6 +7,7 @@ */ use Automattic\Jetpack\Constants; +use Automattic\WooCommerce\Admin\Features\Features; if ( ! defined( 'ABSPATH' ) ) { exit; @@ -214,6 +215,7 @@ if ( ! class_exists( 'WC_Admin_Assets', false ) ) : 'gateway_toggle' => wp_create_nonce( 'woocommerce-toggle-payment-gateway-enabled' ), ), 'urls' => array( + 'add_product' => Features::is_enabled( 'new-product-management-experience' ) ? esc_url_raw( admin_url( 'admin.php?page=wc-admin&path=/add-product' ) ) : null, 'import_products' => current_user_can( 'import' ) ? esc_url_raw( admin_url( 'edit.php?post_type=product&page=product_importer' ) ) : null, 'export_products' => current_user_can( 'export' ) ? esc_url_raw( admin_url( 'edit.php?post_type=product&page=product_exporter' ) ) : null, ), diff --git a/plugins/woocommerce/includes/admin/class-wc-admin-menus.php b/plugins/woocommerce/includes/admin/class-wc-admin-menus.php index acaaf32dd9d..bb14310e195 100644 --- a/plugins/woocommerce/includes/admin/class-wc-admin-menus.php +++ b/plugins/woocommerce/includes/admin/class-wc-admin-menus.php @@ -425,7 +425,14 @@ class WC_Admin_Menus { */ public function maybe_add_new_product_management_experience() { if ( Features::is_enabled( 'new-product-management-experience' ) ) { - add_submenu_page( 'edit.php?post_type=product', __( 'Add New', 'woocommerce' ), __( 'Add New (MVP)', 'woocommerce' ), 'manage_woocommerce', 'admin.php?page=wc-admin&path=/add-product', '', 2 ); + global $submenu; + if ( isset( $submenu['edit.php?post_type=product'][10] ) ) { + // Disable phpcs since we need to override submenu classes. + // Note that `phpcs:ignore WordPress.Variables.GlobalVariables.OverrideProhibited` does not work to disable this check. + // phpcs:disable + $submenu['edit.php?post_type=product'][10][2] = 'admin.php?page=wc-admin&path=/add-product'; + // phps:enableWordPress.Variables.GlobalVariables.OverrideProhibited + } } } } diff --git a/plugins/woocommerce/includes/admin/class-wc-admin-notices.php b/plugins/woocommerce/includes/admin/class-wc-admin-notices.php index 1bb51f64c04..05f528fc04c 100644 --- a/plugins/woocommerce/includes/admin/class-wc-admin-notices.php +++ b/plugins/woocommerce/includes/admin/class-wc-admin-notices.php @@ -8,6 +8,7 @@ use Automattic\Jetpack\Constants; use Automattic\WooCommerce\Internal\Utilities\Users; +use Automattic\WooCommerce\Internal\Traits\AccessiblePrivateMethods; defined( 'ABSPATH' ) || exit; @@ -16,6 +17,8 @@ defined( 'ABSPATH' ) || exit; */ class WC_Admin_Notices { + use AccessiblePrivateMethods; + /** * Stores notices. * @@ -54,6 +57,7 @@ class WC_Admin_Notices { add_action( 'woocommerce_installed', array( __CLASS__, 'reset_admin_notices' ) ); add_action( 'wp_loaded', array( __CLASS__, 'add_redirect_download_method_notice' ) ); add_action( 'admin_init', array( __CLASS__, 'hide_notices' ), 20 ); + self::add_action( 'admin_init', array( __CLASS__, 'maybe_remove_php73_required_notice' ) ); // @TODO: This prevents Action Scheduler async jobs from storing empty list of notices during WC installation. // That could lead to OBW not starting and 'Run setup wizard' notice not appearing in WP admin, which we want @@ -116,8 +120,53 @@ class WC_Admin_Notices { self::add_notice( 'template_files' ); self::add_min_version_notice(); self::add_maxmind_missing_license_key_notice(); + self::maybe_add_php73_required_notice(); } + // phpcs:disable Generic.Commenting.Todo.TaskFound + + /** + * Add an admin notice about the bump of the required PHP version in WooCommerce 7.7 + * if the current PHP version is too old. + * + * TODO: Remove this method in WooCommerce 7.7. + */ + private static function maybe_add_php73_required_notice() { + if ( version_compare( phpversion(), '7.3', '>=' ) ) { + return; + } + + self::add_custom_notice( + 'php73_required_in_woo_77', + sprintf( + '%s%s', + sprintf( + '

    %s

    ', + esc_html__( 'PHP version requirements will change soon', 'woocommerce' ) + ), + sprintf( + // translators: Placeholder is a URL. + wpautop( wp_kses_data( __( 'WooCommerce 7.7, scheduled for May 2023, will require PHP 7.3 or newer to work. Your server is currently running an older version of PHP, so this change will impact your store. Upgrading to at least PHP 8.0 is recommended.
    Learn more about this change.', 'woocommerce' ) ) ), + 'https://developer.woocommerce.com/2023/01/10/new-requirement-for-woocommerce-7-7-php-7-3/' + ) + ) + ); + } + + /** + * Remove the admin notice about the bump of the required PHP version in WooCommerce 7.7 + * if the current PHP version is good. + * + * TODO: Remove this method in WooCommerce 7.7. + */ + private static function maybe_remove_php73_required_notice() { + if ( version_compare( phpversion(), '7.3', '>=' ) && self::has_notice( 'php73_required_in_woo_77' ) ) { + self::remove_notice( 'php73_required_in_woo_77' ); + } + } + + // phpcs:enable Generic.Commenting.Todo.TaskFound + /** * Show a notice. * diff --git a/plugins/woocommerce/includes/admin/class-wc-admin-post-types.php b/plugins/woocommerce/includes/admin/class-wc-admin-post-types.php index 5680eec7f88..817530fe0e2 100644 --- a/plugins/woocommerce/includes/admin/class-wc-admin-post-types.php +++ b/plugins/woocommerce/includes/admin/class-wc-admin-post-types.php @@ -41,6 +41,7 @@ class WC_Admin_Post_Types { // Admin notices. add_filter( 'post_updated_messages', array( $this, 'post_updated_messages' ) ); + add_filter( 'woocommerce_order_updated_messages', array( $this, 'order_updated_messages' ) ); add_filter( 'bulk_post_updated_messages', array( $this, 'bulk_post_updated_messages' ), 10, 2 ); // Disable Auto Save. @@ -145,24 +146,7 @@ class WC_Admin_Post_Types { 10 => sprintf( __( 'Product draft updated. Preview product', 'woocommerce' ), esc_url( add_query_arg( 'preview', 'true', get_permalink( $post->ID ) ) ) ), ); - $messages['shop_order'] = array( - 0 => '', // Unused. Messages start at index 1. - 1 => __( 'Order updated.', 'woocommerce' ), - 2 => __( 'Custom field updated.', 'woocommerce' ), - 3 => __( 'Custom field deleted.', 'woocommerce' ), - 4 => __( 'Order updated.', 'woocommerce' ), - 5 => __( 'Revision restored.', 'woocommerce' ), - 6 => __( 'Order updated.', 'woocommerce' ), - 7 => __( 'Order saved.', 'woocommerce' ), - 8 => __( 'Order submitted.', 'woocommerce' ), - 9 => sprintf( - /* translators: %s: date */ - __( 'Order scheduled for: %s.', 'woocommerce' ), - '' . date_i18n( __( 'M j, Y @ G:i', 'woocommerce' ), strtotime( $post->post_date ) ) . '' - ), - 10 => __( 'Order draft updated.', 'woocommerce' ), - 11 => __( 'Order updated and sent.', 'woocommerce' ), - ); + $messages = $this->order_updated_messages( $messages ); $messages['shop_coupon'] = array( 0 => '', // Unused. Messages start at index 1. @@ -185,6 +169,46 @@ class WC_Admin_Post_Types { return $messages; } + /** + * Add messages when an order is updated. + * + * @param array $messages Array of messages. + * + * @return array + */ + public function order_updated_messages( array $messages ) { + global $post, $theorder; + + if ( ! isset( $theorder ) || ! $theorder instanceof WC_Abstract_Order ) { + if ( ! isset( $post ) || 'shop_order' !== $post->post_type ) { + return $messages; + } else { + \Automattic\WooCommerce\Utilities\OrderUtil::init_theorder_object( $post ); + } + } + + $messages['shop_order'] = array( + 0 => '', // Unused. Messages start at index 1. + 1 => __( 'Order updated.', 'woocommerce' ), + 2 => __( 'Custom field updated.', 'woocommerce' ), + 3 => __( 'Custom field deleted.', 'woocommerce' ), + 4 => __( 'Order updated.', 'woocommerce' ), + 5 => __( 'Revision restored.', 'woocommerce' ), + 6 => __( 'Order updated.', 'woocommerce' ), + 7 => __( 'Order saved.', 'woocommerce' ), + 8 => __( 'Order submitted.', 'woocommerce' ), + 9 => sprintf( + /* translators: %s: date */ + __( 'Order scheduled for: %s.', 'woocommerce' ), + '' . date_i18n( __( 'M j, Y @ G:i', 'woocommerce' ), strtotime( $theorder->get_date_created() ) ) . '' + ), + 10 => __( 'Order draft updated.', 'woocommerce' ), + 11 => __( 'Order updated and sent.', 'woocommerce' ), + ); + + return $messages; + } + /** * Specify custom bulk actions messages for different post types. * diff --git a/plugins/woocommerce/includes/admin/settings/class-wc-settings-emails.php b/plugins/woocommerce/includes/admin/settings/class-wc-settings-emails.php index 9086d7fd8b8..36705f13c50 100644 --- a/plugins/woocommerce/includes/admin/settings/class-wc-settings-emails.php +++ b/plugins/woocommerce/includes/admin/settings/class-wc-settings-emails.php @@ -120,7 +120,7 @@ class WC_Settings_Emails extends WC_Settings_Page { array( 'title' => __( 'Header image', 'woocommerce' ), - 'desc' => __( 'URL to an image you want to show in the email header. Upload images using the media uploader (Admin > Media).', 'woocommerce' ), + 'desc' => __( 'Paste the URL of an image you want to show in the email header. Upload images using the media uploader (Media > Add New).', 'woocommerce' ), 'id' => 'woocommerce_email_header_image', 'type' => 'text', 'css' => 'min-width:400px;', diff --git a/plugins/woocommerce/includes/admin/settings/class-wc-settings-payment-gateways.php b/plugins/woocommerce/includes/admin/settings/class-wc-settings-payment-gateways.php index a519ce26148..809057cc6f3 100644 --- a/plugins/woocommerce/includes/admin/settings/class-wc-settings-payment-gateways.php +++ b/plugins/woocommerce/includes/admin/settings/class-wc-settings-payment-gateways.php @@ -267,7 +267,7 @@ class WC_Settings_Payment_Gateways extends WC_Settings_Page { foreach ( $plugin_suggestions as $plugin_suggestion ) { $alt = str_replace( '.png', '', basename( $plugin_suggestion->image_72x72 ) ); // phpcs:ignore - echo "${alt}"; + echo "{$alt}"; } echo '& more.'; } diff --git a/plugins/woocommerce/includes/admin/views/html-admin-page-status-tools.php b/plugins/woocommerce/includes/admin/views/html-admin-page-status-tools.php index 31489a6ccdd..8da221e34ff 100644 --- a/plugins/woocommerce/includes/admin/views/html-admin-page-status-tools.php +++ b/plugins/woocommerce/includes/admin/views/html-admin-page-status-tools.php @@ -39,7 +39,7 @@ foreach ( $tools as $action_name => $tool ) { echo wp_kses_post( $selector['description'] ); } // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped - echo "  "; + echo "  "; } ?>

    diff --git a/plugins/woocommerce/includes/class-wc-countries.php b/plugins/woocommerce/includes/class-wc-countries.php index a1a9aa8507f..bfaf83a6b06 100644 --- a/plugins/woocommerce/includes/class-wc-countries.php +++ b/plugins/woocommerce/includes/class-wc-countries.php @@ -410,7 +410,7 @@ class WC_Countries { public function shipping_to_prefix( $country_code = '' ) { $country_code = $country_code ? $country_code : WC()->customer->get_shipping_country(); $countries = array( 'AE', 'CZ', 'DO', 'GB', 'NL', 'PH', 'US', 'USAF' ); - $return = in_array( $country_code, $countries, true ) ? __( 'to the', 'woocommerce' ) : __( 'to', 'woocommerce' ); + $return = in_array( $country_code, $countries, true ) ? _x( 'to the', 'shipping country prefix', 'woocommerce' ) : _x( 'to', 'shipping country prefix', 'woocommerce' ); return apply_filters( 'woocommerce_countries_shipping_to_prefix', $return, $country_code ); } diff --git a/plugins/woocommerce/includes/class-wc-order-factory.php b/plugins/woocommerce/includes/class-wc-order-factory.php index bc096b0016d..543f2bf3915 100644 --- a/plugins/woocommerce/includes/class-wc-order-factory.php +++ b/plugins/woocommerce/includes/class-wc-order-factory.php @@ -10,6 +10,8 @@ defined( 'ABSPATH' ) || exit; +use Automattic\WooCommerce\Utilities\OrderUtil; + /** * Order factory class */ @@ -165,10 +167,8 @@ class WC_Order_Factory { * @return int|bool false on failure */ public static function get_order_id( $order ) { - global $post; - - if ( false === $order && is_a( $post, 'WP_Post' ) && 'shop_order' === get_post_type( $post ) ) { - return absint( $post->ID ); + if ( false === $order ) { + return self::get_global_order_id(); } elseif ( is_numeric( $order ) ) { return $order; } elseif ( $order instanceof WC_Abstract_Order ) { @@ -180,6 +180,33 @@ class WC_Order_Factory { } } + /** + * Try to determine the current order ID based on available global state. + * + * @return false|int + */ + private static function get_global_order_id() { + global $post; + global $theorder; + + // Initialize the global $theorder object if necessary. + if ( ! isset( $theorder ) || ! $theorder instanceof WC_Abstract_Order ) { + if ( ! isset( $post ) || 'shop_order' !== $post->post_type ) { + return false; + } else { + OrderUtil::init_theorder_object( $post ); + } + } + + if ( $theorder instanceof WC_Order ) { + return $theorder->get_id(); + } elseif ( is_a( $post, 'WP_Post' ) && 'shop_order' === get_post_type( $post ) ) { + return absint( $post->ID ); + } else { + return false; + } + } + /** * Gets the class name bunch of order instances should have based on their IDs. * diff --git a/plugins/woocommerce/includes/class-wc-session-handler.php b/plugins/woocommerce/includes/class-wc-session-handler.php index 332ecb5e7f6..046583dfe45 100644 --- a/plugins/woocommerce/includes/class-wc-session-handler.php +++ b/plugins/woocommerce/includes/class-wc-session-handler.php @@ -369,7 +369,11 @@ class WC_Session_Handler extends WC_Session { public function forget_session() { wc_setcookie( $this->_cookie, '', time() - YEAR_IN_SECONDS, $this->use_secure_cookie(), true ); - wc_empty_cart(); + if ( ! is_admin() ) { + include_once WC_ABSPATH . 'includes/wc-cart-functions.php'; + + wc_empty_cart(); + } $this->_data = array(); $this->_dirty = false; diff --git a/plugins/woocommerce/includes/export/abstract-wc-csv-batch-exporter.php b/plugins/woocommerce/includes/export/abstract-wc-csv-batch-exporter.php index d65f36bed17..73fd268af66 100644 --- a/plugins/woocommerce/includes/export/abstract-wc-csv-batch-exporter.php +++ b/plugins/woocommerce/includes/export/abstract-wc-csv-batch-exporter.php @@ -127,6 +127,13 @@ abstract class WC_CSV_Batch_Exporter extends WC_CSV_Exporter { protected function write_csv_data( $data ) { if ( ! file_exists( $this->get_file_path() ) || ! is_writeable( $this->get_file_path() ) ) { + wc_get_logger()->error( + sprintf( + /* translators: %s is file path. */ + __( 'Unable to create or write to %s during CSV export. Please check file permissions.', 'woocommerce' ), + esc_html( $this->get_file_path() ) + ) + ); return false; } diff --git a/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller.php b/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller.php index 2cc6137d064..f7d8ff984be 100644 --- a/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller.php +++ b/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller.php @@ -1421,6 +1421,24 @@ class WC_REST_Products_Controller extends WC_REST_Products_V2_Controller { ), ), ); + + $post_type_obj = get_post_type_object( $this->post_type ); + if ( is_post_type_viewable( $post_type_obj ) && $post_type_obj->public ) { + $schema['properties']['permalink_template'] = array( + 'description' => __( 'Permalink template for the product.', 'woocommerce' ), + 'type' => 'string', + 'context' => array( 'edit' ), + 'readonly' => true, + ); + + $schema['properties']['generated_slug'] = array( + 'description' => __( 'Slug automatically generated from the product name.', 'woocommerce' ), + 'type' => 'string', + 'context' => array( 'edit' ), + 'readonly' => true, + ); + } + return $this->add_additional_fields_schema( $schema ); } @@ -1462,16 +1480,45 @@ class WC_REST_Products_Controller extends WC_REST_Products_V2_Controller { */ protected function get_product_data( $product, $context = 'view' ) { $data = parent::get_product_data( ...func_get_args() ); - // Add stock_status if needed. + if ( isset( $this->request ) ) { $fields = $this->get_fields_for_response( $this->request ); - if ( in_array( 'stock_status', $fields ) ) { + + // Add stock_status if needed. + if ( in_array( 'stock_status', $fields, true ) ) { $data['stock_status'] = $product->get_stock_status( $context ); } - if ( in_array( 'has_options', $fields ) ) { + + // Add has_options if needed. + if ( in_array( 'has_options', $fields, true ) ) { $data['has_options'] = $product->has_options( $context ); } + + $post_type_obj = get_post_type_object( $this->post_type ); + if ( is_post_type_viewable( $post_type_obj ) && $post_type_obj->public ) { + $permalink_template_requested = in_array( 'permalink_template', $fields, true ); + $generated_slug_requested = in_array( 'generated_slug', $fields, true ); + + if ( $permalink_template_requested || $generated_slug_requested ) { + if ( ! function_exists( 'get_sample_permalink' ) ) { + require_once ABSPATH . 'wp-admin/includes/post.php'; + } + + $sample_permalink = get_sample_permalink( $product->get_id(), $product->get_name(), '' ); + + // Add permalink_template if needed. + if ( $permalink_template_requested ) { + $data['permalink_template'] = $sample_permalink[0]; + } + + // Add generated_slug if needed. + if ( $generated_slug_requested ) { + $data['generated_slug'] = $sample_permalink[1]; + } + } + } } + return $data; } } diff --git a/plugins/woocommerce/includes/shortcodes/class-wc-shortcode-order-tracking.php b/plugins/woocommerce/includes/shortcodes/class-wc-shortcode-order-tracking.php index 6798539ae82..2151c69e9ab 100644 --- a/plugins/woocommerce/includes/shortcodes/class-wc-shortcode-order-tracking.php +++ b/plugins/woocommerce/includes/shortcodes/class-wc-shortcode-order-tracking.php @@ -51,7 +51,7 @@ class WC_Shortcode_Order_Tracking { } else { $order = wc_get_order( apply_filters( 'woocommerce_shortcode_order_tracking_order_id', $order_id ) ); - if ( $order && $order->get_id() && strtolower( $order->get_billing_email() ) === strtolower( $order_email ) ) { + if ( $order && $order->get_id() && is_a( $order, 'WC_Order' ) && strtolower( $order->get_billing_email() ) === strtolower( $order_email ) ) { do_action( 'woocommerce_track_order', $order->get_id() ); wc_get_template( 'order/tracking.php', diff --git a/plugins/woocommerce/includes/wc-conditional-functions.php b/plugins/woocommerce/includes/wc-conditional-functions.php index d992cf24c2c..64651baab16 100644 --- a/plugins/woocommerce/includes/wc-conditional-functions.php +++ b/plugins/woocommerce/includes/wc-conditional-functions.php @@ -541,3 +541,36 @@ function wc_wp_theme_get_element_class_name( $element ) { return ''; } + +/** + * Given an element name, returns true or false depending on whether the + * current theme has styles for that element defined in theme.json. + * + * If the theme is not a block theme or the WP-related function is not defined, + * return false. + * + * @param string $element The name of the element. + * + * @since 7.4.0 + * @return bool + */ +function wc_block_theme_has_styles_for_element( $element ) { + if ( + ! wc_current_theme_is_fse_theme() || + wc_wp_theme_get_element_class_name( $element ) === '' + ) { + return false; + } + + if ( function_exists( 'wp_get_global_styles' ) ) { + $global_styles = wp_get_global_styles(); + if ( + array_key_exists( 'elements', $global_styles ) && + array_key_exists( $element, $global_styles['elements'] ) + ) { + return is_array( $global_styles['elements'][ $element ] ); + } + } + + return false; +} diff --git a/plugins/woocommerce/includes/wc-core-functions.php b/plugins/woocommerce/includes/wc-core-functions.php index 18319fc1230..d4bbe5c944f 100644 --- a/plugins/woocommerce/includes/wc-core-functions.php +++ b/plugins/woocommerce/includes/wc-core-functions.php @@ -1701,8 +1701,11 @@ function wc_get_shipping_method_count( $include_legacy = false, $enabled_only = return absint( $transient_value['value'] ); } - $where_clause = $enabled_only ? 'WHERE is_enabled=1' : ''; - $method_count = absint( $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}woocommerce_shipping_zone_methods ${where_clause}" ) ); + if ( $enabled_only ) { + $method_count = absint( $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}woocommerce_shipping_zone_methods WHERE is_enabled=1" ) ); + } else { + $method_count = absint( $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}woocommerce_shipping_zone_methods" ) ); + } if ( $include_legacy ) { // Count activated methods that don't support shipping zones. diff --git a/plugins/woocommerce/includes/wc-formatting-functions.php b/plugins/woocommerce/includes/wc-formatting-functions.php index e5e34e33a6b..9f05e82c76f 100644 --- a/plugins/woocommerce/includes/wc-formatting-functions.php +++ b/plugins/woocommerce/includes/wc-formatting-functions.php @@ -105,10 +105,10 @@ function wc_get_filename_from_url( $file_url ) { * * @param int|float $dimension Dimension. * @param string $to_unit Unit to convert to. - * Options: 'in', 'm', 'cm', 'm'. + * Options: 'in', 'mm', 'cm', 'm'. * @param string $from_unit Unit to convert from. * Defaults to ''. - * Options: 'in', 'm', 'cm', 'm'. + * Options: 'in', 'mm', 'cm', 'm'. * @return float */ function wc_get_dimension( $dimension, $to_unit, $from_unit = '' ) { diff --git a/plugins/woocommerce/includes/wc-product-functions.php b/plugins/woocommerce/includes/wc-product-functions.php index 132de94d689..d1099b5605c 100644 --- a/plugins/woocommerce/includes/wc-product-functions.php +++ b/plugins/woocommerce/includes/wc-product-functions.php @@ -788,7 +788,7 @@ function wc_get_product_attachment_props( $attachment_id = null, $product = fals } $alt_text = array_filter( $alt_text ); - $props['alt'] = isset( $alt_text[0] ) ? $alt_text[0] : ''; + $props['alt'] = $alt_text ? reset( $alt_text ) : ''; // Large version. $full_size = apply_filters( 'woocommerce_gallery_full_size', apply_filters( 'woocommerce_product_thumbnails_large_size', 'full' ) ); @@ -915,7 +915,7 @@ function wc_get_related_products( $product_id, $limit = 5, $exclude_ids = array( ); $transient = get_transient( $transient_name ); - $related_posts = $transient && isset( $transient[ $query_args ] ) ? $transient[ $query_args ] : false; + $related_posts = $transient && is_array( $transient ) && isset( $transient[ $query_args ] ) ? $transient[ $query_args ] : false; // We want to query related posts if they are not cached, or we don't have enough. if ( false === $related_posts || count( $related_posts ) < $limit ) { @@ -931,7 +931,7 @@ function wc_get_related_products( $product_id, $limit = 5, $exclude_ids = array( $related_posts = $data_store->get_related_products( $cats_array, $tags_array, $exclude_ids, $limit + 10, $product_id ); } - if ( $transient ) { + if ( $transient && is_array( $transient ) ) { $transient[ $query_args ] = $related_posts; } else { $transient = array( $query_args => $related_posts ); diff --git a/plugins/woocommerce/includes/wc-template-functions.php b/plugins/woocommerce/includes/wc-template-functions.php index 4b3ef3ec827..c376d601e47 100644 --- a/plugins/woocommerce/includes/wc-template-functions.php +++ b/plugins/woocommerce/includes/wc-template-functions.php @@ -339,6 +339,12 @@ function wc_body_class( $classes ) { } } + if ( wc_block_theme_has_styles_for_element( 'button' ) ) { + + $classes[] = 'woocommerce-block-theme-has-button-styles'; + + } + $classes[] = 'woocommerce-no-js'; add_action( 'wp_footer', 'wc_no_js' ); @@ -1797,6 +1803,7 @@ if ( ! function_exists( 'woocommerce_quantity_input' ) ) { // When autocomplete is enabled in firefox, it will overwrite actual value with what user entered last. So we default to off. // See @link https://github.com/woocommerce/woocommerce/issues/30733. 'autocomplete' => apply_filters( 'woocommerce_quantity_input_autocomplete', 'off', $product ), + 'readonly' => false, ); $args = apply_filters( 'woocommerce_quantity_input_args', wp_parse_args( $args, $defaults ), $product ); @@ -1810,8 +1817,25 @@ if ( ! function_exists( 'woocommerce_quantity_input' ) ) { $args['max_value'] = $args['min_value']; } - ob_start(); + /** + * The input type attribute will generally be 'number' unless the quantity cannot be changed, in which case + * it will be set to 'hidden'. An exception is made for non-hidden readonly inputs: in this case we set the + * type to 'text' (this prevents most browsers from rendering increment/decrement arrows, which are useless + * and/or confusing in this context). + */ + $type = $args['min_value'] > 0 && $args['min_value'] === $args['max_value'] ? 'hidden' : 'number'; + $type = $args['readonly'] && 'hidden' !== $type ? 'text' : $type; + /** + * Controls the quantity input's type attribute. + * + * @since 7.4.0 + * + * @param string $type A valid input type attribute value, usually 'number' or 'hidden'. + */ + $args['type'] = apply_filters( 'woocommerce_quantity_input_type', $type ); + + ob_start(); wc_get_template( 'global/quantity-input.php', $args ); if ( $echo ) { diff --git a/plugins/woocommerce/src/Admin/API/Reports/Orders/DataStore.php b/plugins/woocommerce/src/Admin/API/Reports/Orders/DataStore.php index b3bc39a3048..741e2a5d2d1 100644 --- a/plugins/woocommerce/src/Admin/API/Reports/Orders/DataStore.php +++ b/plugins/woocommerce/src/Admin/API/Reports/Orders/DataStore.php @@ -118,7 +118,7 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { if ( $query_args['customer_type'] ) { $returning_customer = 'returning' === $query_args['customer_type'] ? 1 : 0; - $where_subquery[] = "{$order_stats_lookup_table}.returning_customer = ${returning_customer}"; + $where_subquery[] = "{$order_stats_lookup_table}.returning_customer = {$returning_customer}"; } $refund_subquery = $this->get_refund_subquery( $query_args ); diff --git a/plugins/woocommerce/src/Admin/API/Reports/Orders/Stats/DataStore.php b/plugins/woocommerce/src/Admin/API/Reports/Orders/Stats/DataStore.php index b4661107704..b2ef6217df0 100644 --- a/plugins/woocommerce/src/Admin/API/Reports/Orders/Stats/DataStore.php +++ b/plugins/woocommerce/src/Admin/API/Reports/Orders/Stats/DataStore.php @@ -378,7 +378,7 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { $this->update_intervals_sql_params( $query_args, $db_interval_count, $expected_interval_count, $table_name ); $this->interval_query->add_sql_clause( 'order_by', $this->get_sql_clause( 'order_by' ) ); $this->interval_query->add_sql_clause( 'limit', $this->get_sql_clause( 'limit' ) ); - $this->interval_query->add_sql_clause( 'select', ", MAX(${table_name}.date_created) AS datetime_anchor" ); + $this->interval_query->add_sql_clause( 'select', ", MAX({$table_name}.date_created) AS datetime_anchor" ); if ( '' !== $selections ) { $this->interval_query->add_sql_clause( 'select', ', ' . $selections ); } @@ -697,7 +697,10 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { $wpdb->query( $wpdb->prepare( - "UPDATE ${orders_stats_table} SET returning_customer = CASE WHEN order_id = %d THEN false ELSE true END WHERE customer_id = %d", + // phpcs:ignore Generic.Commenting.Todo.TaskFound + // TODO: use the %i placeholder to prepare the table name when available in the the minimum required WordPress version. + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + "UPDATE {$orders_stats_table} SET returning_customer = CASE WHEN order_id = %d THEN false ELSE true END WHERE customer_id = %d", $order_id, $customer_id ) diff --git a/plugins/woocommerce/src/Admin/API/Reports/Products/Stats/DataStore.php b/plugins/woocommerce/src/Admin/API/Reports/Products/Stats/DataStore.php index ec3513938fb..6883d35a891 100644 --- a/plugins/woocommerce/src/Admin/API/Reports/Products/Stats/DataStore.php +++ b/plugins/woocommerce/src/Admin/API/Reports/Products/Stats/DataStore.php @@ -188,7 +188,7 @@ class DataStore extends ProductsDataStore implements DataStoreInterface { $this->interval_query->add_sql_clause( 'order_by', $this->get_sql_clause( 'order_by' ) ); $this->interval_query->add_sql_clause( 'limit', $this->get_sql_clause( 'limit' ) ); - $this->interval_query->add_sql_clause( 'select', ", MAX(${table_name}.date_created) AS datetime_anchor" ); + $this->interval_query->add_sql_clause( 'select', ", MAX({$table_name}.date_created) AS datetime_anchor" ); if ( '' !== $selections ) { $this->interval_query->add_sql_clause( 'select', ', ' . $selections ); } diff --git a/plugins/woocommerce/src/Admin/API/Reports/SqlQuery.php b/plugins/woocommerce/src/Admin/API/Reports/SqlQuery.php index 83e78b32f8a..fdcd2a4cb57 100644 --- a/plugins/woocommerce/src/Admin/API/Reports/SqlQuery.php +++ b/plugins/woocommerce/src/Admin/API/Reports/SqlQuery.php @@ -30,6 +30,7 @@ class SqlQuery { 'having' => array(), 'limit' => array(), 'order_by' => array(), + 'union' => array(), ); /** * SQL clause merge filters. @@ -69,7 +70,7 @@ class SqlQuery { * @param string $type Clause type. * @param string $clause SQL clause. */ - protected function add_sql_clause( $type, $clause ) { + public function add_sql_clause( $type, $clause ) { if ( isset( $this->sql_clauses[ $type ] ) && ! empty( $clause ) ) { $this->sql_clauses[ $type ][] = $clause; } @@ -160,8 +161,11 @@ class SqlQuery { $group_by = $this->get_sql_clause( 'group_by', 'filtered' ); $having = $this->get_sql_clause( 'having', 'filtered' ); $order_by = $this->get_sql_clause( 'order_by', 'filtered' ); + $union = $this->get_sql_clause( 'union', 'filtered' ); - $statement = " + $statement = ''; + + $statement .= " SELECT {$this->get_sql_clause( 'select', 'filtered' )} FROM @@ -186,6 +190,13 @@ class SqlQuery { } } + if ( ! empty( $union ) ) { + $statement .= " + UNION + {$union} + "; + } + if ( ! empty( $order_by ) ) { $statement .= " ORDER BY @@ -212,6 +223,7 @@ class SqlQuery { 'having' => array(), 'limit' => array(), 'order_by' => array(), + 'union' => array(), ); } } diff --git a/plugins/woocommerce/src/Admin/API/Reports/Variations/DataStore.php b/plugins/woocommerce/src/Admin/API/Reports/Variations/DataStore.php index 4ca0bbf4f20..0915ad1390d 100644 --- a/plugins/woocommerce/src/Admin/API/Reports/Variations/DataStore.php +++ b/plugins/woocommerce/src/Admin/API/Reports/Variations/DataStore.php @@ -453,6 +453,19 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { $variations_query = $this->get_query_statement(); } else { + + $this->subquery->clear_sql_clause( 'select' ); + $this->subquery->add_sql_clause( 'select', $selections ); + + /** + * Experimental: Filter the Variations SQL query allowing extensions to add additional SQL clauses. + * + * @since 7.4.0 + * @param array $query_args Query parameters. + * @param SqlQuery $subquery Variations query class. + */ + apply_filters( 'experimental_woocommerce_analytics_variations_additional_clauses', $query_args, $this->subquery ); + /* phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared */ $db_records_count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM ( @@ -468,8 +481,6 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { return $data; } - $this->subquery->clear_sql_clause( 'select' ); - $this->subquery->add_sql_clause( 'select', $selections ); $this->subquery->add_sql_clause( 'order_by', $this->get_sql_clause( 'order_by' ) ); $this->subquery->add_sql_clause( 'limit', $this->get_sql_clause( 'limit' ) ); $variations_query = $this->subquery->get_query_statement(); diff --git a/plugins/woocommerce/src/Admin/API/Reports/Variations/Stats/DataStore.php b/plugins/woocommerce/src/Admin/API/Reports/Variations/Stats/DataStore.php index 5ea01ef6cd8..0b03d0df6ce 100644 --- a/plugins/woocommerce/src/Admin/API/Reports/Variations/Stats/DataStore.php +++ b/plugins/woocommerce/src/Admin/API/Reports/Variations/Stats/DataStore.php @@ -226,7 +226,7 @@ class DataStore extends VariationsDataStore implements DataStoreInterface { $this->interval_query->add_sql_clause( 'order_by', $this->get_sql_clause( 'order_by' ) ); $this->interval_query->add_sql_clause( 'limit', $this->get_sql_clause( 'limit' ) ); - $this->interval_query->add_sql_clause( 'select', ", MAX(${table_name}.date_created) AS datetime_anchor" ); + $this->interval_query->add_sql_clause( 'select', ", MAX({$table_name}.date_created) AS datetime_anchor" ); if ( '' !== $selections ) { $this->interval_query->add_sql_clause( 'select', ', ' . $selections ); } diff --git a/plugins/woocommerce/src/Admin/Features/Navigation/Menu.php b/plugins/woocommerce/src/Admin/Features/Navigation/Menu.php index 35ef7c80b9d..27d540fb6e2 100644 --- a/plugins/woocommerce/src/Admin/Features/Navigation/Menu.php +++ b/plugins/woocommerce/src/Admin/Features/Navigation/Menu.php @@ -463,10 +463,10 @@ class Menu { ? "&post_type={$taxonomy_object->object_type[0]}" : ''; $match_expression = 'term.php'; // Match term.php pages. - $match_expression .= "(?=.*[?|&]taxonomy=${taxonomy}(&|$|#))"; // Lookahead to match a taxonomy URL param. + $match_expression .= "(?=.*[?|&]taxonomy={$taxonomy}(&|$|#))"; // Lookahead to match a taxonomy URL param. $match_expression .= '|'; // Or. $match_expression .= 'edit-tags.php'; // Match edit-tags.php pages. - $match_expression .= "(?=.*[?|&]taxonomy=${taxonomy}(&|$|#))"; // Lookahead to match a taxonomy URL param. + $match_expression .= "(?=.*[?|&]taxonomy={$taxonomy}(&|$|#))"; // Lookahead to match a taxonomy URL param. return array( 'default' => array_merge( diff --git a/plugins/woocommerce/src/Admin/Features/NewProductManagementExperience.php b/plugins/woocommerce/src/Admin/Features/NewProductManagementExperience.php index 8480f1b0be9..edb87e32427 100644 --- a/plugins/woocommerce/src/Admin/Features/NewProductManagementExperience.php +++ b/plugins/woocommerce/src/Admin/Features/NewProductManagementExperience.php @@ -22,7 +22,12 @@ class NewProductManagementExperience { * Constructor */ public function __construct() { + if ( ! Features::is_enabled( 'new-product-management-experience' ) ) { + return; + } + add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_styles' ) ); + add_action( 'get_edit_post_link', array( $this, 'update_edit_product_link' ), 10, 2 ); } /** @@ -43,4 +48,25 @@ class NewProductManagementExperience { do_action( 'enqueue_block_editor_assets' ); } + /** + * Update the edit product links when the new experience is enabled. + * + * @param string $link The edit link. + * @param int $post_id Post ID. + * @return string + */ + public function update_edit_product_link( $link, $post_id ) { + $product = wc_get_product( $post_id ); + + if ( ! $product ) { + return $link; + } + + if ( $product->get_type() === 'simple' ) { + return admin_url( 'admin.php?page=wc-admin&path=/product/' . $product->get_id() ); + } + + return $link; + } + } diff --git a/plugins/woocommerce/src/Admin/Marketing/MarketingChannelInterface.php b/plugins/woocommerce/src/Admin/Marketing/MarketingChannelInterface.php index 6794c4b4259..5bdf72194c6 100644 --- a/plugins/woocommerce/src/Admin/Marketing/MarketingChannelInterface.php +++ b/plugins/woocommerce/src/Admin/Marketing/MarketingChannelInterface.php @@ -15,6 +15,7 @@ namespace Automattic\WooCommerce\Admin\Marketing; interface MarketingChannelInterface { public const PRODUCT_LISTINGS_NOT_APPLICABLE = 'not-applicable'; public const PRODUCT_LISTINGS_SYNC_IN_PROGRESS = 'sync-in-progress'; + public const PRODUCT_LISTINGS_SYNC_FAILED = 'sync-failed'; public const PRODUCT_LISTINGS_SYNCED = 'synced'; /** diff --git a/plugins/woocommerce/src/Internal/Admin/Orders/Edit.php b/plugins/woocommerce/src/Internal/Admin/Orders/Edit.php index c1df8e59130..9ae7e049508 100644 --- a/plugins/woocommerce/src/Internal/Admin/Orders/Edit.php +++ b/plugins/woocommerce/src/Internal/Admin/Orders/Edit.php @@ -230,13 +230,6 @@ class Edit { */ $messages = apply_filters( 'woocommerce_order_updated_messages', array() ); - /** - * Backward compatibility for displaying messages using the post fields. - * - * @since 7.4.0. (Although available earlier by the posts based screen). - */ - $messages = apply_filters( 'post_updated_messages', $messages ); - $message = $this->message; if ( isset( $_GET['message'] ) ) { $message = absint( $_GET['message'] ); diff --git a/plugins/woocommerce/src/Internal/Admin/ProductForm/Section.php b/plugins/woocommerce/src/Internal/Admin/ProductForm/Section.php index 049be3f4ef4..b86512bdb7a 100644 --- a/plugins/woocommerce/src/Internal/Admin/ProductForm/Section.php +++ b/plugins/woocommerce/src/Internal/Admin/ProductForm/Section.php @@ -54,28 +54,6 @@ class Section extends Component { $this->title = $additional_args['title']; } - /** - * Field arguments. - * - * @return array - */ - public function get_arguments() { - return $this->additional_args; - } - - /** - * Get the section as JSON. - * - * @return array - */ - public function get_json() { - return array( - 'id' => $this->get_id(), - 'plugin_id' => $this->get_plugin_id(), - 'arguments' => $this->get_arguments(), - ); - } - /** * Get missing arguments of args array. * diff --git a/plugins/woocommerce/src/Internal/Admin/ProductReviews/Reviews.php b/plugins/woocommerce/src/Internal/Admin/ProductReviews/Reviews.php index 426aefc538f..22109694091 100644 --- a/plugins/woocommerce/src/Internal/Admin/ProductReviews/Reviews.php +++ b/plugins/woocommerce/src/Internal/Admin/ProductReviews/Reviews.php @@ -533,12 +533,7 @@ class Reviews { $comment = get_comment( $comment_id ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited } - $is_reply = false; - - if ( isset( $comment->comment_parent ) && $comment->comment_parent > 0 ) { - $is_reply = true; - $comment = get_comment( $comment->comment_parent ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited - } + $is_reply = isset( $comment->comment_parent ) && $comment->comment_parent > 0; // Only replace the translated text if we are editing a comment left on a product (ie. a review). if ( isset( $comment->comment_post_ID ) && get_post_type( $comment->comment_post_ID ) === 'product' ) { diff --git a/plugins/woocommerce/src/Internal/DataStores/Orders/CustomOrdersTableController.php b/plugins/woocommerce/src/Internal/DataStores/Orders/CustomOrdersTableController.php index d38305c2e8c..3ceeb399229 100644 --- a/plugins/woocommerce/src/Internal/DataStores/Orders/CustomOrdersTableController.php +++ b/plugins/woocommerce/src/Internal/DataStores/Orders/CustomOrdersTableController.php @@ -146,7 +146,11 @@ class CustomOrdersTableController { $class_and_method = ( new \ReflectionClass( $this ) )->getShortName() . '::' . __FUNCTION__; wc_doing_it_wrong( $class_and_method, - __( "${class_and_method}: The visibility of the custom orders table feature is now handled by the WooCommerce features engine. See the FeaturesController class, or go to WooCommerce - Settings - Advanced - Features.", 'woocommerce' ), + sprintf( + // translators: %1$s the name of the class and method used. + __( '%1$s: The visibility of the custom orders table feature is now handled by the WooCommerce features engine. See the FeaturesController class, or go to WooCommerce - Settings - Advanced - Features.', 'woocommerce' ), + $class_and_method + ), '7.0' ); } @@ -160,7 +164,11 @@ class CustomOrdersTableController { $class_and_method = ( new \ReflectionClass( $this ) )->getShortName() . '::' . __FUNCTION__; wc_doing_it_wrong( $class_and_method, - __( "${class_and_method}: The visibility of the custom orders table feature is now handled by the WooCommerce features engine. See the FeaturesController class, or go to WooCommerce - Settings - Advanced - Features.", 'woocommerce' ), + sprintf( + // translators: %1$s the name of the class and method used. + __( '%1$s: The visibility of the custom orders table feature is now handled by the WooCommerce features engine. See the FeaturesController class, or go to WooCommerce - Settings - Advanced - Features.', 'woocommerce' ), + $class_and_method + ), '7.0' ); } diff --git a/plugins/woocommerce/src/Internal/DownloadPermissionsAdjuster.php b/plugins/woocommerce/src/Internal/DownloadPermissionsAdjuster.php index c2bbac1c030..f2f3adb5949 100644 --- a/plugins/woocommerce/src/Internal/DownloadPermissionsAdjuster.php +++ b/plugins/woocommerce/src/Internal/DownloadPermissionsAdjuster.php @@ -6,6 +6,7 @@ namespace Automattic\WooCommerce\Internal; use Automattic\WooCommerce\Proxies\LegacyProxy; +use WC_Product; defined( 'ABSPATH' ) || exit; @@ -99,7 +100,21 @@ class DownloadPermissionsAdjuster { $children_with_downloads = array(); foreach ( $children_ids as $child_id ) { - $child = wc_get_product( $child_id ); + $child = wc_get_product( $child_id ); + + // Ensure we have a valid child product. + if ( ! $child instanceof WC_Product ) { + wc_get_logger()->warning( + sprintf( + /* translators: 1: child product ID 2: parent product ID. */ + __( 'Unable to load child product %1$d while adjusting download permissions for product %2$d.', 'woocommerce' ), + $child_id, + $product_id + ) + ); + continue; + } + $children_with_downloads[ $child_id ] = $this->get_download_files_and_permissions( $child ); } @@ -154,7 +169,7 @@ class DownloadPermissionsAdjuster { 'file' => $file, 'data' => (array) $permission->data, ); - $result['permission_data_by_file_order_user'][ "${file}:${permission_data['user_id']}:${permission_data['order_id']}" ] = $data; + $result['permission_data_by_file_order_user'][ "{$file}:{$permission_data['user_id']}:{$permission_data['order_id']}" ] = $data; } } diff --git a/plugins/woocommerce/src/Internal/Features/FeaturesController.php b/plugins/woocommerce/src/Internal/Features/FeaturesController.php index 5fe17a80970..9adc5508ce6 100644 --- a/plugins/woocommerce/src/Internal/Features/FeaturesController.php +++ b/plugins/woocommerce/src/Internal/Features/FeaturesController.php @@ -404,7 +404,7 @@ class FeaturesController { return NewProductManagementExperience::TOGGLE_OPTION_NAME; } - return "woocommerce_feature_${feature_id}_enabled"; + return "woocommerce_feature_{$feature_id}_enabled"; } /** diff --git a/plugins/woocommerce/src/Utilities/FeaturesUtil.php b/plugins/woocommerce/src/Utilities/FeaturesUtil.php index 6acf0f29918..0eedde641f4 100644 --- a/plugins/woocommerce/src/Utilities/FeaturesUtil.php +++ b/plugins/woocommerce/src/Utilities/FeaturesUtil.php @@ -59,7 +59,7 @@ class FeaturesUtil { if ( ! $plugin_id ) { $logger = wc_get_logger(); - $logger->error( "FeaturesUtil::declare_compatibility: ${plugin_file} is not a known WordPress plugin." ); + $logger->error( "FeaturesUtil::declare_compatibility: {$plugin_file} is not a known WordPress plugin." ); return false; } diff --git a/plugins/woocommerce/templates/global/quantity-input.php b/plugins/woocommerce/templates/global/quantity-input.php index 5c346ecd307..07f865bf071 100644 --- a/plugins/woocommerce/templates/global/quantity-input.php +++ b/plugins/woocommerce/templates/global/quantity-input.php @@ -12,7 +12,10 @@ * * @see https://docs.woocommerce.com/document/template-structure/ * @package WooCommerce\Templates - * @version 7.2.1 + * @version 7.4.0 + * + * @var bool $readonly If the input should be set to readonly mode. + * @var string $type The input type attribute. */ defined( 'ABSPATH' ) || exit; @@ -20,13 +23,6 @@ defined( 'ABSPATH' ) || exit; /* translators: %s: Quantity. */ $label = ! empty( $args['product_name'] ) ? sprintf( esc_html__( '%s quantity', 'woocommerce' ), wp_strip_all_tags( $args['product_name'] ) ) : esc_html__( 'Quantity', 'woocommerce' ); -// In some cases we wish to display the quantity but not allow for it to be changed. -if ( $max_value && $min_value === $max_value ) { - $is_readonly = true; - $input_value = $min_value; -} else { - $is_readonly = false; -} ?>
    + type="" + id="" class="" name="" @@ -49,7 +45,7 @@ if ( $max_value && $min_value === $max_value ) { size="4" min="" max="" - + step="" placeholder="" inputmode="" diff --git a/plugins/woocommerce/tests/Tools/CodeHacking/Hacks/FunctionsMockerHack.php b/plugins/woocommerce/tests/Tools/CodeHacking/Hacks/FunctionsMockerHack.php index 91f6b3e535a..cf3fc83c6ab 100644 --- a/plugins/woocommerce/tests/Tools/CodeHacking/Hacks/FunctionsMockerHack.php +++ b/plugins/woocommerce/tests/Tools/CodeHacking/Hacks/FunctionsMockerHack.php @@ -35,6 +35,13 @@ use ReflectionClass; * executed inside tests (and thus the above example won't stack-overflow). */ final class FunctionsMockerHack extends CodeHack { + /** + * An array containing the names of the functions that will become mockable. + * + * @var array + */ + private $mockable_functions; + /** * Tokens that precede a non-standalone-function identifier. * diff --git a/plugins/woocommerce/tests/Tools/CodeHacking/Hacks/StaticMockerHack.php b/plugins/woocommerce/tests/Tools/CodeHacking/Hacks/StaticMockerHack.php index 61fd4a68d90..65b5b334aca 100644 --- a/plugins/woocommerce/tests/Tools/CodeHacking/Hacks/StaticMockerHack.php +++ b/plugins/woocommerce/tests/Tools/CodeHacking/Hacks/StaticMockerHack.php @@ -35,6 +35,12 @@ namespace Automattic\WooCommerce\Testing\Tools\CodeHacking\Hacks; * executed inside tests (and thus the above example won't stack-overflow). */ final class StaticMockerHack extends CodeHack { + /** + * An associative array of class name => array of class methods. + * + * @var array + */ + private $mockable_classes; /** * @var StaticMockerHack Holds the only existing instance of the class. diff --git a/plugins/woocommerce/tests/api-core-tests/tests/settings/settings-crud.test.js b/plugins/woocommerce/tests/api-core-tests/tests/settings/settings-crud.test.js index fd643ad419f..d706dbab89e 100644 --- a/plugins/woocommerce/tests/api-core-tests/tests/settings/settings-crud.test.js +++ b/plugins/woocommerce/tests/api-core-tests/tests/settings/settings-crud.test.js @@ -1368,10 +1368,10 @@ test.describe('Settings API tests: CRUD', () => { expect.objectContaining({ "id": "woocommerce_email_header_image", "label": "Header image", - "description": "URL to an image you want to show in the email header. Upload images using the media uploader (Admin > Media).", + "description": "Paste the URL of an image you want to show in the email header. Upload images using the media uploader (Media > Add New).", "type": "text", "default": "", - "tip": "URL to an image you want to show in the email header. Upload images using the media uploader (Admin > Media).", + "tip": "Paste the URL of an image you want to show in the email header. Upload images using the media uploader (Media > Add New).", "value": "", }) ])); diff --git a/plugins/woocommerce/tests/legacy/unit-tests/order/class-wc-tests-order-functions.php b/plugins/woocommerce/tests/legacy/unit-tests/order/class-wc-tests-order-functions.php index eb110323302..4df224a5e58 100644 --- a/plugins/woocommerce/tests/legacy/unit-tests/order/class-wc-tests-order-functions.php +++ b/plugins/woocommerce/tests/legacy/unit-tests/order/class-wc-tests-order-functions.php @@ -162,6 +162,12 @@ class WC_Tests_Order_Functions extends WC_Unit_Test_Case { * @group test */ public function test_wc_get_order() { + global $post; + global $theorder; + + // phpcs:disable WordPress.WP.GlobalVariablesOverride.Prohibited + $original_post = $post; + $original_theorder = $theorder; $order = WC_Helper_Order::create_order(); @@ -178,11 +184,30 @@ class WC_Tests_Order_Functions extends WC_Unit_Test_Case { $post = $this->factory->post->create_and_get( array( 'post_type' => 'post' ) ); $this->assertFalse( wc_get_order( $post->ID ) ); + // Assert the return when $the_order args is a random (incorrect) id. + $this->assertFalse( wc_get_order( 123456 ) ); + // Assert the return when $the_order args is false. $this->assertFalse( wc_get_order( false ) ); - // Assert the return when $the_order args is a random (incorrect) id. - $this->assertFalse( wc_get_order( 123456 ) ); + $post = get_post( $order->get_id() ); + $this->assertInstanceOf( + 'WC_Order', + wc_get_order(), + 'If no order ID is specified, wc_get_order() will use the global $post object to try and determine the current order.' + ); + + unset( $post ); + $theorder = $order; + $this->assertInstanceOf( + 'WC_Order', + wc_get_order(), + 'If no order ID is specified, wc_get_order() will use the global $theorder object to try and determine the current order.' + ); + + $post = $original_post; + $theorder = $original_theorder; + // phpcs:enable WordPress.WP.GlobalVariablesOverride.Prohibited } /** diff --git a/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/products.php b/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/products.php index dd78f483623..552a54c4f75 100644 --- a/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/products.php +++ b/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/products.php @@ -646,7 +646,7 @@ class WC_Tests_API_Product extends WC_REST_Unit_Test_Case { $response = $this->server->dispatch( $request ); $data = $response->get_data(); $properties = $data['schema']['properties']; - $this->assertEquals( 67, count( $properties ) ); + $this->assertEquals( 69, count( $properties ) ); } /** diff --git a/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-dashboard-setup-test.php b/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-dashboard-setup-test.php index 1ef4c8c4ae6..637f4cdeaf5 100644 --- a/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-dashboard-setup-test.php +++ b/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-dashboard-setup-test.php @@ -165,7 +165,7 @@ class WC_Admin_Dashboard_Setup_Test extends WC_Unit_Test_Case { ); foreach ( $required_strings as $required_string ) { - $this->assertRegexp( "/${required_string}/", $html ); + $this->assertRegexp( "/{$required_string}/", $html ); } } @@ -191,7 +191,7 @@ class WC_Admin_Dashboard_Setup_Test extends WC_Unit_Test_Case { if ( $completed_tasks_count === $tasks_count ) { $this->assertEmpty( $this->get_widget_output() ); } else { - $this->assertRegexp( "/Step ${step_number} of 6/", $this->get_widget_output() ); + $this->assertRegexp( "/Step {$step_number} of 6/", $this->get_widget_output() ); } } } diff --git a/plugins/woocommerce/tests/php/includes/settings/class-wc-settings-example.php b/plugins/woocommerce/tests/php/includes/settings/class-wc-settings-example.php index 49a0bfbf965..a685a6f514c 100644 --- a/plugins/woocommerce/tests/php/includes/settings/class-wc-settings-example.php +++ b/plugins/woocommerce/tests/php/includes/settings/class-wc-settings-example.php @@ -26,7 +26,7 @@ class WC_Settings_Example extends WC_Settings_Page { } protected function get_settings_for_section_core( $section_id ) { - return array( "${section_id}_key" => "${section_id}_value" ); + return array( "{$section_id}_key" => "{$section_id}_value" ); } protected function get_own_sections() { diff --git a/plugins/woocommerce/tests/php/src/Internal/Traits/AccessiblePrivateMethodsTest.php b/plugins/woocommerce/tests/php/src/Internal/Traits/AccessiblePrivateMethodsTest.php index 32a14ea7297..31679b6ac25 100644 --- a/plugins/woocommerce/tests/php/src/Internal/Traits/AccessiblePrivateMethodsTest.php +++ b/plugins/woocommerce/tests/php/src/Internal/Traits/AccessiblePrivateMethodsTest.php @@ -419,8 +419,8 @@ class AccessiblePrivateMethodsTest extends \WC_Unit_Test_Case { use AccessiblePrivateMethods; }; - $method_name = "add_${action_or_filter}"; - $proper_method_name = "add_static_${action_or_filter}"; + $method_name = "add_{$action_or_filter}"; + $proper_method_name = "add_static_{$action_or_filter}"; $this->expectException( \Error::class ); $this->expectExceptionMessage( get_class( $sut ) . '::' . "$method_name can't be called statically, did you mean '$proper_method_name'?" ); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dbcbbc7aa92..f69f4a8dfa2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1862,6 +1862,7 @@ importers: tools/cli-core: specifiers: '@tsconfig/node16': ^1.0.3 + '@types/uuid': ^9.0.0 chalk: ^4.1.2 dotenv: ^10.0.0 ora: ^5.4.1 @@ -1874,10 +1875,11 @@ importers: dotenv: 10.0.0 ora: 5.4.1 simple-git: 3.14.0 - ts-node: 10.9.1_suuodkax7fygvcgfx5vhk45yei + ts-node: 10.9.1_vqcafhj4xvr2nzknlrdklk55zm uuid: 8.3.2 devDependencies: '@tsconfig/node16': 1.0.3 + '@types/uuid': 9.0.0 typescript: 4.8.4 tools/code-analyzer: @@ -1996,7 +1998,12 @@ importers: '@commander-js/extra-typings': ^0.1.0 '@octokit/rest': ^19.0.4 '@tsconfig/node16': ^1.0.3 + '@types/ejs': ^3.1.1 '@types/express': ^4.17.13 + '@types/lodash.shuffle': ^4.2.7 + '@types/node': ^18.11.18 + '@types/node-fetch': ^2.6.2 + '@types/semver': ^7.3.10 cli-core: workspace:* code-analyzer: workspace:* commander: 9.4.0 @@ -2024,10 +2031,15 @@ importers: node-fetch: 2.6.7 open: 8.4.0 semver: 7.3.7 - ts-node: 10.9.1_suuodkax7fygvcgfx5vhk45yei + ts-node: 10.9.1_vqcafhj4xvr2nzknlrdklk55zm devDependencies: '@tsconfig/node16': 1.0.3 + '@types/ejs': 3.1.1 '@types/express': 4.17.14 + '@types/lodash.shuffle': 4.2.7 + '@types/node': 18.11.18 + '@types/node-fetch': 2.6.2 + '@types/semver': 7.3.12 typescript: 4.8.4 tools/storybook: @@ -2108,7 +2120,7 @@ importers: express: 4.18.1 ora: 5.4.1 semver: 7.3.7 - ts-node: 10.9.1_suuodkax7fygvcgfx5vhk45yei + ts-node: 10.9.1_vqcafhj4xvr2nzknlrdklk55zm devDependencies: '@tsconfig/node16': 1.0.3 '@types/express': 4.17.14 @@ -3347,7 +3359,7 @@ packages: dependencies: '@babel/core': 7.12.9 '@babel/helper-create-class-features-plugin': 7.17.6_@babel+core@7.12.9 - '@babel/helper-plugin-utils': 7.19.0 + '@babel/helper-plugin-utils': 7.18.9 transitivePeerDependencies: - supports-color dev: true @@ -3360,7 +3372,7 @@ packages: dependencies: '@babel/core': 7.16.12 '@babel/helper-create-class-features-plugin': 7.17.6_@babel+core@7.16.12 - '@babel/helper-plugin-utils': 7.19.0 + '@babel/helper-plugin-utils': 7.18.9 transitivePeerDependencies: - supports-color dev: false @@ -6320,9 +6332,9 @@ packages: '@babel/core': ^7.0.0-0 dependencies: '@babel/core': 7.16.12 - '@babel/helper-module-imports': 7.18.6 - '@babel/helper-plugin-utils': 7.19.0 - babel-plugin-polyfill-corejs2: 0.3.3_@babel+core@7.16.12 + '@babel/helper-module-imports': 7.16.0 + '@babel/helper-plugin-utils': 7.14.5 + babel-plugin-polyfill-corejs2: 0.3.0_@babel+core@7.16.12 babel-plugin-polyfill-corejs3: 0.4.0_@babel+core@7.16.12 babel-plugin-polyfill-regenerator: 0.3.0_@babel+core@7.16.12 semver: 6.3.0 @@ -6337,9 +6349,9 @@ packages: '@babel/core': ^7.0.0-0 dependencies: '@babel/core': 7.17.8 - '@babel/helper-module-imports': 7.18.6 - '@babel/helper-plugin-utils': 7.19.0 - babel-plugin-polyfill-corejs2: 0.3.3_@babel+core@7.17.8 + '@babel/helper-module-imports': 7.16.0 + '@babel/helper-plugin-utils': 7.14.5 + babel-plugin-polyfill-corejs2: 0.3.0_@babel+core@7.17.8 babel-plugin-polyfill-corejs3: 0.4.0_@babel+core@7.17.8 babel-plugin-polyfill-regenerator: 0.3.0_@babel+core@7.17.8 semver: 6.3.0 @@ -7251,8 +7263,8 @@ packages: '@babel/core': ^7.0.0-0 dependencies: '@babel/core': 7.16.12 - '@babel/helper-plugin-utils': 7.19.0 - '@babel/helper-validator-option': 7.18.6 + '@babel/helper-plugin-utils': 7.18.9 + '@babel/helper-validator-option': 7.16.7 '@babel/plugin-transform-typescript': 7.16.8_@babel+core@7.16.12 transitivePeerDependencies: - supports-color @@ -8043,7 +8055,7 @@ packages: engines: {node: '>= 10.14.2'} dependencies: '@jest/types': 26.6.2 - '@types/node': 17.0.21 + '@types/node': 18.11.18 chalk: 4.1.2 jest-message-util: 26.6.2 jest-util: 26.6.2 @@ -9789,7 +9801,7 @@ packages: resolution: {integrity: sha512-OkIJpiU2fz6HOJujhlhfIGrc8hB4ibqtf7nnbJQDerG0BqwZCfmgtK5sWzZ0TkXVRBKD5MpLrTmCYyMxoMCgPw==} engines: {node: '>= 8.9.0', npm: '>= 5.5.1'} dependencies: - '@types/node': 17.0.21 + '@types/node': 18.11.18 dev: false /@slack/logger/3.0.0: @@ -9816,7 +9828,7 @@ packages: '@slack/logger': 2.0.0 '@slack/types': 1.10.0 '@types/is-stream': 1.1.0 - '@types/node': 17.0.21 + '@types/node': 18.11.18 axios: 0.21.4 eventemitter3: 3.1.2 form-data: 2.5.1 @@ -13118,7 +13130,7 @@ packages: resolution: {integrity: sha512-ALYone6pm6QmwZoAgeyNksccT9Q4AWZQ6PvfwR37GT6r6FWUPguq6sUmNGSMV2Wr761oQoBxwGGa6DR5o1DC9g==} dependencies: '@types/connect': 3.4.35 - '@types/node': 17.0.21 + '@types/node': 18.11.18 dev: true /@types/cacheable-request/6.0.2: @@ -13151,7 +13163,7 @@ packages: /@types/connect/3.4.35: resolution: {integrity: sha512-cdeYyv4KWoEgpBISTxWvqYsVy444DOqehiF3fM3ne10AmJ62RSyNkUnxMJXHQWRQQX2eR94m5y1IZyDwBjV9FQ==} dependencies: - '@types/node': 17.0.21 + '@types/node': 18.11.18 dev: true /@types/cookie/0.4.1: @@ -13180,6 +13192,10 @@ packages: '@types/trusted-types': 2.0.2 dev: true + /@types/ejs/3.1.1: + resolution: {integrity: sha512-RQul5wEfY7BjWm0sYY86cmUN/pcXWGyVxWX93DFFJvcrxax5zKlieLwA3T77xJGwNcZW0YW6CYG70p1m8xPFmA==} + dev: true + /@types/eslint-scope/3.7.3: resolution: {integrity: sha512-PB3ldyrcnAicT35TWPs5IcwKD8S333HMaa2VVv4+wdvebJkjWuW/xESoB8IwRcog8HYVYamb1g/R31Qv5Bx03g==} dependencies: @@ -13209,7 +13225,7 @@ packages: /@types/express-serve-static-core/4.17.31: resolution: {integrity: sha512-DxMhY+NAsTwMMFHBTtJFNp5qiHKJ7TeqOo23zVEM9alT1Ml27Q3xcTH0xwxn7Q0BbMcVEJOs/7aQtUWupUQN3Q==} dependencies: - '@types/node': 17.0.21 + '@types/node': 18.11.18 '@types/qs': 6.9.7 '@types/range-parser': 1.2.4 dev: true @@ -13227,7 +13243,7 @@ packages: resolution: {integrity: sha512-ZUxbzKl0IfJILTS6t7ip5fQQM/J3TJYubDm3nMbgubNNYS62eXeUpoLUC8/7fJNiFYHTrGPQn7hspDUzIHX3UA==} dependencies: '@types/minimatch': 3.0.5 - '@types/node': 17.0.21 + '@types/node': 18.11.18 dev: true /@types/graceful-fs/4.1.5: @@ -13312,6 +13328,12 @@ packages: dependencies: '@types/node': 17.0.21 + /@types/lodash.shuffle/4.2.7: + resolution: {integrity: sha512-b+K0NBpB4WcNoQTfifuTmi5nm5mJXRw9DBdbFfBr1q1+EVoTKkClDxq/7r1sq2GZcRelMFRsFcGGHrHQgxRySg==} + dependencies: + '@types/lodash': 4.14.184 + dev: true + /@types/lodash/4.14.180: resolution: {integrity: sha512-XOKXa1KIxtNXgASAnwj7cnttJxS4fksBRywK/9LzRV5YxrF80BXZIGeQSuoESQ/VkUj30Ae0+YcuHc15wJCB2g==} @@ -13357,7 +13379,14 @@ packages: /@types/node-fetch/2.6.1: resolution: {integrity: sha512-oMqjURCaxoSIsHSr1E47QHzbmzNR5rK8McHuNb11BOM9cHcIK3Avy0s/b2JlXHoQGTYS3NsvWzV1M0iK7l0wbA==} dependencies: - '@types/node': 17.0.21 + '@types/node': 18.11.18 + form-data: 3.0.1 + dev: true + + /@types/node-fetch/2.6.2: + resolution: {integrity: sha512-DHqhlq5jeESLy19TYhLakJ07kNumXWjcDdxXsLUMJZ6ue8VZJj4kLPQVE/2mdHh3xZziNF1xppu5lwmS53HR+A==} + dependencies: + '@types/node': 18.11.18 form-data: 3.0.1 dev: true @@ -13383,6 +13412,9 @@ packages: /@types/node/17.0.21: resolution: {integrity: sha512-DBZCJbhII3r90XbQxI8Y9IjjiiOGlZ0Hr32omXIZvwwZ7p4DMMXGrKXVyPfuoBOri9XNtL0UK69jYIBIsRX3QQ==} + /@types/node/18.11.18: + resolution: {integrity: sha512-DHQpWGjyQKSHj3ebjFI/wRKcqQcdR+MoFBygntYOZytCqNfkd2ZC4ARDJ2DQqhjH5p85Nnd3jhUJIXrszFX/JA==} + /@types/normalize-package-data/2.4.1: resolution: {integrity: sha512-Gj7cI7z+98M282Tqmp2K5EIsoouUEzbBJhQQzDE3jSIRk6r9gsz0oUokqIUR4u1R3dMHo0pDHM7sNOHyhulypw==} dev: true @@ -13527,7 +13559,7 @@ packages: resolution: {integrity: sha512-z5xyF6uh8CbjAu9760KDKsH2FcDxZ2tFCsA4HIMWE6IkiYMXfVoa+4f9KX+FN0ZLsaMw1WNG2ETLA6N+/YA+cg==} dependencies: '@types/mime': 3.0.1 - '@types/node': 17.0.21 + '@types/node': 18.11.18 dev: true /@types/sizzle/2.3.3: @@ -13583,11 +13615,15 @@ packages: /@types/uuid/8.3.4: resolution: {integrity: sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw==} + /@types/uuid/9.0.0: + resolution: {integrity: sha512-kr90f+ERiQtKWMz5rP32ltJ/BtULDI5RVO0uavn1HQUOwjx0R1h0rnDYNL0CepF1zL5bSY6FISAfd9tOdDhU5Q==} + dev: true + /@types/vinyl/2.0.6: resolution: {integrity: sha512-ayJ0iOCDNHnKpKTgBG6Q6JOnHTj9zFta+3j2b8Ejza0e4cvRyMn0ZoLEmbPrTHe5YYRlDYPvPWVdV4cTaRyH7g==} dependencies: '@types/expect': 1.20.4 - '@types/node': 17.0.21 + '@types/node': 18.11.18 dev: true /@types/webpack-env/1.16.3: @@ -13597,7 +13633,7 @@ packages: /@types/webpack-sources/0.1.9: resolution: {integrity: sha512-bvzMnzqoK16PQIC8AYHNdW45eREJQMd6WG/msQWX5V2+vZmODCOPb4TJcbgRljTZZTwTM4wUMcsI8FftNA7new==} dependencies: - '@types/node': 17.0.21 + '@types/node': 18.11.18 '@types/source-list-map': 0.1.2 source-map: 0.6.1 dev: true @@ -13767,7 +13803,7 @@ packages: resolution: {integrity: sha512-8uALY5LTvSuHgloDVUvWP3pIauILm+8/0pDMokuDYIoNsOkSwd5AiHBTSEJjKTDcZr5z8UpgOWZkxBF4iJftoA==} requiresBuild: true dependencies: - '@types/node': 17.0.21 + '@types/node': 18.11.18 dev: true optional: true @@ -18859,19 +18895,6 @@ packages: - supports-color dev: true - /babel-plugin-polyfill-corejs2/0.3.3_@babel+core@7.16.12: - resolution: {integrity: sha512-8hOdmFYFSZhqg2C/JgLUQ+t52o5nirNwaWM2B9LWteozwIvM14VSwdsCAUET10qT+kmySAlseadmfeeSWFCy+Q==} - peerDependencies: - '@babel/core': ^7.0.0-0 - dependencies: - '@babel/compat-data': 7.19.3 - '@babel/core': 7.16.12 - '@babel/helper-define-polyfill-provider': 0.3.3_@babel+core@7.16.12 - semver: 6.3.0 - transitivePeerDependencies: - - supports-color - dev: false - /babel-plugin-polyfill-corejs2/0.3.3_@babel+core@7.17.8: resolution: {integrity: sha512-8hOdmFYFSZhqg2C/JgLUQ+t52o5nirNwaWM2B9LWteozwIvM14VSwdsCAUET10qT+kmySAlseadmfeeSWFCy+Q==} peerDependencies: @@ -21243,7 +21266,7 @@ packages: postcss-value-parser: 4.2.0 schema-utils: 2.7.1 semver: 6.3.0 - webpack: 5.70.0 + webpack: 5.70.0_webpack-cli@3.3.12 /css-loader/5.2.7_webpack@5.70.0: resolution: {integrity: sha512-Q7mOvpBNBG7YrVGMxRxcBJZFL75o+cH2abNASdibkj/fffYD8qWbInZrD0S9ccI6vZclF3DsHE7njGlLtaHbhg==} @@ -28495,7 +28518,7 @@ packages: '@jest/environment': 26.6.2 '@jest/fake-timers': 26.6.2 '@jest/types': 26.6.2 - '@types/node': 17.0.21 + '@types/node': 18.11.18 jest-mock: 26.6.2 jest-util: 26.6.2 jsdom: 16.7.0 @@ -28758,7 +28781,7 @@ packages: '@jest/source-map': 26.6.2 '@jest/test-result': 26.6.2 '@jest/types': 26.6.2 - '@types/node': 17.0.21 + '@types/node': 18.11.18 chalk: 4.1.2 co: 4.6.0 expect: 26.6.2 @@ -29447,7 +29470,7 @@ packages: resolution: {integrity: sha512-S5wqyz0DXnNJPd/xfIzZ5Xnp1HrJWBczg8mMfMpN78OJ5eDxXyf+Ygld9wX1DnUWbIbhM1YDY95NjR4CBXkb2g==} engines: {node: '>= 10.14.2'} dependencies: - '@types/node': 17.0.21 + '@types/node': 18.11.18 graceful-fs: 4.2.9 /jest-serializer/27.5.1: @@ -29684,7 +29707,7 @@ packages: dependencies: '@jest/test-result': 26.6.2 '@jest/types': 26.6.2 - '@types/node': 17.0.21 + '@types/node': 18.11.18 ansi-escapes: 4.3.2 chalk: 4.1.2 jest-util: 26.6.2 @@ -36855,7 +36878,7 @@ packages: neo-async: 2.6.2 schema-utils: 3.1.1 semver: 7.3.5 - webpack: 5.70.0 + webpack: 5.70.0_webpack-cli@3.3.12 /sass-loader/12.6.0_sass@1.49.9+webpack@5.70.0: resolution: {integrity: sha512-oLTaH0YCtX4cfnJZxKSLAyglED0naiYfNG1iXfU5w1LNZ+ukoA5DtyDIN5zmKVZwYNJP4KRc5Y3hkWga+7tYfA==} @@ -38792,7 +38815,7 @@ packages: serialize-javascript: 6.0.0 source-map: 0.6.1 terser: 5.10.0_acorn@8.8.1 - webpack: 5.70.0 + webpack: 5.70.0_webpack-cli@3.3.12 transitivePeerDependencies: - acorn @@ -39295,7 +39318,7 @@ packages: yn: 3.1.1 dev: true - /ts-node/10.9.1_suuodkax7fygvcgfx5vhk45yei: + /ts-node/10.9.1_vqcafhj4xvr2nzknlrdklk55zm: resolution: {integrity: sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw==} hasBin: true peerDependencies: @@ -39314,7 +39337,7 @@ packages: '@tsconfig/node12': 1.0.11 '@tsconfig/node14': 1.0.3 '@tsconfig/node16': 1.0.3 - '@types/node': 17.0.21 + '@types/node': 18.11.18 acorn: 8.7.0 acorn-walk: 8.2.0 arg: 4.1.3 diff --git a/tools/cli-core/package.json b/tools/cli-core/package.json index 986dfbd8a02..21ce499f80a 100644 --- a/tools/cli-core/package.json +++ b/tools/cli-core/package.json @@ -12,6 +12,7 @@ }, "devDependencies": { "@tsconfig/node16": "^1.0.3", + "@types/uuid": "^9.0.0", "typescript": "^4.8.3" }, "dependencies": { diff --git a/tools/monorepo/check-changelogger-use.php b/tools/monorepo/check-changelogger-use.php index b2819ef2a48..b9f07dd3c83 100644 --- a/tools/monorepo/check-changelogger-use.php +++ b/tools/monorepo/check-changelogger-use.php @@ -70,9 +70,9 @@ if ( $verbose ) { */ function debug( ...$args ) { if ( getenv( 'CI' ) ) { - $args[0] = "\e[34m${args[0]}\e[0m\n"; + $args[0] = "\e[34m{$args[0]}\e[0m\n"; } else { - $args[0] = "\e[1;30m${args[0]}\e[0m\n"; + $args[0] = "\e[1;30m{$args[0]}\e[0m\n"; } fprintf( STDERR, ...$args ); } diff --git a/tools/release-posts/.prettierignore b/tools/release-posts/.prettierignore new file mode 100644 index 00000000000..6e551732dd9 --- /dev/null +++ b/tools/release-posts/.prettierignore @@ -0,0 +1 @@ +*.ejs diff --git a/tools/release-posts/lib/environment.ts b/tools/release-posts/lib/environment.ts index f683c13e963..93b871d27c8 100644 --- a/tools/release-posts/lib/environment.ts +++ b/tools/release-posts/lib/environment.ts @@ -3,7 +3,6 @@ */ import { Logger } from 'cli-core/src/logger'; - export const getEnvVar = ( varName: string, isRequired = false ) => { const value = process.env[ varName ]; diff --git a/tools/release-posts/lib/github-api.ts b/tools/release-posts/lib/github-api.ts index 3b844ee7d08..ebd3504f2f7 100644 --- a/tools/release-posts/lib/github-api.ts +++ b/tools/release-posts/lib/github-api.ts @@ -3,6 +3,10 @@ */ import { Octokit } from '@octokit/rest'; import shuffle from 'lodash.shuffle'; + +/** + * Internal dependencies + */ import { getEnvVar } from './environment'; export type ContributorData = { diff --git a/tools/release-posts/lib/logger.ts b/tools/release-posts/lib/logger.ts deleted file mode 100644 index 9f3553b8c76..00000000000 --- a/tools/release-posts/lib/logger.ts +++ /dev/null @@ -1,62 +0,0 @@ -/** - * External dependencies - */ -import ora, { Ora } from 'ora'; -import chalk from 'chalk'; - -/** - * Internal dependencies - */ -import { getEnvVar } from './environment'; - -const { log, error, warn } = console; -export class Logger { - private static lastSpinner: Ora | null; - private static get loggingLevel() { - return { - warn: 2, - silent: 1, - }[ getEnvVar( 'LOGGER_LEVEL' ) || 'warn' ] as number; - } - - static error( message: string ) { - Logger.failTask(); - error( chalk.red( message ) ); - process.exit( 1 ); - } - - static warn( message: string ) { - if ( Logger.loggingLevel >= 2 ) { - warn( chalk.yellow( message ) ); - } - } - - static notice( message: string ) { - if ( Logger.loggingLevel >= 1 ) { - log( chalk.green( message ) ); - } - } - - static startTask( message: string ) { - if ( Logger.loggingLevel >= 1 ) { - const spinner = ora( chalk.green( `${ message }...` ) ).start(); - Logger.lastSpinner = spinner; - } - } - - static failTask() { - if ( Logger.lastSpinner ) { - Logger.lastSpinner.fail( `${ Logger.lastSpinner.text } failed.` ); - Logger.lastSpinner = null; - } - } - - static endTask() { - if ( Logger.loggingLevel > 1 && Logger.lastSpinner ) { - Logger.lastSpinner.succeed( - `${ Logger.lastSpinner.text } complete.` - ); - Logger.lastSpinner = null; - } - } -} diff --git a/tools/release-posts/lib/render-template.ts b/tools/release-posts/lib/render-template.ts index bac0b561eb7..e9a77ffbf41 100644 --- a/tools/release-posts/lib/render-template.ts +++ b/tools/release-posts/lib/render-template.ts @@ -8,14 +8,14 @@ const TEMPLATE_DIR = join( __dirname, '..', 'templates' ); export const renderTemplate = ( templateFile: string, - templateData: unknown + templateData: ejs.Data ) => { return new Promise< string >( ( resolve, reject ) => { ejs.renderFile( join( TEMPLATE_DIR, templateFile ), templateData, {}, - function ( err: Error, str: string ) { + function ( err: Error | null, str: string ) { if ( err ) { reject( err ); } else { diff --git a/tools/release-posts/package.json b/tools/release-posts/package.json index 7fa345e8bdc..5aa9f43503a 100644 --- a/tools/release-posts/package.json +++ b/tools/release-posts/package.json @@ -4,6 +4,7 @@ "description": "Automate release post generation for Wordpress plugins", "main": " ", "scripts": { + "lint": "tsc --noEmit", "release-post": "node -r ts-node/register ./commands/release-post/index.ts" }, "author": "Automattic", @@ -14,7 +15,12 @@ }, "devDependencies": { "@tsconfig/node16": "^1.0.3", + "@types/ejs": "^3.1.1", "@types/express": "^4.17.13", + "@types/lodash.shuffle": "^4.2.7", + "@types/node": "^18.11.18", + "@types/node-fetch": "^2.6.2", + "@types/semver": "^7.3.10", "typescript": "^4.8.3" }, "dependencies": { diff --git a/tools/release-posts/templates/database.ejs b/tools/release-posts/templates/database.ejs index 6f5f9016bf2..de3fea44d6f 100644 --- a/tools/release-posts/templates/database.ejs +++ b/tools/release-posts/templates/database.ejs @@ -49,6 +49,7 @@ + <% } else { %>

    There are no database changes in this release.

    diff --git a/tools/release-posts/templates/oauth.ejs b/tools/release-posts/templates/oauth.ejs index 5228670644e..248ea63d171 100644 --- a/tools/release-posts/templates/oauth.ejs +++ b/tools/release-posts/templates/oauth.ejs @@ -3,5 +3,5 @@ Authentication Success - Authentication successful, please return the console to complete the process. + Authentication successful, please return to the console to complete the process. diff --git a/tools/release-posts/templates/release.ejs b/tools/release-posts/templates/release.ejs index 8b442aaddb1..00621bf1c3f 100644 --- a/tools/release-posts/templates/release.ejs +++ b/tools/release-posts/templates/release.ejs @@ -5,6 +5,7 @@

    We are pleased to announce the release of WooCommerce <%= displayVersion %>. This release should be backwards compatible with the previous version.

    +

    This release contains:

    diff --git a/tools/release-posts/templates/templates.ejs b/tools/release-posts/templates/templates.ejs index ec7d93ecf95..e5d1bd5019b 100644 --- a/tools/release-posts/templates/templates.ejs +++ b/tools/release-posts/templates/templates.ejs @@ -22,6 +22,7 @@ + <% } else { %>

    There are no template changes in this release.