diff --git a/lib/experimental/editor-settings.php b/lib/experimental/editor-settings.php index c09b5cde0f16b..f5cc2ec969d33 100644 --- a/lib/experimental/editor-settings.php +++ b/lib/experimental/editor-settings.php @@ -16,6 +16,9 @@ function gutenberg_enable_experiments() { if ( $gutenberg_experiments && array_key_exists( 'gutenberg-zoomed-out-view', $gutenberg_experiments ) ) { wp_add_inline_script( 'wp-block-editor', 'window.__experimentalEnableZoomedOutView = true', 'before' ); } + if ( $gutenberg_experiments && array_key_exists( 'gutenberg-dataviews', $gutenberg_experiments ) ) { + wp_add_inline_script( 'wp-block-editor', 'window.__experimentalAdminViews = true', 'before' ); + } if ( $gutenberg_experiments && array_key_exists( 'gutenberg-color-randomizer', $gutenberg_experiments ) ) { wp_add_inline_script( 'wp-block-editor', 'window.__experimentalEnableColorRandomizer = true', 'before' ); } diff --git a/lib/experiments-page.php b/lib/experiments-page.php index 133d968ba2cb7..3bf53efd61622 100644 --- a/lib/experiments-page.php +++ b/lib/experiments-page.php @@ -67,6 +67,18 @@ function gutenberg_initialize_experiments_settings() { ) ); + add_settings_field( + 'gutenberg-dataviews', + __( 'New admin views', 'gutenberg' ), + 'gutenberg_display_experiment_field', + 'gutenberg-experiments', + 'gutenberg_experiments_section', + array( + 'label' => __( 'Test the new views for different entities like pages.', 'gutenberg' ), + 'id' => 'gutenberg-dataviews', + ) + ); + add_settings_field( 'gutenberg-color-randomizer', __( 'Color randomizer ', 'gutenberg' ), diff --git a/package-lock.json b/package-lock.json index f3e3d5930054a..cc0ac9a5d4bbd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13854,6 +13854,37 @@ "resolved": "https://registry.npmjs.org/@tannin/postfix/-/postfix-1.1.0.tgz", "integrity": "sha512-oocsqY7g0cR+Gur5jRQLSrX2OtpMLMse1I10JQBm8CdGMrDkh1Mg2gjsiquMHRtBs4Qwu5wgEp5GgIYHk4SNPw==" }, + "node_modules/@tanstack/react-table": { + "version": "8.10.3", + "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.10.3.tgz", + "integrity": "sha512-Qya1cJ+91arAlW7IRDWksRDnYw28O446jJ/ljkRSc663EaftJoBCAU10M+VV1K6MpCBLrXq1BD5IQc1zj/ZEjA==", + "dependencies": { + "@tanstack/table-core": "8.10.3" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=16", + "react-dom": ">=16" + } + }, + "node_modules/@tanstack/table-core": { + "version": "8.10.3", + "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.10.3.tgz", + "integrity": "sha512-hJ55YfJlWbfzRROfcyA/kC1aZr/shsLA8XNAwN8jXylhYWGLnPmiJJISrUfj4dMMWRiFi0xBlnlC7MLH+zSrcw==", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@testing-library/dom": { "version": "9.3.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-9.3.1.tgz", @@ -56741,6 +56772,7 @@ "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "^7.16.0", + "@tanstack/react-table": "^8.10.3", "@wordpress/a11y": "file:../a11y", "@wordpress/api-fetch": "file:../api-fetch", "@wordpress/block-editor": "file:../block-editor", @@ -67791,6 +67823,19 @@ "resolved": "https://registry.npmjs.org/@tannin/postfix/-/postfix-1.1.0.tgz", "integrity": "sha512-oocsqY7g0cR+Gur5jRQLSrX2OtpMLMse1I10JQBm8CdGMrDkh1Mg2gjsiquMHRtBs4Qwu5wgEp5GgIYHk4SNPw==" }, + "@tanstack/react-table": { + "version": "8.10.3", + "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.10.3.tgz", + "integrity": "sha512-Qya1cJ+91arAlW7IRDWksRDnYw28O446jJ/ljkRSc663EaftJoBCAU10M+VV1K6MpCBLrXq1BD5IQc1zj/ZEjA==", + "requires": { + "@tanstack/table-core": "8.10.3" + } + }, + "@tanstack/table-core": { + "version": "8.10.3", + "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.10.3.tgz", + "integrity": "sha512-hJ55YfJlWbfzRROfcyA/kC1aZr/shsLA8XNAwN8jXylhYWGLnPmiJJISrUfj4dMMWRiFi0xBlnlC7MLH+zSrcw==" + }, "@testing-library/dom": { "version": "9.3.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-9.3.1.tgz", @@ -69910,6 +69955,7 @@ "version": "file:packages/edit-site", "requires": { "@babel/runtime": "^7.16.0", + "@tanstack/react-table": "^8.10.3", "@wordpress/a11y": "file:../a11y", "@wordpress/api-fetch": "file:../api-fetch", "@wordpress/block-editor": "file:../block-editor", diff --git a/packages/edit-site/package.json b/packages/edit-site/package.json index 3441b79ff9dd4..21cff84cf564b 100644 --- a/packages/edit-site/package.json +++ b/packages/edit-site/package.json @@ -27,6 +27,7 @@ "react-native": "src/index", "dependencies": { "@babel/runtime": "^7.16.0", + "@tanstack/react-table": "^8.10.3", "@wordpress/a11y": "file:../a11y", "@wordpress/api-fetch": "file:../api-fetch", "@wordpress/block-editor": "file:../block-editor", diff --git a/packages/edit-site/src/components/dataviews/dataviews.js b/packages/edit-site/src/components/dataviews/dataviews.js new file mode 100644 index 0000000000000..bf492cb5691a3 --- /dev/null +++ b/packages/edit-site/src/components/dataviews/dataviews.js @@ -0,0 +1,60 @@ +/** + * External dependencies + */ +import { + getCoreRowModel, + getFilteredRowModel, + getSortedRowModel, + getPaginationRowModel, + useReactTable, +} from '@tanstack/react-table'; + +/** + * WordPress dependencies + */ +import { + __experimentalVStack as VStack, + __experimentalHStack as HStack, +} from '@wordpress/components'; + +/** + * Internal dependencies + */ +import ListView from './list-view'; +import { Pagination } from './pagination'; +import ViewActions from './view-actions'; +import TextFilter from './text-filter'; + +export default function DataViews( { + data, + fields, + isLoading, + paginationInfo, + options, +} ) { + const dataView = useReactTable( { + data, + columns: fields, + ...options, + getCoreRowModel: getCoreRowModel(), + getFilteredRowModel: getFilteredRowModel(), + getSortedRowModel: getSortedRowModel(), + getPaginationRowModel: getPaginationRowModel(), + } ); + return ( +
+ + + + + + { /* This component will be selected based on viewConfigs. Now we only have the list view. */ } + + + +
+ ); +} diff --git a/packages/edit-site/src/components/dataviews/index.js b/packages/edit-site/src/components/dataviews/index.js new file mode 100644 index 0000000000000..3fea6fd63714b --- /dev/null +++ b/packages/edit-site/src/components/dataviews/index.js @@ -0,0 +1,2 @@ +export { default as DataViews } from './dataviews'; +export { PAGE_SIZE_VALUES } from './view-actions'; diff --git a/packages/edit-site/src/components/dataviews/list-view.js b/packages/edit-site/src/components/dataviews/list-view.js new file mode 100644 index 0000000000000..55a871d3df8c9 --- /dev/null +++ b/packages/edit-site/src/components/dataviews/list-view.js @@ -0,0 +1,106 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; +import { flexRender } from '@tanstack/react-table'; + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { chevronDown, chevronUp } from '@wordpress/icons'; +import { Button } from '@wordpress/components'; +import { forwardRef } from '@wordpress/element'; + +const sortIcons = { asc: chevronUp, desc: chevronDown }; +function Header( { header } ) { + if ( header.isPlaceholder ) { + return null; + } + const text = flexRender( + header.column.columnDef.header, + header.getContext() + ); + if ( ! header.column.getCanSort() ) { + return text; + } + const sortDirection = header.column.getIsSorted(); + return ( + + + + { createInterpolateElement( + sprintf( + // translators: %1$s: Current page number, %2$s: Total number of pages. + _x( ' of %2$s', 'paging' ), + currentPage, + numPages + ), + { + CurrenPageControl: ( + { + if ( value > numPages ) return; + dataView.setPageIndex( value - 1 ); + } } + step="1" + value={ currentPage } + isDragEnabled={ false } + spinControls="none" + /> + ), + } + ) } + + + + + ) } + + + ); +} diff --git a/packages/edit-site/src/components/dataviews/style.scss b/packages/edit-site/src/components/dataviews/style.scss new file mode 100644 index 0000000000000..47e80782255a4 --- /dev/null +++ b/packages/edit-site/src/components/dataviews/style.scss @@ -0,0 +1,36 @@ +.dataviews-wrapper { + width: 100%; + padding: $grid-unit-40; +} + +.dataviews-list-view { + width: 100%; + text-indent: 0; + border-color: inherit; + border-collapse: collapse; + position: relative; + a { + text-decoration: none; + } + th { + text-align: left; + font-weight: normal; + padding: 0 $grid-unit-20 $grid-unit-20; + color: $gray-700; + } + td, + th { + padding: $grid-unit-15; + &:last-child { + text-align: right; + } + } + tr { + border-bottom: 1px solid $gray-100; + } +} + +.dataviews__per-page-control-prefix { + color: $gray-700; + text-wrap: nowrap; +} diff --git a/packages/edit-site/src/components/dataviews/text-filter.js b/packages/edit-site/src/components/dataviews/text-filter.js new file mode 100644 index 0000000000000..76a06c1448617 --- /dev/null +++ b/packages/edit-site/src/components/dataviews/text-filter.js @@ -0,0 +1,37 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { useEffect } from '@wordpress/element'; +import { SearchControl } from '@wordpress/components'; + +/** + * Internal dependencies + */ +import useDebouncedInput from '../../utils/use-debounced-input'; + +export default function TextFilter( { + className, + searchLabel = __( 'Filter list' ), + onChange, +} ) { + const [ search, setSearch, debouncedSearch ] = useDebouncedInput(); + useEffect( () => { + onChange( debouncedSearch ); + }, [ debouncedSearch, onChange ] ); + return ( + + ); +} diff --git a/packages/edit-site/src/components/dataviews/view-actions.js b/packages/edit-site/src/components/dataviews/view-actions.js new file mode 100644 index 0000000000000..1ede6ebcd8b75 --- /dev/null +++ b/packages/edit-site/src/components/dataviews/view-actions.js @@ -0,0 +1,246 @@ +/** + * WordPress dependencies + */ +import { + Button, + Icon, + SelectControl, + privateApis as componentsPrivateApis, + __experimentalInputControlPrefixWrapper as InputControlPrefixWrapper, +} from '@wordpress/components'; +import { + chevronRightSmall, + check, + blockTable, + chevronDown, + arrowUp, + arrowDown, +} from '@wordpress/icons'; +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import { unlock } from '../../lock-unlock'; + +const { + DropdownMenuV2, + DropdownMenuGroupV2, + DropdownMenuItemV2, + DropdownSubMenuV2, + DropdownSubMenuTriggerV2, +} = unlock( componentsPrivateApis ); + +export const PAGE_SIZE_VALUES = [ 5, 20, 50 ]; + +export function PageSizeControl( { dataView } ) { + const label = __( 'Rows per page:' ); + return ( + + { label } + + } + value={ dataView.getState().pagination.pageSize } + options={ PAGE_SIZE_VALUES.map( ( pageSize ) => ( { + value: pageSize, + label: pageSize, + } ) ) } + onChange={ ( value ) => dataView.setPageSize( +value ) } + /> + ); +} + +function PageSizeMenu( { dataView } ) { + const currenPageSize = dataView.getState().pagination.pageSize; + return ( + + { currenPageSize } + + + } + > + { /* TODO: probably label per view type. */ } + { __( 'Rows per page' ) } + + } + > + { PAGE_SIZE_VALUES.map( ( size ) => { + return ( + + } + onSelect={ ( event ) => { + // We need to handle this on DropDown component probably.. + event.preventDefault(); + dataView.setPageSize( size ); + } } + // TODO: check about role and a11y. + role="menuitemcheckbox" + > + { size } + + ); + } ) } + + ); +} + +function FieldsVisibilityMenu( { dataView } ) { + const hideableFields = dataView + .getAllColumns() + .filter( ( columnn ) => columnn.getCanHide() ); + if ( ! hideableFields?.length ) { + return null; + } + return ( + } + > + { __( 'Fields' ) } + + } + > + { hideableFields?.map( ( field ) => { + return ( + + } + onSelect={ ( event ) => { + event.preventDefault(); + field.getToggleVisibilityHandler()( event ); + } } + role="menuitemcheckbox" + > + { field.columnDef.header } + + ); + } ) } + + ); +} + +// This object is used to construct the sorting options per sortable field. +const sortingItemsInfo = { + asc: { icon: arrowUp, label: __( 'Sort ascending' ) }, + desc: { icon: arrowDown, label: __( 'Sort descending' ) }, +}; +function SortMenu( { dataView } ) { + const sortableFields = dataView + .getAllColumns() + .filter( ( columnn ) => columnn.getCanSort() ); + if ( ! sortableFields?.length ) { + return null; + } + const currentSortedField = sortableFields.find( ( field ) => + field.getIsSorted() + ); + return ( + + { currentSortedField?.columnDef.header } + + + } + > + { __( 'Sort by' ) } + + } + > + { sortableFields?.map( ( field ) => { + const sortedDirection = field.getIsSorted(); + return ( + } + > + { field.columnDef.header } + + } + side="left" + > + { Object.entries( sortingItemsInfo ).map( + ( [ direction, info ] ) => { + return ( + } + suffix={ + sortedDirection === direction && ( + + ) + } + onSelect={ ( event ) => { + event.preventDefault(); + if ( + sortedDirection === direction + ) { + dataView.resetSorting(); + } else { + dataView.setSorting( [ + { + id: field.id, + desc: + direction === + 'desc', + }, + ] ); + } + } } + > + { info.label } + + ); + } + ) } + + ); + } ) } + + ); +} + +export default function ViewActions( { dataView, className } ) { + return ( + + { __( 'View' ) } + + + } + > + + + + + + + ); +} diff --git a/packages/edit-site/src/components/page-main/index.js b/packages/edit-site/src/components/page-main/index.js index af017a8db9700..10b5b99dc2fbf 100644 --- a/packages/edit-site/src/components/page-main/index.js +++ b/packages/edit-site/src/components/page-main/index.js @@ -9,6 +9,7 @@ import { privateApis as routerPrivateApis } from '@wordpress/router'; import PagePatterns from '../page-patterns'; import PageTemplateParts from '../page-template-parts'; import PageTemplates from '../page-templates'; +import PagePages from '../page-pages'; import { unlock } from '../../lock-unlock'; const { useLocation } = unlock( routerPrivateApis ); @@ -24,6 +25,8 @@ export default function PageMain() { return ; } else if ( path === '/patterns' ) { return ; + } else if ( window?.__experimentalAdminViews && path === '/pages' ) { + return ; } return null; diff --git a/packages/edit-site/src/components/page-pages/index.js b/packages/edit-site/src/components/page-pages/index.js new file mode 100644 index 0000000000000..e7b8f913efe7c --- /dev/null +++ b/packages/edit-site/src/components/page-pages/index.js @@ -0,0 +1,190 @@ +/** + * WordPress dependencies + */ +import apiFetch from '@wordpress/api-fetch'; +import { addQueryArgs } from '@wordpress/url'; +import { + VisuallyHidden, + __experimentalHeading as Heading, + __experimentalVStack as VStack, +} from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +import { useEntityRecords } from '@wordpress/core-data'; +import { decodeEntities } from '@wordpress/html-entities'; +import { useState, useEffect, useMemo } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import Page from '../page'; +import Link from '../routes/link'; +import PageActions from '../page-actions'; +import { DataViews, PAGE_SIZE_VALUES } from '../dataviews'; + +const EMPTY_ARRAY = []; + +export default function PagePages() { + const [ reset, setResetQuery ] = useState( ( v ) => ! v ); + const [ globalFilter, setGlobalFilter ] = useState( '' ); + const [ paginationInfo, setPaginationInfo ] = useState(); + const [ { pageIndex, pageSize }, setPagination ] = useState( { + pageIndex: 0, + pageSize: PAGE_SIZE_VALUES[ 0 ], + } ); + // Request post statuses to get the proper labels. + const [ postStatuses, setPostStatuses ] = useState( EMPTY_ARRAY ); + useEffect( () => { + apiFetch( { + path: '/wp/v2/statuses', + } ).then( setPostStatuses ); + }, [] ); + + // TODO: probably memo other objects passed as state(ex:https://tanstack.com/table/v8/docs/examples/react/pagination-controlled). + const pagination = useMemo( + () => ( { pageIndex, pageSize } ), + [ pageIndex, pageSize ] + ); + const [ sorting, setSorting ] = useState( [ + { order: 'desc', orderby: 'date' }, + ] ); + const queryArgs = useMemo( + () => ( { + per_page: pageSize, + page: pageIndex + 1, // tanstack starts from zero. + _embed: 'author', + order: sorting[ 0 ]?.desc ? 'desc' : 'asc', + orderby: sorting[ 0 ]?.id, + search: globalFilter, + status: [ 'publish', 'draft' ], + } ), + [ + globalFilter, + sorting[ 0 ]?.id, + sorting[ 0 ]?.desc, + pageSize, + pageIndex, + reset, + ] + ); + const { records, isResolving: isLoading } = useEntityRecords( + 'postType', + 'page', + queryArgs + ); + useEffect( () => { + // Make extra request to handle controlled pagination. + apiFetch( { + path: addQueryArgs( '/wp/v2/pages', { + ...queryArgs, + _fields: 'id', + } ), + method: 'HEAD', + parse: false, + } ).then( ( res ) => { + const totalPages = parseInt( res.headers.get( 'X-WP-TotalPages' ) ); + const totalItems = parseInt( res.headers.get( 'X-WP-Total' ) ); + setPaginationInfo( { + totalPages, + totalItems, + } ); + } ); + // Status should not make extra request if already did.. + }, [ globalFilter, pageSize, reset ] ); + + const fields = useMemo( + () => [ + { + header: __( 'Title' ), + id: 'title', + accessorFn: ( page ) => page.title?.rendered || page.slug, + cell: ( props ) => { + const page = props.row.original; + return ( + + + + { decodeEntities( props.getValue() ) } + + + + ); + }, + maxWidth: 400, + sortingFn: 'alphanumeric', + enableHiding: false, + }, + { + header: __( 'Author' ), + id: 'author', + accessorFn: ( page ) => page._embedded?.author[ 0 ]?.name, + cell: ( props ) => { + const author = props.row.original._embedded?.author[ 0 ]; + return ( + + { author.name } + + ); + }, + }, + { + header: 'Status', + id: 'status', + cell: ( props ) => + postStatuses[ props.row.original.status ]?.name, + }, + { + header: { __( 'Actions' ) }, + id: 'actions', + cell: ( props ) => { + const page = props.row.original; + return ( + setResetQuery() } + /> + ); + }, + enableHiding: false, + }, + ], + [ postStatuses ] + ); + + // TODO: we need to handle properly `data={ data || EMPTY_ARRAY }` for when `isLoading`. + return ( + + { + setGlobalFilter( value ); + setPagination( { pageIndex: 0, pageSize } ); + }, + // TODO: check these callbacks and maybe reset the query when needed... + onPaginationChange: setPagination, + meta: { resetQuery: setResetQuery }, + } } + /> + + ); +} diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-pages/index.js b/packages/edit-site/src/components/sidebar-navigation-screen-pages/index.js index 4d143235e9597..e9a6163a0047e 100644 --- a/packages/edit-site/src/components/sidebar-navigation-screen-pages/index.js +++ b/packages/edit-site/src/components/sidebar-navigation-screen-pages/index.js @@ -136,6 +136,16 @@ export default function SidebarNavigationScreenPages() { }; }; + const pagesLink = useLink( { path: '/pages' } ); + const manageAllPagesProps = window?.__experimentalAdminViews + ? { ...pagesLink } + : { + href: 'edit.php?post_type=page', + onClick: () => { + document.location = 'edit.php?post_type=page'; + }, + }; + return ( <> { showAddPage && ( @@ -220,10 +230,7 @@ export default function SidebarNavigationScreenPages() { ) ) } { - document.location = 'edit.php?post_type=page'; - } } + { ...manageAllPagesProps } > { __( 'Manage all pages' ) } diff --git a/packages/edit-site/src/components/sidebar/index.js b/packages/edit-site/src/components/sidebar/index.js index 9e035759ea9ad..a233cf1eca198 100644 --- a/packages/edit-site/src/components/sidebar/index.js +++ b/packages/edit-site/src/components/sidebar/index.js @@ -2,6 +2,7 @@ * WordPress dependencies */ import { memo, useRef } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; import { __experimentalNavigatorProvider as NavigatorProvider, __experimentalNavigatorScreen as NavigatorScreen, @@ -27,6 +28,7 @@ import SaveHub from '../save-hub'; import { unlock } from '../../lock-unlock'; import SidebarNavigationScreenPages from '../sidebar-navigation-screen-pages'; import SidebarNavigationScreenPage from '../sidebar-navigation-screen-page'; +import SidebarNavigationScreen from '../sidebar-navigation-screen'; const { useLocation } = unlock( routerPrivateApis ); @@ -53,6 +55,15 @@ function SidebarScreens() { + { window?.__experimentalAdminViews && ( + + + + ) } diff --git a/packages/edit-site/src/style.scss b/packages/edit-site/src/style.scss index 111696241d0d6..e95cd3571c419 100644 --- a/packages/edit-site/src/style.scss +++ b/packages/edit-site/src/style.scss @@ -4,6 +4,7 @@ @import "./components/block-editor/style.scss"; @import "./components/canvas-loader/style.scss"; @import "./components/code-editor/style.scss"; +@import "./components/dataviews/style.scss"; @import "./components/global-styles/style.scss"; @import "./components/global-styles/screen-revisions/style.scss"; @import "./components/header-edit-mode/style.scss"; diff --git a/packages/edit-site/src/utils/get-is-list-page.js b/packages/edit-site/src/utils/get-is-list-page.js index 600e686618bf9..2ee661253cf06 100644 --- a/packages/edit-site/src/utils/get-is-list-page.js +++ b/packages/edit-site/src/utils/get-is-list-page.js @@ -14,8 +14,9 @@ export default function getIsListPage( isMobileViewport ) { return ( - path === '/wp_template/all' || - path === '/wp_template_part/all' || + [ '/wp_template/all', '/wp_template_part/all', '/pages' ].includes( + path + ) || ( path === '/patterns' && // Don't treat "/patterns" without categoryType and categoryId as a // list page in mobile because the sidebar covers the whole page.