Add product data views list to experimental product data views page (#51008)

* Add products data views list

* Add navigation on the left

* Update edit site package

* Fix styling

* Add changelog

* Add wp/icons package to syncpack exception list

* Delete some unused stuff and address types

* Add changelog

* Remove un needed css

* Remove dependency on edit-site package

* Fix custom status filters

* Make sure page size works with view config

* Remove use of canvasMode and navigation context as it is not needed

* Remove wordpress/dom from syncpack
This commit is contained in:
louwie17 2024-08-30 18:27:10 +02:00 committed by GitHub
parent e589fa87e1
commit 2890e16c86
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 1988 additions and 653 deletions

View File

@ -201,7 +201,9 @@
"@wordpress/interface", "@wordpress/interface",
"@wordpress/router", "@wordpress/router",
"@wordpress/edit-site", "@wordpress/edit-site",
"@wordpress/private-apis" "@wordpress/private-apis",
"@wordpress/dataviews",
"@wordpress/icons"
], ],
"packages": [ "packages": [
"@woocommerce/block-templates", "@woocommerce/block-templates",

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Add sidebar and dataviews list to the experimental dataviews products page.

View File

@ -56,6 +56,7 @@
"@wordpress/compose": "wp-6.0", "@wordpress/compose": "wp-6.0",
"@wordpress/core-data": "wp-6.0", "@wordpress/core-data": "wp-6.0",
"@wordpress/data": "wp-6.0", "@wordpress/data": "wp-6.0",
"@wordpress/dataviews": "^4.2.0",
"@wordpress/date": "wp-6.0", "@wordpress/date": "wp-6.0",
"@wordpress/deprecated": "wp-6.0", "@wordpress/deprecated": "wp-6.0",
"@wordpress/edit-post": "wp-6.0", "@wordpress/edit-post": "wp-6.0",
@ -64,7 +65,7 @@
"@wordpress/hooks": "wp-6.0", "@wordpress/hooks": "wp-6.0",
"@wordpress/html-entities": "wp-6.0", "@wordpress/html-entities": "wp-6.0",
"@wordpress/i18n": "wp-6.0", "@wordpress/i18n": "wp-6.0",
"@wordpress/icons": "wp-6.0", "@wordpress/icons": "10.6.0",
"@wordpress/interface": "wp-6.0", "@wordpress/interface": "wp-6.0",
"@wordpress/keyboard-shortcuts": "wp-6.0", "@wordpress/keyboard-shortcuts": "wp-6.0",
"@wordpress/keycodes": "wp-6.0", "@wordpress/keycodes": "wp-6.0",

View File

@ -0,0 +1,8 @@
export const LAYOUT_GRID = 'grid';
export const LAYOUT_TABLE = 'table';
export const LAYOUT_LIST = 'list';
export const OPERATOR_IS = 'is';
export const OPERATOR_IS_NOT = 'isNot';
export const OPERATOR_IS_ANY = 'isAny';
export const OPERATOR_IS_NONE = 'isNone';

View File

@ -13,12 +13,16 @@ import {
* Internal dependencies * Internal dependencies
*/ */
import { unlock } from '../lock-unlock'; import { unlock } from '../lock-unlock';
import useLayoutAreas from './router';
import { Layout } from './layout';
const { RouterProvider } = unlock( routerPrivateApis ); const { RouterProvider } = unlock( routerPrivateApis );
const { GlobalStylesProvider } = unlock( editorPrivateApis ); const { GlobalStylesProvider } = unlock( editorPrivateApis );
function ProductsLayout() { function ProductsLayout() {
return <div>Initial Products Layout</div>; // This ensures the edited entity id and type are initialized properly.
const route = useLayoutAreas();
return <Layout route={ route } />;
} }
export function ProductsApp() { export function ProductsApp() {

View File

@ -0,0 +1,118 @@
/**
* External dependencies
*/
import { createElement, Fragment, useRef } from '@wordpress/element';
import {
useViewportMatch,
useResizeObserver,
useReducedMotion,
} from '@wordpress/compose';
import { __ } from '@wordpress/i18n';
import {
// @ts-expect-error missing type.
EditorSnackbars,
// @ts-expect-error missing type.
privateApis as editorPrivateApis,
} from '@wordpress/editor';
// eslint-disable-next-line @woocommerce/dependency-group
import {
// @ts-expect-error missing type.
__unstableMotion as motion,
// @ts-expect-error missing type.
__unstableAnimatePresence as AnimatePresence,
} from '@wordpress/components';
/**
* Internal dependencies
*/
import SidebarContent from './sidebar';
import SiteHub from './site-hub';
import { Route } from './router';
import { unlock } from '../lock-unlock';
const { NavigableRegion } = unlock( editorPrivateApis );
const ANIMATION_DURATION = 0.3;
type LayoutProps = {
route: Route;
};
export function Layout( { route }: LayoutProps ) {
const [ fullResizer ] = useResizeObserver();
const toggleRef = useRef< HTMLAnchorElement >( null );
const isMobileViewport = useViewportMatch( 'medium', '<' );
const disableMotion = useReducedMotion();
const { key: routeKey, areas, widths } = route;
return (
<>
{ fullResizer }
<div className="edit-site-layout">
<div className="edit-site-layout__content">
{ /*
The NavigableRegion must always be rendered and not use
`inert` otherwise `useNavigateRegions` will fail.
*/ }
{ ( ! isMobileViewport || ! areas.mobile ) && (
<NavigableRegion
ariaLabel={ __( 'Navigation', 'woocommerce' ) }
className="edit-site-layout__sidebar-region"
>
<AnimatePresence>
<motion.div
initial={ { opacity: 0 } }
animate={ { opacity: 1 } }
exit={ { opacity: 0 } }
transition={ {
type: 'tween',
duration:
// Disable transition in mobile to emulate a full page transition.
disableMotion || isMobileViewport
? 0
: ANIMATION_DURATION,
ease: 'easeOut',
} }
className="edit-site-layout__sidebar"
>
<SiteHub
ref={ toggleRef }
isTransparent={ false }
/>
<SidebarContent routeKey={ routeKey }>
{ areas.sidebar }
</SidebarContent>
</motion.div>
</AnimatePresence>
</NavigableRegion>
) }
<EditorSnackbars />
{ ! isMobileViewport && areas.content && (
<div
className="edit-site-layout__area"
style={ {
maxWidth: widths?.content,
} }
>
{ areas.content }
</div>
) }
{ ! isMobileViewport && areas.edit && (
<div
className="edit-site-layout__area"
style={ {
maxWidth: widths?.edit,
} }
>
{ areas.edit }
</div>
) }
</div>
</div>
</>
);
}

View File

@ -0,0 +1,343 @@
/**
* External dependencies
*/
import { Action, DataViews, View } from '@wordpress/dataviews';
import {
createElement,
useState,
useMemo,
useCallback,
useEffect,
} from '@wordpress/element';
import { Product, ProductQuery } from '@woocommerce/data';
import { drawerRight } from '@wordpress/icons';
import { privateApis as routerPrivateApis } from '@wordpress/router';
import { __ } from '@wordpress/i18n';
import { useSelect } from '@wordpress/data';
import classNames from 'classnames';
import {
// @ts-expect-error missing types.
__experimentalHeading as Heading,
// @ts-expect-error missing types.
__experimentalText as Text,
// @ts-expect-error missing types.
__experimentalHStack as HStack,
// @ts-expect-error missing types.
__experimentalVStack as VStack,
FlexItem,
Button,
} from '@wordpress/components';
// @ts-expect-error missing type.
// eslint-disable-next-line @woocommerce/dependency-group
import { privateApis as editorPrivateApis } from '@wordpress/editor';
/**
* Internal dependencies
*/
import { unlock } from '../../lock-unlock';
import {
useDefaultViews,
defaultLayouts,
} from '../sidebar-dataviews/default-views';
import { LAYOUT_LIST, OPERATOR_IS } from '../constants';
const { NavigableRegion } = unlock( editorPrivateApis );
const { useHistory, useLocation } = unlock( routerPrivateApis );
const STATUSES = [
{ value: 'draft', label: __( 'Draft', 'woocommerce' ) },
{ value: 'future', label: __( 'Scheduled', 'woocommerce' ) },
{ value: 'private', label: __( 'Private', 'woocommerce' ) },
{ value: 'publish', label: __( 'Published', 'woocommerce' ) },
{ value: 'trash', label: __( 'Trash', 'woocommerce' ) },
];
/**
* TODO: auto convert some of the product editor blocks ( from the blocks directory ) to this format.
* The edit function should work relatively well with the edit from the blocks, the only difference is that the blocks rely on getEntityProp to get the value
*/
const fields = [
{
id: 'name',
label: __( 'Name', 'woocommerce' ),
enableHiding: false,
type: 'text',
render: function nameRender( { item }: { item: Product } ) {
return item.name;
},
},
{
id: 'sku',
label: __( 'SKU', 'woocommerce' ),
enableHiding: false,
enableSorting: false,
render: ( { item }: { item: Product } ) => {
return item.sku;
},
},
{
id: 'date',
label: __( 'Date', 'woocommerce' ),
render: ( { item }: { item: Product } ) => {
return <time>{ item.date_created }</time>;
},
},
{
label: __( 'Status', 'woocommerce' ),
id: 'status',
getValue: ( { item }: { item: Product } ) =>
STATUSES.find( ( { value } ) => value === item.status )?.label ??
item.status,
elements: STATUSES,
filterBy: {
operators: [ OPERATOR_IS ],
},
enableSorting: false,
},
];
export type ProductListProps = {
subTitle?: string;
className?: string;
hideTitleFromUI?: boolean;
postType?: string;
};
const PAGE_SIZE = 25;
const EMPTY_ARRAY: Product[] = [];
const EMPTY_ACTIONS_ARRAY: Action< Product >[] = [];
const getDefaultView = (
defaultViews: Array< { slug: string; view: View } >,
activeView: string
) => {
return defaultViews.find( ( { slug } ) => slug === activeView )?.view;
};
/**
* This function abstracts working with default & custom views by
* providing a [ state, setState ] tuple based on the URL parameters.
*
* Consumers use the provided tuple to work with state
* and don't have to deal with the specifics of default & custom views.
*
* @param {string} postType Post type to retrieve default views for.
* @return {Array} The [ state, setState ] tuple.
*/
function useView(
postType: string
): [ View, ( view: View ) => void, ( view: View ) => void ] {
const {
params: { activeView = 'all', isCustom = 'false', layout },
} = useLocation();
const history = useHistory();
const defaultViews = useDefaultViews( { postType } );
const [ view, setView ] = useState< View >( () => {
const initialView = getDefaultView( defaultViews, activeView ) ?? {
type: layout ?? LAYOUT_LIST,
};
const type = layout ?? initialView.type;
return {
...initialView,
type,
};
} );
const setViewWithUrlUpdate = useCallback(
( newView: View ) => {
const { params } = history.getLocationWithParams();
if ( newView.type === LAYOUT_LIST && ! params?.layout ) {
// Skip updating the layout URL param if
// it is not present and the newView.type is LAYOUT_LIST.
} else if ( newView.type !== params?.layout ) {
history.push( {
...params,
layout: newView.type,
} );
}
setView( newView );
},
[ history, isCustom ]
);
// When layout URL param changes, update the view type
// without affecting any other config.
useEffect( () => {
setView( ( prevView ) => ( {
...prevView,
type: layout ?? LAYOUT_LIST,
} ) );
}, [ layout ] );
// When activeView or isCustom URL parameters change, reset the view.
useEffect( () => {
const newView = getDefaultView( defaultViews, activeView );
if ( newView ) {
const type = layout ?? newView.type;
setView( {
...newView,
type,
} );
}
}, [ activeView, isCustom, layout, defaultViews ] );
return [ view, setViewWithUrlUpdate, setViewWithUrlUpdate ];
}
function getItemId( item: Product ) {
return item.id.toString();
}
export default function ProductList( {
subTitle,
className,
hideTitleFromUI = false,
}: ProductListProps ) {
const history = useHistory();
const location = useLocation();
const {
postId,
quickEdit = false,
postType = 'product',
isCustom,
activeView = 'all',
} = location.params;
const [ selection, setSelection ] = useState( [ postId ] );
const [ view, setView ] = useView( postType );
const queryParams = useMemo( () => {
const filters: Partial< ProductQuery > = {};
view.filters?.forEach( ( filter ) => {
if ( filter.field === 'status' ) {
filters.status = Array.isArray( filter.value )
? filter.value.join( ',' )
: filter.value;
}
} );
const orderby =
view.sort?.field === 'name' ? 'title' : view.sort?.field;
return {
per_page: view.perPage,
page: view.page,
order: view.sort?.direction,
orderby,
search: view.search,
...filters,
};
}, [ location.params, view ] );
const onChangeSelection = useCallback(
( items ) => {
setSelection( items );
history.push( {
...location.params,
postId: items.join( ',' ),
} );
},
[ history, location.params, view?.type ]
);
// TODO: Use the Woo data store to get all the products, as this doesn't contain all the product data.
const { records, totalCount, isLoading } = useSelect(
( select ) => {
const { getProducts, getProductsTotalCount, isResolving } =
select( 'wc/admin/products' );
return {
records: getProducts( queryParams ) as Product[],
totalCount: getProductsTotalCount( queryParams ) as number,
isLoading: isResolving( 'getProducts', [ queryParams ] ),
};
},
[ queryParams ]
);
const paginationInfo = useMemo(
() => ( {
totalItems: totalCount,
totalPages: Math.ceil( totalCount / ( view.perPage || PAGE_SIZE ) ),
} ),
[ totalCount, view.perPage ]
);
const classes = classNames( 'edit-site-page', className );
return (
<NavigableRegion
className={ classes }
ariaLabel={ __( 'Products', 'woocommerce' ) }
>
<div className="edit-site-page-content">
{ ! hideTitleFromUI && (
<VStack
className="edit-site-page-header"
as="header"
spacing={ 0 }
>
<HStack className="edit-site-page-header__page-title">
<Heading
as="h2"
level={ 3 }
weight={ 500 }
className="edit-site-page-header__title"
truncate
>
{ __( 'Products', 'woocommerce' ) }
</Heading>
<FlexItem className="edit-site-page-header__actions">
{ /* { actions } */ }
</FlexItem>
</HStack>
{ subTitle && (
<Text
variant="muted"
as="p"
className="edit-site-page-header__sub-title"
>
{ subTitle }
</Text>
) }
</VStack>
) }
<DataViews
key={ activeView + isCustom }
paginationInfo={ paginationInfo }
// @ts-expect-error types seem rather strict for this still.
fields={ fields }
actions={ EMPTY_ACTIONS_ARRAY }
data={ records || EMPTY_ARRAY }
isLoading={ isLoading }
view={ view }
onChangeView={ setView }
onChangeSelection={ onChangeSelection }
getItemId={ getItemId }
selection={ selection }
defaultLayouts={ defaultLayouts }
header={
<Button
// @ts-expect-error outdated type.
size="compact"
isPressed={ quickEdit }
icon={ drawerRight }
label={ __(
'Toggle details panel',
'woocommerce'
) }
onClick={ () => {
history.push( {
...location.params,
quickEdit: quickEdit ? undefined : true,
} );
} }
/>
}
/>
</div>
</NavigableRegion>
);
}

View File

@ -0,0 +1,68 @@
/**
* External dependencies
*/
import { createElement } from '@wordpress/element';
import { privateApis as routerPrivateApis } from '@wordpress/router';
/**
* Internal dependencies
*/
import { unlock } from '../lock-unlock';
import ProductList from './product-list';
import DataViewsSidebarContent from './sidebar-dataviews';
import SidebarNavigationScreen from './sidebar-navigation-screen';
const { useLocation } = unlock( routerPrivateApis );
export type Route = {
key: string;
areas: {
sidebar: React.JSX.Element | React.FunctionComponent;
content?: React.JSX.Element | React.FunctionComponent;
edit?: React.JSX.Element | React.FunctionComponent;
mobile?: React.JSX.Element | React.FunctionComponent | boolean;
preview?: boolean;
};
widths?: {
content?: number;
edit?: number;
sidebar?: number;
};
};
export default function useLayoutAreas() {
const { params = {} } = useLocation();
const { postType = 'product', layout = 'table', canvas } = params;
// Products list.
if ( [ 'product' ].includes( postType ) ) {
const isListLayout = layout === 'list' || ! layout;
return {
key: 'products-list',
areas: {
sidebar: (
<SidebarNavigationScreen
title={ 'Products' }
isRoot
content={ <DataViewsSidebarContent /> }
/>
),
content: <ProductList />,
preview: false,
mobile: <ProductList postType={ postType } />,
},
widths: {
content: isListLayout ? 380 : undefined,
},
};
}
// Fallback shows the home page preview
return {
key: 'default',
areas: {
sidebar: () => null,
preview: false,
mobile: canvas === 'edit',
},
};
}

View File

@ -0,0 +1,110 @@
/**
* External dependencies
*/
import { createElement } from '@wordpress/element';
import classNames from 'classnames';
import { privateApis as routerPrivateApis } from '@wordpress/router';
import { addQueryArgs, getQueryArgs, removeQueryArgs } from '@wordpress/url';
import { VIEW_LAYOUTS } from '@wordpress/dataviews';
// @ts-expect-error missing type.
// eslint-disable-next-line @woocommerce/dependency-group
import { __experimentalHStack as HStack } from '@wordpress/components';
/**
* Internal dependencies
*/
import SidebarNavigationItem from '../sidebar-navigation-item';
import { unlock } from '../../lock-unlock';
const { useHistory, useLocation } = unlock( routerPrivateApis );
type DataViewItemProps = {
title: string;
slug: string;
customViewId?: string;
type: string;
icon: React.JSX.Element;
isActive: boolean;
isCustom: boolean;
suffix?: string;
};
function useLink(
params: Record< string, string | undefined >,
state?: Record< string, string | undefined >,
shouldReplace = false
) {
const history = useHistory();
function onClick( event: Event ) {
event?.preventDefault();
if ( shouldReplace ) {
history.replace( params, state );
} else {
history.push( params, state );
}
}
const currentArgs = getQueryArgs( window.location.href );
const currentUrlWithoutArgs = removeQueryArgs(
window.location.href,
...Object.keys( currentArgs )
);
const newUrl = addQueryArgs( currentUrlWithoutArgs, params );
return {
href: newUrl,
onClick,
};
}
export default function DataViewItem( {
title,
slug,
customViewId,
type,
icon,
isActive,
isCustom,
suffix,
}: DataViewItemProps ) {
const {
params: { postType, page },
} = useLocation();
const iconToUse =
icon || VIEW_LAYOUTS.find( ( v ) => v.type === type )?.icon;
let activeView: undefined | string = isCustom ? customViewId : slug;
if ( activeView === 'all' ) {
activeView = undefined;
}
const linkInfo = useLink( {
page,
postType,
layout: type,
activeView,
isCustom: isCustom ? 'true' : undefined,
} );
return (
<HStack
justify="flex-start"
className={ classNames(
'edit-site-sidebar-dataviews-dataview-item',
{
'is-selected': isActive,
}
) }
>
<SidebarNavigationItem
icon={ iconToUse }
{ ...linkInfo }
aria-current={ isActive ? 'true' : undefined }
>
{ title }
</SidebarNavigationItem>
{ suffix }
</HStack>
);
}

View File

@ -0,0 +1,179 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { useSelect } from '@wordpress/data';
import { store as coreStore } from '@wordpress/core-data';
import { useMemo } from '@wordpress/element';
import {
trash,
pages,
drafts,
published,
scheduled,
notAllowed,
} from '@wordpress/icons';
import type { ColumnStyle, ViewTable } from '@wordpress/dataviews';
/**
* Internal dependencies
*/
import {
LAYOUT_LIST,
LAYOUT_TABLE,
LAYOUT_GRID,
OPERATOR_IS,
} from '../constants';
export const defaultLayouts: Record<
string,
{
layout: {
primaryField: string;
mediaField?: string;
styles?: Record< string, ColumnStyle >;
};
}
> = {
[ LAYOUT_TABLE ]: {
layout: {
primaryField: 'name',
styles: {
name: {
maxWidth: 300,
},
},
},
},
[ LAYOUT_GRID ]: {
layout: {
mediaField: 'featured-image',
primaryField: 'name',
},
},
[ LAYOUT_LIST ]: {
layout: {
primaryField: 'name',
mediaField: 'featured-image',
},
},
};
const DEFAULT_POST_BASE: Omit< ViewTable, 'view' | 'title' | 'slug' | 'icon' > =
{
type: LAYOUT_TABLE,
search: '',
filters: [],
page: 1,
perPage: 20,
sort: {
field: 'date',
direction: 'desc',
},
fields: [ 'name', 'sku', 'status', 'date' ],
layout: defaultLayouts[ LAYOUT_LIST ].layout,
};
export function useDefaultViews( { postType }: { postType: string } ): Array< {
title: string;
slug: string;
icon: React.JSX.Element;
view: ViewTable;
} > {
const labels = useSelect(
( select ) => {
const { getPostType } = select( coreStore );
const postTypeData: { labels?: Record< string, string > } =
getPostType( postType );
return postTypeData?.labels;
},
[ postType ]
);
return useMemo( () => {
return [
{
title: labels?.all_items || __( 'All items', 'woocommerce' ),
slug: 'all',
icon: pages,
view: { ...DEFAULT_POST_BASE },
},
{
title: __( 'Published', 'woocommerce' ),
slug: 'published',
icon: published,
view: {
...DEFAULT_POST_BASE,
filters: [
{
field: 'status',
operator: OPERATOR_IS,
value: 'publish',
},
],
},
},
{
title: __( 'Scheduled', 'woocommerce' ),
slug: 'future',
icon: scheduled,
view: {
...DEFAULT_POST_BASE,
filters: [
{
field: 'status',
operator: OPERATOR_IS,
value: 'future',
},
],
},
},
{
title: __( 'Drafts', 'woocommerce' ),
slug: 'drafts',
icon: drafts,
view: {
...DEFAULT_POST_BASE,
filters: [
{
field: 'status',
operator: OPERATOR_IS,
value: 'draft',
},
],
},
},
{
title: __( 'Private', 'woocommerce' ),
slug: 'private',
icon: notAllowed,
view: {
...DEFAULT_POST_BASE,
filters: [
{
field: 'status',
operator: OPERATOR_IS,
value: 'private',
},
],
},
},
{
title: __( 'Trash', 'woocommerce' ),
slug: 'trash',
icon: trash,
view: {
...DEFAULT_POST_BASE,
type: LAYOUT_TABLE,
layout: defaultLayouts[ LAYOUT_TABLE ].layout,
filters: [
{
field: 'status',
operator: OPERATOR_IS,
value: 'trash',
},
],
},
},
];
}, [ labels ] );
}

View File

@ -0,0 +1,55 @@
/**
* External dependencies
*/
import { createElement, Fragment } from '@wordpress/element';
import { privateApis as routerPrivateApis } from '@wordpress/router';
// @ts-expect-error missing type.
// eslint-disable-next-line @wordpress/no-unsafe-wp-apis, @woocommerce/dependency-group
import { __experimentalItemGroup as ItemGroup } from '@wordpress/components';
/**
* Internal dependencies
*/
import { unlock } from '../../lock-unlock';
import DataViewItem from './dataview-item';
import { useDefaultViews } from './default-views';
const { useLocation } = unlock( routerPrivateApis );
export default function DataViewsSidebarContent() {
const {
params: {
postType = 'product',
activeView = 'all',
isCustom = 'false',
},
} = useLocation();
const defaultViews = useDefaultViews( { postType } );
if ( ! postType ) {
return null;
}
const isCustomBoolean = isCustom === 'true';
return (
<>
<ItemGroup>
{ defaultViews.map( ( dataview ) => {
return (
<DataViewItem
key={ dataview.slug }
slug={ dataview.slug }
title={ dataview.title }
icon={ dataview.icon }
type={ dataview.view.type }
isActive={
! isCustomBoolean &&
dataview.slug === activeView
}
isCustom={ false }
/>
);
} ) }
</ItemGroup>
</>
);
}

View File

@ -0,0 +1,19 @@
.edit-site-sidebar-navigation-screen__title-icon {
position: sticky;
top: 0;
background: $gray-900;
padding-top: $grid-unit-60;
margin-bottom: $grid-unit-10;
padding-bottom: $grid-unit-10;
}
.edit-site-sidebar-button {
color: #e0e0e0;
flex-shrink: 0;
}
.edit-site-sidebar-navigation-screen__title {
flex-grow: 1;
overflow-wrap: break-word;
padding: 2px 0 0
}

View File

@ -0,0 +1,88 @@
/**
* External dependencies
*/
import { isRTL } from '@wordpress/i18n';
import { chevronRightSmall, chevronLeftSmall, Icon } from '@wordpress/icons';
import { privateApis as routerPrivateApis } from '@wordpress/router';
import classNames from 'classnames';
import { createElement } from '@wordpress/element';
import {
// @ts-expect-error missing type.
__experimentalItem as Item,
// @ts-expect-error missing type.
__experimentalHStack as HStack,
FlexBlock,
} from '@wordpress/components';
/**
* Internal dependencies
*/
import { unlock } from '../../lock-unlock';
const { useHistory } = unlock( routerPrivateApis );
type SidebarNavigationItemProps = {
className?: string;
icon?: React.JSX.Element;
suffix?: string;
withChevron?: boolean;
uid?: string;
params?: Record< string, string >;
onClick?: ( e: Event ) => void;
children: React.ReactNode;
};
export default function SidebarNavigationItem( {
className,
icon,
withChevron = false,
suffix,
uid,
params,
onClick,
children,
...props
}: SidebarNavigationItemProps ) {
const history = useHistory();
// If there is no custom click handler, create one that navigates to `params`.
function handleClick( e: Event ) {
if ( onClick ) {
onClick( e );
} else if ( params ) {
e.preventDefault();
history.push( params );
}
}
return (
<Item
className={ classNames(
'edit-site-sidebar-navigation-item',
{ 'with-suffix': ! withChevron && suffix },
className
) }
onClick={ handleClick }
id={ uid }
{ ...props }
>
<HStack justify="flex-start">
{ icon && (
<Icon
style={ { fill: 'currentcolor' } }
icon={ icon }
size={ 24 }
/>
) }
<FlexBlock>{ children }</FlexBlock>
{ withChevron && (
<Icon
icon={ isRTL() ? chevronLeftSmall : chevronRightSmall }
className="edit-site-sidebar-navigation-item__drilldown-indicator"
size={ 24 }
/>
) }
{ ! withChevron && suffix }
</HStack>
</Item>
);
}

View File

@ -0,0 +1,136 @@
/**
* External dependencies
*/
import { isRTL, __ } from '@wordpress/i18n';
import classNames from 'classnames';
import { chevronRight, chevronLeft } from '@wordpress/icons';
import { useSelect } from '@wordpress/data';
import { privateApis as routerPrivateApis } from '@wordpress/router';
import { createElement, Fragment } from '@wordpress/element';
import {
// @ts-expect-error missing type.
__experimentalHStack as HStack,
// @ts-expect-error missing type.
__experimentalHeading as Heading,
// @ts-expect-error missing type.
__experimentalVStack as VStack,
} from '@wordpress/components';
/**
* Internal dependencies
*/
import { unlock } from '../../lock-unlock';
import SidebarButton from './sidebar-button';
const { useHistory, useLocation } = unlock( routerPrivateApis );
type SidebarNavigationScreenProps = {
isRoot?: boolean;
title: string;
actions?: React.JSX.Element;
meta?: string;
content: React.JSX.Element;
footer?: string;
description?: string;
backPath?: string;
};
export default function SidebarNavigationScreen( {
isRoot,
title,
actions,
meta,
content,
footer,
description,
backPath: backPathProp,
}: SidebarNavigationScreenProps ) {
const { dashboardLink, dashboardLinkText } = useSelect( ( select ) => {
const { getSettings } = unlock( select( 'core/edit-site' ) );
return {
dashboardLink: getSettings().__experimentalDashboardLink,
dashboardLinkText: getSettings().__experimentalDashboardLinkText,
};
}, [] );
const location = useLocation();
const history = useHistory();
const backPath = backPathProp ?? location.state?.backPath;
const icon = isRTL() ? chevronRight : chevronLeft;
return (
<>
<VStack
className={ classNames(
'edit-site-sidebar-navigation-screen__main',
{
'has-footer': !! footer,
}
) }
spacing={ 0 }
justify="flex-start"
>
<HStack
spacing={ 3 }
alignment="flex-start"
className="edit-site-sidebar-navigation-screen__title-icon"
>
{ ! isRoot && (
<SidebarButton
onClick={ () => {
history.push( backPath );
} }
icon={ icon }
label={ __( 'Back', 'woocommerce' ) }
showTooltip={ false }
/>
) }
{ isRoot && (
<SidebarButton
icon={ icon }
label={
dashboardLinkText ||
__( 'Go to the Dashboard', 'woocommerce' )
}
href={ dashboardLink || 'index.php' }
/>
) }
<Heading
className="edit-site-sidebar-navigation-screen__title"
color={ '#e0e0e0' /* $gray-200 */ }
level={ 1 }
size={ 20 }
>
{ title }
</Heading>
{ actions && (
<div className="edit-site-sidebar-navigation-screen__actions">
{ actions }
</div>
) }
</HStack>
{ meta && (
<>
<div className="edit-site-sidebar-navigation-screen__meta">
{ meta }
</div>
</>
) }
<div className="edit-site-sidebar-navigation-screen__content">
{ description && (
<p className="edit-site-sidebar-navigation-screen__description">
{ description }
</p>
) }
{ content }
</div>
</VStack>
{ footer && (
<footer className="edit-site-sidebar-navigation-screen__footer">
{ footer }
</footer>
) }
</>
);
}

View File

@ -0,0 +1,18 @@
/**
* External dependencies
*/
import { createElement } from '@wordpress/element';
import { Button } from '@wordpress/components';
import classNames from 'classnames';
export default function SidebarButton( props: Button.Props ) {
return (
<Button
{ ...props }
className={ classNames(
'edit-site-sidebar-button',
props.className
) }
/>
);
}

View File

@ -0,0 +1,31 @@
/**
* External dependencies
*/
import { createElement, useRef } from '@wordpress/element';
function SidebarContentWrapper( { children }: { children: React.ReactNode } ) {
const wrapperRef = useRef< HTMLDivElement | null >( null );
const wrapperCls = 'edit-site-sidebar__screen-wrapper';
return (
<div ref={ wrapperRef } className={ wrapperCls }>
{ children }
</div>
);
}
export default function SidebarContent( {
routeKey,
children,
}: {
routeKey: string;
children: React.ReactNode;
} ) {
return (
<div className="edit-site-sidebar__content">
<SidebarContentWrapper key={ routeKey }>
{ children }
</SidebarContentWrapper>
</div>
);
}

View File

@ -0,0 +1,114 @@
/**
* External dependencies
*/
import { createElement, memo, forwardRef } from '@wordpress/element';
import classNames from 'classnames';
import { useSelect } from '@wordpress/data';
import { __ } from '@wordpress/i18n';
import { store as coreStore } from '@wordpress/core-data';
import { decodeEntities } from '@wordpress/html-entities';
import { filterURLForDisplay } from '@wordpress/url';
import {
Button,
// @ts-expect-error missing types.
__experimentalHStack as HStack,
VisuallyHidden,
} from '@wordpress/components';
/**
* Internal dependencies
*/
import SiteIcon from './site-icon';
import { unlock } from '../../lock-unlock';
const SiteHub = memo(
forwardRef(
(
{ isTransparent }: { isTransparent: boolean },
ref: React.Ref< HTMLAnchorElement >
) => {
const { dashboardLink, homeUrl, siteTitle } = useSelect(
( select ) => {
const { getSettings } = unlock(
select( 'core/edit-site' )
);
const {
getSite,
getUnstableBase, // Site index.
} = select( coreStore );
const _site: undefined | { title: string; url: string } =
getSite();
const base: { home: string } | undefined =
getUnstableBase();
return {
dashboardLink:
getSettings().__experimentalDashboardLink ||
'index.php',
homeUrl: base?.home,
siteTitle:
! _site?.title && !! _site?.url
? filterURLForDisplay( _site?.url )
: _site?.title,
};
},
[]
);
return (
<div className="edit-site-site-hub">
<HStack justify="flex-start" spacing="0">
<div
className={ classNames(
'edit-site-site-hub__view-mode-toggle-container',
{
'has-transparent-background': isTransparent,
}
) }
>
<Button
ref={ ref }
href={ dashboardLink }
label={ __(
'Go to the Dashboard',
'woocommerce'
) }
className="edit-site-layout__view-mode-toggle"
style={ {
transform: 'scale(0.5)',
borderRadius: 4,
} }
>
<SiteIcon className="edit-site-layout__view-mode-toggle-icon" />
</Button>
</div>
<HStack>
<div className="edit-site-site-hub__title">
<Button
variant="link"
href={ homeUrl }
target="_blank"
>
{ siteTitle && decodeEntities( siteTitle ) }
<VisuallyHidden as="span">
{
/* translators: accessibility text */
__(
'(opens in a new tab)',
'woocommerce'
)
}
</VisuallyHidden>
</Button>
</div>
</HStack>
</HStack>
</div>
);
}
)
);
export default SiteHub;

View File

@ -0,0 +1,56 @@
/**
* External dependencies
*/
import { createElement } from '@wordpress/element';
import { useSelect } from '@wordpress/data';
import { Icon } from '@wordpress/components';
import { __ } from '@wordpress/i18n';
import { wordpress } from '@wordpress/icons';
import { store as coreDataStore } from '@wordpress/core-data';
import classNames from 'classnames';
type SiteIconProps = {
className: string;
};
function SiteIcon( { className }: SiteIconProps ) {
const { isRequestingSite, siteIconUrl } = useSelect( ( select ) => {
const { getEntityRecord } = select( coreDataStore );
const siteData: { site_icon_url?: string } = getEntityRecord(
'root',
'__unstableBase',
undefined
);
return {
isRequestingSite: ! siteData,
siteIconUrl: siteData?.site_icon_url,
};
}, [] );
if ( isRequestingSite && ! siteIconUrl ) {
return <div className="edit-site-site-icon__image" />;
}
const icon = siteIconUrl ? (
<img
className="edit-site-site-icon__image"
alt={ __( 'Site Icon', 'woocommerce' ) }
src={ siteIconUrl }
/>
) : (
<Icon
className="edit-site-site-icon__icon"
icon={ wordpress }
size={ 48 }
/>
);
return (
<div className={ classNames( className, 'edit-site-site-icon' ) }>
{ icon }
</div>
);
}
export default SiteIcon;

View File

@ -4,11 +4,12 @@
.woocommerce_page_woocommerce-products-dashboard #adminmenumain { .woocommerce_page_woocommerce-products-dashboard #adminmenumain {
display: none; display: none;
} }
.woocommerce_page_woocommerce-products-dashboard #wpcontent { .woocommerce_page_woocommerce-products-dashboard #wpcontent {
margin-left: 0; margin-left: 0;
} }
body.woocommerce_page_woocommerce-products-dashboard
#woocommerce-products-dashboard { body.woocommerce_page_woocommerce-products-dashboard #woocommerce-products-dashboard {
@include wp-admin-reset("#woocommerce-products-dashboard"); @include wp-admin-reset("#woocommerce-products-dashboard");
@include reset; @include reset;
display: block !important; display: block !important;
@ -38,3 +39,5 @@ body.js.is-fullscreen-mode {
} }
} }
} }
@import "products-app/sidebar-dataviews/style.scss";

View File

@ -31,6 +31,7 @@ declare module '@wordpress/core-data' {
isResolving: boolean; isResolving: boolean;
hasResolved: boolean; hasResolved: boolean;
}; };
const store: string;
} }
declare module '@wordpress/keyboard-shortcuts' { declare module '@wordpress/keyboard-shortcuts' {
function useShortcut( function useShortcut(
@ -43,3 +44,24 @@ declare module '@wordpress/keyboard-shortcuts' {
declare module '@wordpress/router' { declare module '@wordpress/router' {
const privateApis; const privateApis;
} }
declare module '@wordpress/edit-site/build-module/components/sync-state-with-url/use-init-edited-entity-from-url' {
export default function useInitEditedEntityFromURL(): void;
}
declare module '@wordpress/edit-site/build-module/components/sidebar-navigation-screen' {
const SidebarNavigationScreen: React.FunctionComponent< {
title: string;
isRoot: boolean;
content: JSX.Element;
} >;
export default SidebarNavigationScreen;
}
declare module '@wordpress/edit-site/build-module/components/site-hub' {
const SiteHub: React.FunctionComponent< {
ref: React.Ref;
isTransparent: boolean;
} >;
export default SiteHub;
}

View File

@ -186,6 +186,8 @@ const webpackConfig = {
extensions: [ '.json', '.js', '.jsx', '.ts', '.tsx' ], extensions: [ '.json', '.js', '.jsx', '.ts', '.tsx' ],
alias: { alias: {
'~': path.resolve( __dirname + '/client' ), '~': path.resolve( __dirname + '/client' ),
'react/jsx-dev-runtime': require.resolve( 'react/jsx-dev-runtime' ),
'react/jsx-runtime': require.resolve( 'react/jsx-runtime' ),
}, },
}, },
plugins: [ plugins: [
@ -240,6 +242,10 @@ const webpackConfig = {
return null; return null;
} }
if ( request.startsWith( '@wordpress/dataviews' ) ) {
return null;
}
if ( request.startsWith( '@wordpress/edit-site' ) ) { if ( request.startsWith( '@wordpress/edit-site' ) ) {
// The external wp.editSite does not include edit-site components, so we need to skip requesting to external here. We can remove this once the edit-site components are exported in the external wp.editSite. // The external wp.editSite does not include edit-site components, so we need to skip requesting to external here. We can remove this once the edit-site components are exported in the external wp.editSite.
// We use the edit-site components in the customize store. // We use the edit-site components in the customize store.

View File

@ -0,0 +1,4 @@
Significance: minor
Type: update
Update webpack config to bundle in @wordpress/dataviews package.

File diff suppressed because it is too large Load Diff