diff --git a/.github/workflows/enforce-pr-labels.yml b/.github/workflows/enforce-pr-labels.yml index 320ef1375029c..4ef163694b947 100644 --- a/.github/workflows/enforce-pr-labels.yml +++ b/.github/workflows/enforce-pr-labels.yml @@ -14,5 +14,5 @@ jobs: count: 1 labels: '[Type] Automated Testing, [Type] Breaking Change, [Type] Bug, [Type] Build Tooling, [Type] Code Quality, [Type] Copy, [Type] Developer Documentation, [Type] Enhancement, [Type] Experimental, [Type] Feature, [Type] New API, [Type] Task, [Type] Performance, [Type] Project Management, [Type] Regression, [Type] Security, [Type] WP Core Ticket, Backport from WordPress Core' add_comment: true - message: "**Warning: Type of PR label error**\n\n To merge this PR, it requires {{ errorString }} {{ count }} label indicating the type of PR. Other labels are optional and not being checked here. \n- **Type-related labels to choose from**: {{ provided }}.\n- **Labels found**: {{ applied }}.\n\nRead more about [Type labels in Gutenberg](https://github.com/WordPress/gutenberg/labels?q=type)." + message: "**Warning: Type of PR label mismatch**\n\n To merge this PR, it requires {{ errorString }} {{ count }} label indicating the type of PR. Other labels are optional and not being checked here. \n- **Type-related labels to choose from**: {{ provided }}.\n- **Labels found**: {{ applied }}.\n\nRead more about [Type labels in Gutenberg](https://github.com/WordPress/gutenberg/labels?q=type). Don't worry if you don't have the required permissions to add labels; the PR reviewer should be able to help with the task." exit_type: failure diff --git a/docs/getting-started/devenv/get-started-with-create-block.md b/docs/getting-started/devenv/get-started-with-create-block.md index a01c08a4ce2f4..3a2c6607b82cf 100644 --- a/docs/getting-started/devenv/get-started-with-create-block.md +++ b/docs/getting-started/devenv/get-started-with-create-block.md @@ -1,6 +1,6 @@ # Get started with create-block -Custom blocks for the Block Editor in WordPress are typically registered using plugins and are defined through a specific set of files. The [`@wordpress/create-block`](https://developer.wordpress.org/block-editor/reference-guides/packages/packages-create-block/) package is an officially supported tool to scaffold the structure of files needed to create and register a block. It generates all the necessary code to start a project and integrates a modern JavaScript build setup (using [`wp-scripts`](https://developer.wordpress.org/block-editor/getting-started/devenv/get-started-with-wp-scripts.md)) with no configuration required. +Custom blocks for the Block Editor in WordPress are typically registered using plugins and are defined through a specific set of files. The [`@wordpress/create-block`](https://developer.wordpress.org/block-editor/reference-guides/packages/packages-create-block/) package is an officially supported tool to scaffold the structure of files needed to create and register a block. It generates all the necessary code to start a project and integrates a modern JavaScript build setup (using [`wp-scripts`](https://developer.wordpress.org/block-editor/getting-started/devenv/get-started-with-wp-scripts/)) with no configuration required. The package is designed to help developers quickly set up a block development environment following WordPress best practices. diff --git a/docs/reference-guides/data/data-core.md b/docs/reference-guides/data/data-core.md index 8a190869f99e7..ea97ce28e4d85 100644 --- a/docs/reference-guides/data/data-core.md +++ b/docs/reference-guides/data/data-core.md @@ -150,6 +150,19 @@ _Returns_ - `undefined< 'edit' >`: Current user object. +### getDefaultTemplateId + +Returns the default template use to render a given query. + +_Parameters_ + +- _state_ `State`: Data state. +- _query_ `TemplateQuery`: Query. + +_Returns_ + +- `string`: The default template id for the given query. + ### getEditedEntityRecord Returns the specified entity record, merged with its edits. @@ -648,6 +661,19 @@ _Returns_ - `Object`: Action object. +### receiveDefaultTemplateId + +Returns an action object used to set the template for a given query. + +_Parameters_ + +- _query_ `Object`: The lookup query. +- _templateId_ `string`: The resolved template id. + +_Returns_ + +- `Object`: Action object. + ### receiveEntityRecords Returns an action object used in signalling that entity records have been received. diff --git a/lib/experimental/data-views.php b/lib/experimental/data-views.php index e0346184ffc21..e9fb2134f3b39 100644 --- a/lib/experimental/data-views.php +++ b/lib/experimental/data-views.php @@ -40,7 +40,7 @@ function _gutenberg_register_data_views_post_type() { 'wp_dataviews_type', array( 'wp_dataviews' ), array( - 'public' => true, + 'public' => false, 'hierarchical' => false, 'labels' => array( 'name' => __( 'Dataview types', 'gutenberg' ), diff --git a/lib/experimental/fonts/font-library/class-wp-rest-font-library-controller.php b/lib/experimental/fonts/font-library/class-wp-rest-font-library-controller.php index 19dfcaab49533..9655178d70667 100644 --- a/lib/experimental/fonts/font-library/class-wp-rest-font-library-controller.php +++ b/lib/experimental/fonts/font-library/class-wp-rest-font-library-controller.php @@ -344,6 +344,18 @@ public function update_font_library_permissions_check() { return true; } + /** + * Checks whether the font directory exists or not. + * + * @since 6.5.0 + * + * @return bool Whether the font directory exists. + */ + private function has_upload_directory() { + $upload_dir = WP_Font_Library::get_fonts_dir(); + return is_dir( $upload_dir ); + } + /** * Checks whether the user has write permissions to the temp and fonts directories. * @@ -418,12 +430,29 @@ public function install_fonts( $request ) { $response_status = 400; } - if ( $this->needs_write_permission( $fonts_to_install ) && ! $this->has_write_permission() ) { - $errors[] = new WP_Error( - 'cannot_write_fonts_folder', - __( 'Error: WordPress does not have permission to write the fonts folder on your server.', 'gutenberg' ) - ); - $response_status = 500; + if ( $this->needs_write_permission( $fonts_to_install ) ) { + $upload_dir = WP_Font_Library::get_fonts_dir(); + if ( ! $this->has_upload_directory() ) { + if ( ! wp_mkdir_p( $upload_dir ) ) { + $errors[] = new WP_Error( + 'cannot_create_fonts_folder', + sprintf( + /* translators: %s: Directory path. */ + __( 'Error: Unable to create directory %s.', 'gutenberg' ), + esc_html( $upload_dir ) + ) + ); + $response_status = 500; + } + } + + if ( $this->has_upload_directory() && ! $this->has_write_permission() ) { + $errors[] = new WP_Error( + 'cannot_write_fonts_folder', + __( 'Error: WordPress does not have permission to write the fonts folder on your server.', 'gutenberg' ) + ); + $response_status = 500; + } } if ( ! empty( $errors ) ) { diff --git a/lib/experimental/interactivity-api/directive-processing.php b/lib/experimental/interactivity-api/directive-processing.php index 41223c0815886..eae731e243891 100644 --- a/lib/experimental/interactivity-api/directive-processing.php +++ b/lib/experimental/interactivity-api/directive-processing.php @@ -8,37 +8,9 @@ */ /** - * Process directives in each block. - * - * @param string $block_content The block content. - * @param array $block The full block. - * - * @return string Filtered block content. - */ -function gutenberg_interactivity_process_directives_in_root_blocks( $block_content, $block ) { - // Don't process inner blocks or root blocks that don't contain directives. - if ( ! WP_Directive_Processor::is_root_block( $block ) || strpos( $block_content, 'data-wp-' ) === false ) { - return $block_content; - } - - // TODO: Add some directive/components registration mechanism. - $directives = array( - 'data-wp-bind' => 'gutenberg_interactivity_process_wp_bind', - 'data-wp-context' => 'gutenberg_interactivity_process_wp_context', - 'data-wp-class' => 'gutenberg_interactivity_process_wp_class', - 'data-wp-style' => 'gutenberg_interactivity_process_wp_style', - 'data-wp-text' => 'gutenberg_interactivity_process_wp_text', - ); - - $tags = new WP_Directive_Processor( $block_content ); - $tags = gutenberg_interactivity_process_directives( $tags, 'data-wp-', $directives ); - return $tags->get_updated_html(); -} -add_filter( 'render_block', 'gutenberg_interactivity_process_directives_in_root_blocks', 10, 2 ); - -/** - * Mark the inner blocks with a temporary property so we can discard them later, - * and process only the root blocks. + * Process the Interactivity API directives using the root blocks of the + * outermost rendering, ignoring the root blocks of inner blocks like Patterns, + * Template Parts or Content. * * @param array $parsed_block The parsed block. * @param array $source_block The source block. @@ -46,16 +18,56 @@ function gutenberg_interactivity_process_directives_in_root_blocks( $block_conte * * @return array The parsed block. */ -function gutenberg_interactivity_mark_inner_blocks( $parsed_block, $source_block, $parent_block ) { - if ( ! isset( $parent_block ) ) { +function gutenberg_interactivity_process_directives( $parsed_block, $source_block, $parent_block ) { + static $is_inside_root_block = false; + static $process_directives_in_root_blocks = null; + + if ( ! isset( $process_directives_in_root_blocks ) ) { + /** + * Process directives in each root block. + * + * @param string $block_content The block content. + * @param array $block The full block. + * + * @return string Filtered block content. + */ + $process_directives_in_root_blocks = static function ( $block_content, $block ) use ( &$is_inside_root_block ) { + + if ( WP_Directive_Processor::is_root_block( $block ) ) { + + $directives = array( + 'data-wp-bind' => 'gutenberg_interactivity_process_wp_bind', + 'data-wp-context' => 'gutenberg_interactivity_process_wp_context', + 'data-wp-class' => 'gutenberg_interactivity_process_wp_class', + 'data-wp-style' => 'gutenberg_interactivity_process_wp_style', + 'data-wp-text' => 'gutenberg_interactivity_process_wp_text', + ); + + $tags = new WP_Directive_Processor( $block_content ); + $tags = gutenberg_interactivity_process_rendered_html( $tags, 'data-wp-', $directives ); + $is_inside_root_block = false; + return $tags->get_updated_html(); + + } + + return $block_content; + }; + add_filter( 'render_block', $process_directives_in_root_blocks, 10, 2 ); + } + + if ( ! isset( $parent_block ) && ! $is_inside_root_block ) { WP_Directive_Processor::add_root_block( $parsed_block ); + $is_inside_root_block = true; } + return $parsed_block; } -add_filter( 'render_block_data', 'gutenberg_interactivity_mark_inner_blocks', 10, 3 ); +add_filter( 'render_block_data', 'gutenberg_interactivity_process_directives', 10, 3 ); + /** - * Process directives. + * Traverses the HTML searching for Interactivity API directives and processing + * them. * * @param WP_Directive_Processor $tags An instance of the WP_Directive_Processor. * @param string $prefix Attribute prefix. @@ -64,7 +76,7 @@ function gutenberg_interactivity_mark_inner_blocks( $parsed_block, $source_block * @return WP_Directive_Processor The modified instance of the * WP_Directive_Processor. */ -function gutenberg_interactivity_process_directives( $tags, $prefix, $directives ) { +function gutenberg_interactivity_process_rendered_html( $tags, $prefix, $directives ) { $context = new WP_Directive_Context(); $tag_stack = array(); diff --git a/packages/block-editor/src/components/link-control/style.scss b/packages/block-editor/src/components/link-control/style.scss index 8883af42ee2ca..fae3f03786dfe 100644 --- a/packages/block-editor/src/components/link-control/style.scss +++ b/packages/block-editor/src/components/link-control/style.scss @@ -61,6 +61,10 @@ $preview-image-height: 140px; .block-editor-link-control__field { margin: $grid-unit-20; // allow margin collapse for vertical spacing. + .components-base-control__label { + color: $gray-900; + } + input[type="text"], // Specificity overide of URLInput defaults. &.block-editor-url-input input[type="text"].block-editor-url-input__input { @@ -419,6 +423,10 @@ $preview-image-height: 140px; .components-base-control__field { display: flex; // don't allow label to wrap under checkbox. + + .components-checkbox-control__label { + color: $gray-900; + } } // Cancel left margin inherited from WP Admin Forms CSS. diff --git a/packages/block-library/src/form-submit-button/edit.js b/packages/block-library/src/form-submit-button/edit.js index f8d7a65c6877a..4b22b26fd4755 100644 --- a/packages/block-library/src/form-submit-button/edit.js +++ b/packages/block-library/src/form-submit-button/edit.js @@ -14,6 +14,7 @@ const TEMPLATE = [ { text: __( 'Submit' ), tagName: 'button', + type: 'submit', }, ], ], diff --git a/packages/block-library/src/navigation/edit/overlay-menu-preview.js b/packages/block-library/src/navigation/edit/overlay-menu-preview.js index adb1377a604ed..8ea07351821c8 100644 --- a/packages/block-library/src/navigation/edit/overlay-menu-preview.js +++ b/packages/block-library/src/navigation/edit/overlay-menu-preview.js @@ -20,7 +20,7 @@ export default function OverlayMenuPreview( { setAttributes, hasIcon, icon } ) { __nextHasNoMarginBottom label={ __( 'Show icon button' ) } help={ __( - 'Configure the visual appearance of the button opening the overlay menu.' + 'Configure the visual appearance of the button that toggles the overlay menu.' ) } onChange={ ( value ) => setAttributes( { hasIcon: value } ) } checked={ hasIcon } diff --git a/packages/block-library/src/query/index.php b/packages/block-library/src/query/index.php index 201ceed737a12..b6a5733632ff4 100644 --- a/packages/block-library/src/query/index.php +++ b/packages/block-library/src/query/index.php @@ -44,7 +44,11 @@ function render_block_core_query( $attributes, $content, $block ) { $block->block_type->supports['interactivity'] = true; // Add a div to announce messages using `aria-live`. - $last_div_position = strripos( $content, '' ); + $html_tag = 'div'; + if ( ! empty( $attributes['tagName'] ) ) { + $html_tag = esc_attr( $attributes['tagName'] ); + } + $last_tag_position = strripos( $content, '' ); $content = substr_replace( $content, '
', - $last_div_position, + $last_tag_position, 0 ); } diff --git a/packages/components/CHANGELOG.md b/packages/components/CHANGELOG.md index 72edad0df4e10..5d05abc7026b2 100644 --- a/packages/components/CHANGELOG.md +++ b/packages/components/CHANGELOG.md @@ -5,10 +5,17 @@ ### Internal - Migrate `Divider` from `reakit` to `ariakit` ([#55622](https://github.com/WordPress/gutenberg/pull/55622)) +- Migrate `DisclosureContent` from `reakit` to `ariakit` and TypeScript ([#55639](https://github.com/WordPress/gutenberg/pull/55639)) + +### Experimental + +- `Tabs`: Add `focusable` prop to the `Tabs.TabPanel` sub-component ([#55287](https://github.com/WordPress/gutenberg/pull/55287)) +- `Tabs`: Update sub-components to accept relevant HTML element props ([#55860](https://github.com/WordPress/gutenberg/pull/55860)) ### Enhancements - `ToggleGroupControl`: Add opt-in prop for 40px default size ([#55789](https://github.com/WordPress/gutenberg/pull/55789)). +- `TextControl`: Add opt-in prop for 40px default size ([#55471](https://github.com/WordPress/gutenberg/pull/55471)). ## 25.11.0 (2023-11-02) diff --git a/packages/components/src/disclosure/index.js b/packages/components/src/disclosure/index.js deleted file mode 100644 index 5458ba053eef6..0000000000000 --- a/packages/components/src/disclosure/index.js +++ /dev/null @@ -1,11 +0,0 @@ -/** - * Accessible Disclosure component that controls visibility of a section of - * content. It follows the WAI-ARIA Disclosure Pattern. - * - * @see https://reakit.io/docs/disclosure/ - * - * The plan is to build own API that accounts for future breaking changes - * in Reakit (https://github.com/WordPress/gutenberg/pull/28085). - */ -/* eslint-disable-next-line no-restricted-imports */ -export { DisclosureContent } from 'reakit'; diff --git a/packages/components/src/disclosure/index.tsx b/packages/components/src/disclosure/index.tsx new file mode 100644 index 0000000000000..5bacfcabc349a --- /dev/null +++ b/packages/components/src/disclosure/index.tsx @@ -0,0 +1,44 @@ +/** + * External dependencies + */ +// eslint-disable-next-line no-restricted-imports +import * as Ariakit from '@ariakit/react'; + +/** + * WordPress dependencies + */ +import { forwardRef } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import type { DisclosureContentProps } from './types'; +import type { WordPressComponentProps } from '../context'; + +/** + * Accessible Disclosure component that controls visibility of a section of + * content. It follows the WAI-ARIA Disclosure Pattern. + */ +const UnforwardedDisclosureContent = ( + { + visible, + children, + ...props + }: WordPressComponentProps< DisclosureContentProps, 'div', false >, + ref: React.ForwardedRef< any > +) => { + const disclosure = Ariakit.useDisclosureStore( { open: visible } ); + + return ( + + { children } + + ); +}; + +export const DisclosureContent = forwardRef( UnforwardedDisclosureContent ); +export default DisclosureContent; diff --git a/packages/components/src/disclosure/types.tsx b/packages/components/src/disclosure/types.tsx new file mode 100644 index 0000000000000..6a0a746bb6397 --- /dev/null +++ b/packages/components/src/disclosure/types.tsx @@ -0,0 +1,10 @@ +export type DisclosureContentProps = { + /** + * If set to `true` the content will be shown, otherwise it's hidden. + */ + visible?: boolean; + /** + * The content to display within the component. + */ + children: React.ReactNode; +}; diff --git a/packages/components/src/tabs/README.md b/packages/components/src/tabs/README.md index 6907f385fda37..423216e940584 100644 --- a/packages/components/src/tabs/README.md +++ b/packages/components/src/tabs/README.md @@ -159,19 +159,6 @@ The children elements, which should be a series of `Tabs.TabPanel` components. - Required: No -###### `className`: `string` - -The class name to apply to the tablist. - -- Required: No -- Default: '' - -###### `style`: `React.CSSProperties` - -Custom CSS styles for the tablist. - -- Required: No - #### Tab ##### Props @@ -182,24 +169,12 @@ The id of the tab, which is prepended with the `Tabs` instance ID. - Required: Yes -###### `style`: `React.CSSProperties` - -Custom CSS styles for the tab. - -- Required: No - ###### `children`: `React.ReactNode` The children elements, generally the text to display on the tab. - Required: No -###### `className`: `string` - -The class name to apply to the tab. - -- Required: No - ###### `disabled`: `boolean` Determines if the tab button should be disabled. @@ -229,14 +204,9 @@ The id of the tabpanel, which is combined with the `Tabs` instance ID and the su - Required: Yes -###### `className`: `string` - -The class name to apply to the tabpanel. - -- Required: No - -###### `style`: `React.CSSProperties` +###### `focusable`: `boolean` -Custom CSS styles for the tab. +Determines whether or not the tabpanel element should be focusable. If `false`, pressing the tab key will skip over the tabpanel, and instead focus on the first focusable element in the panel (if there is one). - Required: No +- Default: `true` diff --git a/packages/components/src/tabs/stories/index.story.tsx b/packages/components/src/tabs/stories/index.story.tsx index 3b6ba022f6d91..08e2958988170 100644 --- a/packages/components/src/tabs/stories/index.story.tsx +++ b/packages/components/src/tabs/stories/index.story.tsx @@ -42,8 +42,16 @@ const Template: StoryFn< typeof Tabs > = ( props ) => {

Selected tab: Tab 2

- +

Selected tab: Tab 3

+

+ This tabpanel has its focusable prop set to + false, so it won't get a tab stop. +
+ Instead, the [Tab] key will move focus to the first + focusable element within the panel. +

+
); diff --git a/packages/components/src/tabs/styles.ts b/packages/components/src/tabs/styles.ts index 091ba608fb6ec..cb735f3177662 100644 --- a/packages/components/src/tabs/styles.ts +++ b/packages/components/src/tabs/styles.ts @@ -101,3 +101,19 @@ export const Tab = styled( Ariakit.Tab )` } } `; + +export const TabPanel = styled( Ariakit.TabPanel )` + &:focus { + box-shadow: none; + outline: none; + } + + &:focus-visible { + border-radius: 2px; + box-shadow: 0 0 0 var( --wp-admin-border-width-focus ) + ${ COLORS.theme.accent }; + // Windows high contrast mode. + outline: 2px solid transparent; + outline-offset: 0; + } +`; diff --git a/packages/components/src/tabs/tab.tsx b/packages/components/src/tabs/tab.tsx index 75b3df1c1ba01..03e5d80871c56 100644 --- a/packages/components/src/tabs/tab.tsx +++ b/packages/components/src/tabs/tab.tsx @@ -11,11 +11,12 @@ import type { TabProps } from './types'; import warning from '@wordpress/warning'; import { TabsContext } from './context'; import { Tab as StyledTab } from './styles'; +import type { WordPressComponentProps } from '../context'; -export const Tab = forwardRef< HTMLButtonElement, TabProps >( function Tab( - { children, id, className, disabled, render, style }, - ref -) { +export const Tab = forwardRef< + HTMLButtonElement, + WordPressComponentProps< TabProps, 'button', false > +>( function Tab( { children, id, disabled, render, ...otherProps }, ref ) { const context = useContext( TabsContext ); if ( ! context ) { warning( '`Tabs.TabList` must be wrapped in a `Tabs` component.' ); @@ -28,10 +29,9 @@ export const Tab = forwardRef< HTMLButtonElement, TabProps >( function Tab( ref={ ref } store={ store } id={ instancedTabId } - className={ className } - style={ style } disabled={ disabled } render={ render } + { ...otherProps } > { children } diff --git a/packages/components/src/tabs/tablist.tsx b/packages/components/src/tabs/tablist.tsx index 02255fefd2082..7a53115910796 100644 --- a/packages/components/src/tabs/tablist.tsx +++ b/packages/components/src/tabs/tablist.tsx @@ -16,25 +16,26 @@ import { forwardRef } from '@wordpress/element'; import type { TabListProps } from './types'; import { useTabsContext } from './context'; import { TabListWrapper } from './styles'; +import type { WordPressComponentProps } from '../context'; -export const TabList = forwardRef< HTMLDivElement, TabListProps >( - function TabList( { children, className, style }, ref ) { - const context = useTabsContext(); - if ( ! context ) { - warning( '`Tabs.TabList` must be wrapped in a `Tabs` component.' ); - return null; - } - const { store } = context; - return ( - } - > - { children } - - ); +export const TabList = forwardRef< + HTMLDivElement, + WordPressComponentProps< TabListProps, 'div', false > +>( function TabList( { children, ...otherProps }, ref ) { + const context = useTabsContext(); + if ( ! context ) { + warning( '`Tabs.TabList` must be wrapped in a `Tabs` component.' ); + return null; } -); + const { store } = context; + return ( + } + { ...otherProps } + > + { children } + + ); +} ); diff --git a/packages/components/src/tabs/tabpanel.tsx b/packages/components/src/tabs/tabpanel.tsx index fb62fc9191233..f477d1d3b4b43 100644 --- a/packages/components/src/tabs/tabpanel.tsx +++ b/packages/components/src/tabs/tabpanel.tsx @@ -1,8 +1,6 @@ /** * External dependencies */ -// eslint-disable-next-line no-restricted-imports -import * as Ariakit from '@ariakit/react'; /** * WordPress dependencies @@ -14,29 +12,32 @@ import { forwardRef, useContext } from '@wordpress/element'; * Internal dependencies */ import type { TabPanelProps } from './types'; +import { TabPanel as StyledTabPanel } from './styles'; import warning from '@wordpress/warning'; import { TabsContext } from './context'; +import type { WordPressComponentProps } from '../context'; -export const TabPanel = forwardRef< HTMLDivElement, TabPanelProps >( - function TabPanel( { children, id, className, style }, ref ) { - const context = useContext( TabsContext ); - if ( ! context ) { - warning( '`Tabs.TabPanel` must be wrapped in a `Tabs` component.' ); - return null; - } - const { store, instanceId } = context; - - return ( - - { children } - - ); +export const TabPanel = forwardRef< + HTMLDivElement, + WordPressComponentProps< TabPanelProps, 'div', false > +>( function TabPanel( { children, id, focusable = true, ...otherProps }, ref ) { + const context = useContext( TabsContext ); + if ( ! context ) { + warning( '`Tabs.TabPanel` must be wrapped in a `Tabs` component.' ); + return null; } -); + const { store, instanceId } = context; + + return ( + + { children } + + ); +} ); diff --git a/packages/components/src/tabs/test/index.tsx b/packages/components/src/tabs/test/index.tsx index a89e680e244d8..d2a035e436c19 100644 --- a/packages/components/src/tabs/test/index.tsx +++ b/packages/components/src/tabs/test/index.tsx @@ -7,7 +7,6 @@ import userEvent from '@testing-library/user-event'; /** * WordPress dependencies */ -import { wordpress, category, media } from '@wordpress/icons'; import { useState } from '@wordpress/element'; /** @@ -15,7 +14,6 @@ import { useState } from '@wordpress/element'; */ import Tabs from '..'; import type { TabsProps } from '../types'; -import type { IconType } from '../../icon'; type Tab = { id: string; @@ -23,9 +21,11 @@ type Tab = { content: React.ReactNode; tab: { className?: string; - icon?: IconType; disabled?: boolean; }; + tabpanel?: { + focusable?: boolean; + }; }; const TABS: Tab[] = [ @@ -33,19 +33,19 @@ const TABS: Tab[] = [ id: 'alpha', title: 'Alpha', content: 'Selected tab: Alpha', - tab: { className: 'alpha-class', icon: wordpress }, + tab: { className: 'alpha-class' }, }, { id: 'beta', title: 'Beta', content: 'Selected tab: Beta', - tab: { className: 'beta-class', icon: category }, + tab: { className: 'beta-class' }, }, { id: 'gamma', title: 'Gamma', content: 'Selected tab: Gamma', - tab: { className: 'gamma-class', icon: media }, + tab: { className: 'gamma-class' }, }, ]; @@ -55,17 +55,15 @@ const TABS_WITH_DELTA: Tab[] = [ id: 'delta', title: 'Delta', content: 'Selected tab: Delta', - tab: { className: 'delta-class', icon: media }, + tab: { className: 'delta-class' }, }, ]; const UncontrolledTabs = ( { tabs, - showTabIcons = false, ...props }: Omit< TabsProps, 'children' > & { tabs: Tab[]; - showTabIcons?: boolean; } ) => { return ( @@ -76,14 +74,17 @@ const UncontrolledTabs = ( { id={ tabObj.id } className={ tabObj.tab.className } disabled={ tabObj.tab.disabled } - icon={ showTabIcons ? tabObj.tab.icon : undefined } > - { showTabIcons ? null : tabObj.title } + { tabObj.title } ) ) } { tabs.map( ( tabObj ) => ( - + { tabObj.content } ) ) } @@ -93,11 +94,9 @@ const UncontrolledTabs = ( { const ControlledTabs = ( { tabs, - showTabIcons = false, ...props }: Omit< TabsProps, 'children' > & { tabs: Tab[]; - showTabIcons?: boolean; } ) => { const [ selectedTabId, setSelectedTabId ] = useState< string | undefined | null @@ -119,9 +118,8 @@ const ControlledTabs = ( { id={ tabObj.id } className={ tabObj.tab.className } disabled={ tabObj.tab.disabled } - icon={ showTabIcons ? tabObj.tab.icon : undefined } > - { showTabIcons ? null : tabObj.title } + { tabObj.title } ) ) } @@ -184,6 +182,63 @@ describe( 'Tabs', () => { ); } ); } ); + describe( 'Focus Behavior', () => { + it( 'should focus on the related TabPanel when pressing the Tab key', async () => { + const user = userEvent.setup(); + + render( ); + + const selectedTabPanel = await screen.findByRole( 'tabpanel' ); + + // Tab should initially focus the first tab in the tablist, which + // is Alpha. + await user.keyboard( '[Tab]' ); + expect( + await screen.findByRole( 'tab', { name: 'Alpha' } ) + ).toHaveFocus(); + + // By default the tabpanel should receive focus + await user.keyboard( '[Tab]' ); + expect( selectedTabPanel ).toHaveFocus(); + } ); + it( 'should not focus on the related TabPanel when pressing the Tab key if `focusable: false` is set', async () => { + const user = userEvent.setup(); + + const TABS_WITH_ALPHA_FOCUSABLE_FALSE = TABS.map( ( tabObj ) => + tabObj.id === 'alpha' + ? { + ...tabObj, + content: ( + <> + Selected Tab: Alpha + + + ), + tabpanel: { focusable: false }, + } + : tabObj + ); + + render( + + ); + + const alphaButton = await screen.findByRole( 'button', { + name: /alpha button/i, + } ); + + // Tab should initially focus the first tab in the tablist, which + // is Alpha. + await user.keyboard( '[Tab]' ); + expect( + await screen.findByRole( 'tab', { name: 'Alpha' } ) + ).toHaveFocus(); + // Because the alpha tabpanel is set to `focusable: false`, pressing + // the Tab key should focus the button, not the tabpanel + await user.keyboard( '[Tab]' ); + expect( alphaButton ).toHaveFocus(); + } ); + } ); describe( 'Tab Attributes', () => { it( "should apply the tab's `className` to the tab button", async () => { diff --git a/packages/components/src/tabs/types.ts b/packages/components/src/tabs/types.ts index 88e25eb5a3863..8b07193741091 100644 --- a/packages/components/src/tabs/types.ts +++ b/packages/components/src/tabs/types.ts @@ -4,11 +4,6 @@ // eslint-disable-next-line no-restricted-imports import type * as Ariakit from '@ariakit/react'; -/** - * Internal dependencies - */ -import type { IconType } from '../icon'; - export type TabsContextProps = | { /** @@ -78,14 +73,6 @@ export type TabListProps = { * The children elements, which should be a series of `Tabs.TabPanel` components. */ children?: React.ReactNode; - /** - * The class name to apply to the tablist. - */ - className?: string; - /** - * Custom CSS styles for the rendered tablist. - */ - style?: React.CSSProperties; }; export type TabProps = { @@ -93,22 +80,10 @@ export type TabProps = { * The id of the tab, which is prepended with the `Tabs` instanceId. */ id: string; - /** - * Custom CSS styles for the tab. - */ - style?: React.CSSProperties; /** * The children elements, generally the text to display on the tab. */ children?: React.ReactNode; - /** - * The class name to apply to the tab button. - */ - className?: string; - /** - * The icon used for the tab button. - */ - icon?: IconType; /** * Determines if the tab button should be disabled. * @@ -128,15 +103,15 @@ export type TabPanelProps = { */ children?: React.ReactNode; /** - * A unique identifier for the TabPanel, which is used to generate a unique `id` for the underlying element. + * A unique identifier for the tabpanel, which is used to generate a unique `id` for the underlying element. */ id: string; /** - * The class name to apply to the tabpanel. - */ - className?: string; - /** - * Custom CSS styles for the rendered `TabPanel` component. + * Determines whether or not the tabpanel element should be focusable. + * If `false`, pressing the tab key will skip over the tabpanel, and instead + * focus on the first focusable element in the panel (if there is one). + * + * @default true */ - style?: React.CSSProperties; + focusable?: boolean; }; diff --git a/packages/components/src/text-control/index.tsx b/packages/components/src/text-control/index.tsx index 31b1462a3b3a4..30298357c3c01 100644 --- a/packages/components/src/text-control/index.tsx +++ b/packages/components/src/text-control/index.tsx @@ -2,6 +2,7 @@ * External dependencies */ import type { ChangeEvent, ForwardedRef } from 'react'; +import classnames from 'classnames'; /** * WordPress dependencies @@ -22,6 +23,7 @@ function UnforwardedTextControl( ) { const { __nextHasNoMarginBottom, + __next40pxDefaultSize = false, label, hideLabelFromVision, value, @@ -46,7 +48,9 @@ function UnforwardedTextControl( className={ className } > `: Current user object. +### getDefaultTemplateId + +Returns the default template use to render a given query. + +_Parameters_ + +- _state_ `State`: Data state. +- _query_ `TemplateQuery`: Query. + +_Returns_ + +- `string`: The default template id for the given query. + ### getEditedEntityRecord Returns the specified entity record, merged with its edits. diff --git a/packages/core-data/src/actions.js b/packages/core-data/src/actions.js index c4b19819ed7a4..9e7277f35a62a 100644 --- a/packages/core-data/src/actions.js +++ b/packages/core-data/src/actions.js @@ -908,3 +908,19 @@ export function receiveNavigationFallbackId( fallbackId ) { fallbackId, }; } + +/** + * Returns an action object used to set the template for a given query. + * + * @param {Object} query The lookup query. + * @param {string} templateId The resolved template id. + * + * @return {Object} Action object. + */ +export function receiveDefaultTemplateId( query, templateId ) { + return { + type: 'RECEIVE_DEFAULT_TEMPLATE', + query, + templateId, + }; +} diff --git a/packages/core-data/src/reducer.js b/packages/core-data/src/reducer.js index 68c0cc233d7b6..a21623d8ba89d 100644 --- a/packages/core-data/src/reducer.js +++ b/packages/core-data/src/reducer.js @@ -572,6 +572,26 @@ export function themeGlobalStyleRevisions( state = {}, action ) { return state; } +/** + * Reducer managing the template lookup per query. + * + * @param {Record} state Current state. + * @param {Object} action Dispatched action. + * + * @return {Record} Updated state. + */ +export function defaultTemplates( state = {}, action ) { + switch ( action.type ) { + case 'RECEIVE_DEFAULT_TEMPLATE': + return { + ...state, + [ JSON.stringify( action.query ) ]: action.templateId, + }; + } + + return state; +} + export default combineReducers( { terms, users, @@ -592,4 +612,5 @@ export default combineReducers( { blockPatternCategories, userPatternCategories, navigationFallbackId, + defaultTemplates, } ); diff --git a/packages/core-data/src/resolvers.js b/packages/core-data/src/resolvers.js index 5fc7cd14f35c0..cd2a65a60b013 100644 --- a/packages/core-data/src/resolvers.js +++ b/packages/core-data/src/resolvers.js @@ -707,3 +707,14 @@ export const getNavigationFallbackId = ] ); } }; + +export const getDefaultTemplateId = + ( query ) => + async ( { dispatch } ) => { + const template = await apiFetch( { + path: addQueryArgs( '/wp/v2/templates/lookup', query ), + } ); + if ( template ) { + dispatch.receiveDefaultTemplateId( query, template.id ); + } + }; diff --git a/packages/core-data/src/selectors.ts b/packages/core-data/src/selectors.ts index b6b36fad2ee93..2a046941611c7 100644 --- a/packages/core-data/src/selectors.ts +++ b/packages/core-data/src/selectors.ts @@ -46,6 +46,7 @@ export interface State { users: UserState; navigationFallbackId: EntityRecordKey; userPatternCategories: Array< UserPatternCategory >; + defaultTemplates: Record< string, string >; } type EntityRecordKey = string | number; @@ -81,6 +82,12 @@ interface UserState { byId: Record< EntityRecordKey, ET.User< 'edit' > >; } +type TemplateQuery = { + slug?: string; + is_custom?: boolean; + ignore_empty?: boolean; +}; + export interface UserPatternCategory { id: number; name: string; @@ -1351,3 +1358,18 @@ export function getCurrentThemeGlobalStylesRevisions( return state.themeGlobalStyleRevisions[ currentGlobalStylesId ]; } + +/** + * Returns the default template use to render a given query. + * + * @param state Data state. + * @param query Query. + * + * @return The default template id for the given query. + */ +export function getDefaultTemplateId( + state: State, + query: TemplateQuery +): string { + return state.defaultTemplates[ JSON.stringify( query ) ]; +} diff --git a/packages/data/src/components/use-select/index.js b/packages/data/src/components/use-select/index.js index 62da6a005c0d0..84cf7ed617f18 100644 --- a/packages/data/src/components/use-select/index.js +++ b/packages/data/src/components/use-select/index.js @@ -193,7 +193,14 @@ function useStaticSelect( storeName ) { function useMappingSelect( suspense, mapSelect, deps ) { const registry = useRegistry(); const isAsync = useAsyncMode(); - const store = useMemo( () => Store( registry, suspense ), [ registry ] ); + const store = useMemo( + () => Store( registry, suspense ), + [ registry, suspense ] + ); + + // These are "pass-through" dependencies from the parent hook, + // and the parent should catch any hook rule violations. + // eslint-disable-next-line react-hooks/exhaustive-deps const selector = useCallback( mapSelect, deps ); const { subscribe, getValue } = store( selector, isAsync ); const result = useSyncExternalStore( subscribe, getValue, getValue ); diff --git a/packages/e2e-test-utils-playwright/src/admin/visit-site-editor.ts b/packages/e2e-test-utils-playwright/src/admin/visit-site-editor.ts index da21f17aade11..5883a2b92a5bc 100644 --- a/packages/e2e-test-utils-playwright/src/admin/visit-site-editor.ts +++ b/packages/e2e-test-utils-playwright/src/admin/visit-site-editor.ts @@ -47,7 +47,9 @@ export async function visitSiteEditor( * loading is done. */ await this.page - .locator( '.edit-site-canvas-loader' ) + // Spinner was used instead of the progress bar in an earlier version of + // the site editor. + .locator( '.edit-site-canvas-loader, .edit-site-canvas-spinner' ) // Bigger timeout is needed for larger entities, for example the large // post html fixture that we load for performance tests, which often // doesn't make it under the default 10 seconds. diff --git a/packages/e2e-test-utils-playwright/src/editor/click-block-options-menu-item.ts b/packages/e2e-test-utils-playwright/src/editor/click-block-options-menu-item.ts index 2c473352c6123..e8c02fb11dcb7 100644 --- a/packages/e2e-test-utils-playwright/src/editor/click-block-options-menu-item.ts +++ b/packages/e2e-test-utils-playwright/src/editor/click-block-options-menu-item.ts @@ -12,8 +12,7 @@ import type { Editor } from './index'; export async function clickBlockOptionsMenuItem( this: Editor, label: string ) { await this.clickBlockToolbarButton( 'Options' ); await this.page - .locator( - `role=menu[name="Options"i] >> role=menuitem[name="${ label }"i]` - ) + .getByRole( 'menu', { name: 'Options' } ) + .getByRole( 'menuitem', { name: label } ) .click(); } diff --git a/packages/e2e-tests/plugins/interactive-blocks.php b/packages/e2e-tests/plugins/interactive-blocks.php index a6bd468493840..956508a11361e 100644 --- a/packages/e2e-tests/plugins/interactive-blocks.php +++ b/packages/e2e-tests/plugins/interactive-blocks.php @@ -39,8 +39,8 @@ function () { // HTML is not correct or malformed. if ( 'true' === $_GET['disable_directives_ssr'] ) { remove_filter( - 'render_block', - 'gutenberg_interactivity_process_directives_in_root_blocks' + 'render_block_data', + 'gutenberg_interactivity_process_directives' ); } } diff --git a/packages/e2e-tests/specs/editor/plugins/child-blocks.test.js b/packages/e2e-tests/specs/editor/plugins/child-blocks.test.js deleted file mode 100644 index c7ca368003397..0000000000000 --- a/packages/e2e-tests/specs/editor/plugins/child-blocks.test.js +++ /dev/null @@ -1,66 +0,0 @@ -/** - * WordPress dependencies - */ -import { - activatePlugin, - closeGlobalBlockInserter, - createNewPost, - deactivatePlugin, - getAllBlockInserterItemTitles, - insertBlock, - openGlobalBlockInserter, -} from '@wordpress/e2e-test-utils'; - -describe( 'Child Blocks', () => { - beforeAll( async () => { - await activatePlugin( 'gutenberg-test-child-blocks' ); - } ); - - beforeEach( async () => { - await createNewPost(); - } ); - - afterAll( async () => { - await deactivatePlugin( 'gutenberg-test-child-blocks' ); - } ); - - it( 'are hidden from the global block inserter', async () => { - await openGlobalBlockInserter(); - await expect( await getAllBlockInserterItemTitles() ).not.toContain( - 'Child Blocks Child' - ); - } ); - - it( 'shows up in a parent block', async () => { - await insertBlock( 'Child Blocks Unrestricted Parent' ); - await closeGlobalBlockInserter(); - await page.waitForSelector( - '[data-type="test/child-blocks-unrestricted-parent"] .block-editor-default-block-appender' - ); - await page.click( - '[data-type="test/child-blocks-unrestricted-parent"] .block-editor-default-block-appender' - ); - await openGlobalBlockInserter(); - const inserterItemTitles = await getAllBlockInserterItemTitles(); - expect( inserterItemTitles ).toContain( 'Child Blocks Child' ); - expect( inserterItemTitles.length ).toBeGreaterThan( 20 ); - } ); - - it( 'display in a parent block with allowedItems', async () => { - await insertBlock( 'Child Blocks Restricted Parent' ); - await closeGlobalBlockInserter(); - await page.waitForSelector( - '[data-type="test/child-blocks-restricted-parent"] .block-editor-default-block-appender' - ); - await page.click( - '[data-type="test/child-blocks-restricted-parent"] .block-editor-default-block-appender' - ); - await openGlobalBlockInserter(); - const allowedBlocks = await getAllBlockInserterItemTitles(); - expect( allowedBlocks.sort() ).toEqual( [ - 'Child Blocks Child', - 'Image', - 'Paragraph', - ] ); - } ); -} ); diff --git a/packages/e2e-tests/specs/editor/plugins/meta-boxes.test.js b/packages/e2e-tests/specs/editor/plugins/meta-boxes.test.js deleted file mode 100644 index 3a75f9656c71e..0000000000000 --- a/packages/e2e-tests/specs/editor/plugins/meta-boxes.test.js +++ /dev/null @@ -1,137 +0,0 @@ -/** - * WordPress dependencies - */ -import { - activatePlugin, - createNewPost, - deactivatePlugin, - findSidebarPanelToggleButtonWithTitle, - insertBlock, - openDocumentSettingsSidebar, - publishPost, - saveDraft, -} from '@wordpress/e2e-test-utils'; - -describe( 'Meta boxes', () => { - beforeAll( async () => { - await activatePlugin( 'gutenberg-test-plugin-meta-box' ); - } ); - - beforeEach( async () => { - await createNewPost(); - } ); - - afterAll( async () => { - await deactivatePlugin( 'gutenberg-test-plugin-meta-box' ); - } ); - - it( 'Should save the post', async () => { - // Save should not be an option for new empty post. - expect( await page.$( '.editor-post-save-draft' ) ).toBe( null ); - - // Add title to enable valid non-empty post save. - await page.type( '.editor-post-title__input', 'Hello Meta' ); - expect( await page.$( '.editor-post-save-draft' ) ).not.toBe( null ); - - await saveDraft(); - - // After saving, affirm that the button returns to Save Draft. - await page.waitForSelector( '.editor-post-save-draft' ); - } ); - - it( 'Should render dynamic blocks when the meta box uses the excerpt for front end rendering', async () => { - // Publish a post so there's something for the latest posts dynamic block to render. - await page.type( '.editor-post-title__input', 'A published post' ); - await insertBlock( 'Paragraph' ); - await page.keyboard.type( 'Hello there!' ); - await publishPost(); - - // Publish a post with the latest posts dynamic block. - await createNewPost(); - await page.type( '.editor-post-title__input', 'Dynamic block test' ); - await insertBlock( 'Latest Posts' ); - await publishPost(); - - // View the post. - const viewPostLinks = await page.$x( - "//a[contains(text(), 'View Post')]" - ); - await viewPostLinks[ 0 ].click(); - await page.waitForNavigation(); - - // Check the dynamic block appears. - const latestPostsBlock = await page.waitForSelector( - '.wp-block-latest-posts' - ); - - expect( - await latestPostsBlock.evaluate( ( block ) => block.textContent ) - ).toContain( 'A published post' ); - - expect( - await latestPostsBlock.evaluate( ( block ) => block.textContent ) - ).toContain( 'Dynamic block test' ); - } ); - - it( 'Should render the excerpt in meta based on post content if no explicit excerpt exists', async () => { - await insertBlock( 'Paragraph' ); - await page.keyboard.type( 'Excerpt from content.' ); - await page.type( '.editor-post-title__input', 'A published post' ); - await publishPost(); - - // View the post. - const viewPostLinks = await page.$x( - "//a[contains(text(), 'View Post')]" - ); - await viewPostLinks[ 0 ].click(); - await page.waitForNavigation(); - - // Retrieve the excerpt used as meta. - const metaExcerpt = await page.evaluate( () => { - return document - .querySelector( 'meta[property="gutenberg:hello"]' ) - .getAttribute( 'content' ); - } ); - - expect( metaExcerpt ).toEqual( 'Excerpt from content.' ); - } ); - - it( 'Should render the explicitly set excerpt in meta instead of the content based one', async () => { - await insertBlock( 'Paragraph' ); - await page.keyboard.type( 'Excerpt from content.' ); - await page.type( '.editor-post-title__input', 'A published post' ); - - // Open the excerpt panel. - await openDocumentSettingsSidebar(); - const excerptButton = - await findSidebarPanelToggleButtonWithTitle( 'Excerpt' ); - if ( excerptButton ) { - await excerptButton.click( 'button' ); - } - - await page.waitForSelector( '.editor-post-excerpt textarea' ); - - await page.type( - '.editor-post-excerpt textarea', - 'Explicitly set excerpt.' - ); - - await publishPost(); - - // View the post. - const viewPostLinks = await page.$x( - "//a[contains(text(), 'View Post')]" - ); - await viewPostLinks[ 0 ].click(); - await page.waitForNavigation(); - - // Retrieve the excerpt used as meta. - const metaExcerpt = await page.evaluate( () => { - return document - .querySelector( 'meta[property="gutenberg:hello"]' ) - .getAttribute( 'content' ); - } ); - - expect( metaExcerpt ).toEqual( 'Explicitly set excerpt.' ); - } ); -} ); diff --git a/packages/edit-post/src/components/start-page-options/index.js b/packages/edit-post/src/components/start-page-options/index.js index 02473fd4eaa14..77264d27a5e7d 100644 --- a/packages/edit-post/src/components/start-page-options/index.js +++ b/packages/edit-post/src/components/start-page-options/index.js @@ -3,7 +3,7 @@ */ import { Modal } from '@wordpress/components'; import { __ } from '@wordpress/i18n'; -import { useState, useEffect, useMemo } from '@wordpress/element'; +import { useState, useMemo } from '@wordpress/element'; import { store as blockEditorStore, __experimentalBlockPatternsList as BlockPatternsList, @@ -62,19 +62,11 @@ function PatternSelection( { blockPatterns, onChoosePattern } ) { ); } -function StartPageOptionsModal() { - const [ modalState, setModalState ] = useState( 'initial' ); +function StartPageOptionsModal( { onClose } ) { const startPatterns = useStartPatterns(); const hasStartPattern = startPatterns.length > 0; - const shouldOpenModal = hasStartPattern && modalState === 'initial'; - useEffect( () => { - if ( shouldOpenModal ) { - setModalState( 'open' ); - } - }, [ shouldOpenModal ] ); - - if ( modalState !== 'open' ) { + if ( ! hasStartPattern ) { return null; } @@ -83,12 +75,12 @@ function StartPageOptionsModal() { className="edit-post-start-page-options__modal" title={ __( 'Choose a pattern' ) } isFullScreen - onRequestClose={ () => setModalState( 'closed' ) } + onRequestClose={ onClose } >
setModalState( 'closed' ) } + onChoosePattern={ onClose } />
@@ -96,6 +88,7 @@ function StartPageOptionsModal() { } export default function StartPageOptions() { + const [ isClosed, setIsClosed ] = useState( false ); const shouldEnableModal = useSelect( ( select ) => { const { isCleanNewPost } = select( editorStore ); const { isEditingTemplate, isFeatureActive } = select( editPostStore ); @@ -107,9 +100,9 @@ export default function StartPageOptions() { ); }, [] ); - if ( ! shouldEnableModal ) { + if ( ! shouldEnableModal || isClosed ) { return null; } - return ; + return setIsClosed( true ) } />; } diff --git a/packages/edit-site/src/components/dataviews/README.md b/packages/edit-site/src/components/dataviews/README.md index 4b978f1ee7883..7a5abc8ad485d 100644 --- a/packages/edit-site/src/components/dataviews/README.md +++ b/packages/edit-site/src/components/dataviews/README.md @@ -59,21 +59,38 @@ Example: - `sort.field`: field used for sorting the dataset. - `sort.direction`: the direction to use for sorting, one of `asc` or `desc`. - `search`: the text search applied to the dataset. -- `filters`: the filters applied to the dataset. See filters section. +- `filters`: the filters applied to the dataset. Each item describes: + - `field`: which field this filter is bound to. + - `operator`: which type of filter it is. Only `in` available at the moment. + - `vaule`: the actual value selected by the user. - `visibleFilters`: the `id` of the filters that are visible in the UI. - `hiddenFields`: the `id` of the fields that are hidden in the UI. - `layout`: ... -Note that it's the consumer's responsibility to provide the data and make sure the dataset corresponds to the view's config (sort, pagination, filters, etc.). +### View <=> data -Example: +The view is a representation of the visible state of the dataset. Note, however, that it's the consumer's responsibility to work with the data provider to make sure the user options defined through the view's config (sort, pagination, filters, etc.) are respected. + +The following example shows how a view object is used to query the WordPress REST API via the entities abstraction. The same can be done with any other data provider. ```js function MyCustomPageList() { const [ view, setView ] = useState( { type: 'list', + perPage: 5, page: 1, - "...": "..." + sort: { + field: 'date', + direction: 'desc', + }, + search: '', + filters: [ + { field: 'author', operator: 'in', value: 2 }, + { field: 'status', operator: 'in', value: 'publish,draft' } + ], + visibleFilters: [ 'author', 'status' ], + hiddenFields: [ 'date', 'featured-image' ], + layout: {}, } ); const queryArgs = useMemo( () => { @@ -143,7 +160,7 @@ Example: { value: 1, label: 'Admin' } { value: 2, label: 'User' } ] - filters: [ 'enumeration' ], + filters: [ 'in' ], } ] ``` @@ -153,45 +170,16 @@ Example: - `getValue`: function that returns the value of the field. - `render`: function that renders the field. - `elements`: the set of valid values for the field's value. -- `filters`: what filters are available for the user to use. See filters section. - -## Filters - -Filters describe the conditions a record should match to be listed as part of the dataset. Filters are provided per field. - -```js -const field = [ - { - id: 'author', - filters: [ 'enumeration' ], - } -]; - - -``` +- `filters`: what filter operators are available for the user to use over this field. Only `in` available at the moment. -A filter is an object that may contain the following properties: +## Actions -- `type`: the type of filter. Only `enumeration` is supported at the moment. -- `elements`: for filters of type `enumeration`, the list of options to show. A one-dimensional array of object with value/label keys, as in `[ { value: 1, label: "Value name" } ]`. - - `value`: what's serialized into the view's filters. - - `label`: nice-looking name for users. +Array of operations that can be performed upon each record. Each action is an object with the following properties: -As a convenience, field's filter can provide abbreviated versions for the filter. All of following examples result in the same filter: - -```js -const field = [ - { - id: 'author', - header: __( 'Author' ), - elements: authors, - filters: [ - 'enumeration', - { type: 'enumeration' }, - { type: 'enumeration', elements: authors }, - ], - } -]; -``` +- `id`: string, required. Unique identifier of the action. For example, `move-to-trash`. +- `label`: string, required. User facing description of the action. For example, `Move to Trash`. +- `isPrimary`: boolean, optional. Whether the action should be listed inline (primary) or in hidden in the more actions menu (secondary). +- `icon`: icon to show for primary actions. It's required for a primary action, otherwise the action would be considered secondary. +- `isEligible`: function, optional. Whether the action can be performed for a given record. If not present, the action is considered to be eligible for all items. It takes the given record as input. +- `isDestructive`: boolean, optional. Whether the action can delete data, in which case the UI would communicate it via red color. +- `perform`: function, required. Function that takes the record as input and performs the required action. diff --git a/packages/edit-site/src/components/dataviews/filters.js b/packages/edit-site/src/components/dataviews/filters.js index 0e7466f373742..e34ba84040a95 100644 --- a/packages/edit-site/src/components/dataviews/filters.js +++ b/packages/edit-site/src/components/dataviews/filters.js @@ -6,67 +6,71 @@ import { __ } from '@wordpress/i18n'; /** * Internal dependencies */ -import InFilter from './in-filter'; +import { default as InFilter, OPERATOR_IN } from './in-filter'; +import ResetFilters from './reset-filters'; + +const VALID_OPERATORS = [ OPERATOR_IN ]; export default function Filters( { fields, view, onChangeView } ) { - const filterIndex = {}; + const filtersRegistered = []; fields.forEach( ( field ) => { if ( ! field.filters ) { return; } field.filters.forEach( ( filter ) => { - const id = field.id; - if ( 'string' === typeof filter ) { - filterIndex[ id ] = { - id, - name: field.header, - type: filter, - }; - } - - if ( 'object' === typeof filter ) { - filterIndex[ id ] = { - id, + if ( VALID_OPERATORS.some( ( operator ) => operator === filter ) ) { + filtersRegistered.push( { + field: field.id, name: field.header, - type: filter.type, - }; - } - - if ( 'enumeration' === filterIndex[ id ]?.type ) { - const elements = [ - { - value: '', - label: __( 'All' ), - }, - ...( field.elements || [] ), - ]; - filterIndex[ id ] = { - ...filterIndex[ id ], - elements, - }; + operator: filter, + elements: [ + { + value: '', + label: __( 'All' ), + }, + ...( field.elements || [] ), + ], + } ); } } ); } ); - return view.visibleFilters?.map( ( filterName ) => { - const filter = filterIndex[ filterName ]; + const visibleFilters = view.visibleFilters + ?.map( ( fieldName ) => { + const visibleFiltersForField = filtersRegistered.filter( + ( f ) => f.field === fieldName + ); - if ( ! filter ) { - return null; - } + if ( visibleFiltersForField.length === 0 ) { + return null; + } - if ( filter.type === 'enumeration' ) { - return ( - - ); - } + return visibleFiltersForField.map( ( filter ) => { + if ( OPERATOR_IN === filter.operator ) { + return ( + + ); + } + return null; + } ); + } ) + .filter( Boolean ); - return null; - } ); + if ( visibleFilters?.length > 0 ) { + visibleFilters.push( + + ); + } + + return visibleFilters; } diff --git a/packages/edit-site/src/components/dataviews/in-filter.js b/packages/edit-site/src/components/dataviews/in-filter.js index 826e94de652de..9abd9a2ee21f1 100644 --- a/packages/edit-site/src/components/dataviews/in-filter.js +++ b/packages/edit-site/src/components/dataviews/in-filter.js @@ -6,11 +6,11 @@ import { SelectControl, } from '@wordpress/components'; -const OPERATOR_IN = 'in'; +export const OPERATOR_IN = 'in'; export default ( { filter, view, onChangeView } ) => { const valueFound = view.filters.find( - ( f ) => f.field === filter.id && f.operator === OPERATOR_IN + ( f ) => f.field === filter.field && f.operator === OPERATOR_IN ); const activeValue = @@ -32,11 +32,12 @@ export default ( { filter, view, onChangeView } ) => { options={ filter.elements } onChange={ ( value ) => { const filters = view.filters.filter( - ( f ) => f.field !== filter.id || f.operator !== OPERATOR_IN + ( f ) => + f.field !== filter.field || f.operator !== OPERATOR_IN ); if ( value !== '' ) { filters.push( { - field: filter.id, + field: filter.field, operator: OPERATOR_IN, value, } ); diff --git a/packages/edit-site/src/components/dataviews/pagination.js b/packages/edit-site/src/components/dataviews/pagination.js index 806d4bf7987cc..7948cf01ecfc2 100644 --- a/packages/edit-site/src/components/dataviews/pagination.js +++ b/packages/edit-site/src/components/dataviews/pagination.js @@ -83,16 +83,17 @@ function Pagination( { min={ 1 } max={ totalPages } onChange={ ( value ) => { + const _value = +value; if ( - ! value || - value < 1 || - value > totalPages + ! _value || + _value < 1 || + _value > totalPages ) { return; } onChangeView( { ...view, - page: value, + page: _value, } ); } } step="1" diff --git a/packages/edit-site/src/components/dataviews/reset-filters.js b/packages/edit-site/src/components/dataviews/reset-filters.js new file mode 100644 index 0000000000000..68b5c17590c97 --- /dev/null +++ b/packages/edit-site/src/components/dataviews/reset-filters.js @@ -0,0 +1,26 @@ +/** + * WordPress dependencies + */ +import { BaseControl, Button } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; + +export default ( { view, onChangeView } ) => { + return ( + + + + ); +}; diff --git a/packages/edit-site/src/components/dataviews/view-actions.js b/packages/edit-site/src/components/dataviews/view-actions.js index 035ab6e36facf..cb6aa022cf9a5 100644 --- a/packages/edit-site/src/components/dataviews/view-actions.js +++ b/packages/edit-site/src/components/dataviews/view-actions.js @@ -86,7 +86,7 @@ function ViewTypeMenu( { view, onChangeView } ) { ); } -const PAGE_SIZE_VALUES = [ 5, 20, 50 ]; +const PAGE_SIZE_VALUES = [ 20, 50, 100 ]; function PageSizeMenu( { view, onChangeView } ) { return ( 0 && header.column.columnDef.filters.some( - ( f ) => - ( 'string' === typeof f && f === 'enumeration' ) || - ( 'object' === typeof f && f.type === 'enumeration' ) + ( f ) => 'string' === typeof f && f === 'in' ) ) { filter = { - id: header.column.columnDef.id, + field: header.column.columnDef.id, elements: [ { value: '', @@ -149,7 +147,7 @@ function HeaderMenu( { dataView, header } ) { { isFilterable && ( } @@ -169,7 +167,7 @@ function HeaderMenu( { dataView, header } ) { ( f ) => Object.keys( f )[ 0 ].split( ':' - )[ 0 ] === filter.id + )[ 0 ] === filter.field ); // Set the empty item as active if the filter is not set. @@ -204,7 +202,7 @@ function HeaderMenu( { dataView, header } ) { )[ 0 ].split( ':' ); return ( field !== - filter.id || + filter.field || operator !== 'in' ); } @@ -218,8 +216,8 @@ function HeaderMenu( { dataView, header } ) { dataView.setColumnFilters( [ ...otherFilters, { - [ filter.id + ':in' ]: - element.value, + [ filter.field + + ':in' ]: element.value, }, ] ); } diff --git a/packages/edit-site/src/components/global-styles/screen-revisions/index.js b/packages/edit-site/src/components/global-styles/screen-revisions/index.js index 46a7a8c74ab69..d0c256b6905ec 100644 --- a/packages/edit-site/src/components/global-styles/screen-revisions/index.js +++ b/packages/edit-site/src/components/global-styles/screen-revisions/index.js @@ -32,7 +32,7 @@ const { GlobalStylesContext, areGlobalStyleConfigsEqual } = unlock( ); function ScreenRevisions() { - const { goBack } = useNavigator(); + const { goTo } = useNavigator(); const { user: userConfig, setUserConfig } = useContext( GlobalStylesContext ); const { blocks, editorCanvasContainerView } = useSelect( ( select ) => { @@ -58,13 +58,13 @@ function ScreenRevisions() { useEffect( () => { if ( editorCanvasContainerView !== 'global-styles-revisions' ) { - goBack(); + goTo( '/' ); // Return to global styles main panel. setEditorCanvasContainerView( editorCanvasContainerView ); } }, [ editorCanvasContainerView ] ); const onCloseRevisions = () => { - goBack(); + goTo( '/' ); // Return to global styles main panel. }; const restoreRevision = ( revision ) => { diff --git a/packages/edit-site/src/components/page-pages/index.js b/packages/edit-site/src/components/page-pages/index.js index 529b22f01ca53..4aa670853e97e 100644 --- a/packages/edit-site/src/components/page-pages/index.js +++ b/packages/edit-site/src/components/page-pages/index.js @@ -231,7 +231,7 @@ export default function PagePages() { ); }, - filters: [ 'enumeration' ], + filters: [ 'in' ], elements: authors?.map( ( { id, name } ) => ( { value: id, @@ -244,7 +244,7 @@ export default function PagePages() { getValue: ( { item } ) => statuses?.find( ( { slug } ) => slug === item.status ) ?.name ?? item.status, - filters: [ 'enumeration' ], + filters: [ 'in' ], elements: statuses?.map( ( { slug, name } ) => ( { value: slug, diff --git a/packages/edit-site/src/components/sidebar-dataviews/custom-dataviews-list.js b/packages/edit-site/src/components/sidebar-dataviews/custom-dataviews-list.js index f9b0cddb7d8e1..a8aef191c445e 100644 --- a/packages/edit-site/src/components/sidebar-dataviews/custom-dataviews-list.js +++ b/packages/edit-site/src/components/sidebar-dataviews/custom-dataviews-list.js @@ -3,8 +3,12 @@ */ import { useSelect } from '@wordpress/data'; import { store as coreStore } from '@wordpress/core-data'; -import { __experimentalItemGroup as ItemGroup } from '@wordpress/components'; +import { + __experimentalItemGroup as ItemGroup, + __experimentalHeading as Heading, +} from '@wordpress/components'; import { useMemo } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; /** * Internal dependencies @@ -70,20 +74,25 @@ export function useCustomDataViews( type ) { export default function CustomDataViewsList( { type, activeView, isCustom } ) { const customDataViews = useCustomDataViews( type ); return ( - - { customDataViews.map( ( customViewRecord ) => { - return ( - - ); - } ) } - - + <> +
+ { __( 'Custom Views' ) } +
+ + { customDataViews.map( ( customViewRecord ) => { + return ( + + ); + } ) } + + + ); } diff --git a/packages/edit-site/src/components/sidebar-dataviews/dataview-item.js b/packages/edit-site/src/components/sidebar-dataviews/dataview-item.js index 45e3a9d50f3f6..1d24ff8d5ef3b 100644 --- a/packages/edit-site/src/components/sidebar-dataviews/dataview-item.js +++ b/packages/edit-site/src/components/sidebar-dataviews/dataview-item.js @@ -1,7 +1,7 @@ /** * WordPress dependencies */ -import { page, columns } from '@wordpress/icons'; +import { page, columns, pullRight } from '@wordpress/icons'; import { privateApis as routerPrivateApis } from '@wordpress/router'; /** @@ -13,7 +13,7 @@ import { unlock } from '../../lock-unlock'; const { useLocation } = unlock( routerPrivateApis ); function getDataViewIcon( type ) { - const icons = { list: page, grid: columns }; + const icons = { list: page, grid: columns, 'side-by-side': pullRight }; return icons[ type ]; } diff --git a/packages/edit-site/src/components/sidebar-dataviews/default-views.js b/packages/edit-site/src/components/sidebar-dataviews/default-views.js index 3a2f5991bdd01..a1f5375635d37 100644 --- a/packages/edit-site/src/components/sidebar-dataviews/default-views.js +++ b/packages/edit-site/src/components/sidebar-dataviews/default-views.js @@ -9,7 +9,7 @@ const DEFAULT_PAGE_BASE = { search: '', filters: [], page: 1, - perPage: 5, + perPage: 20, sort: { field: 'date', direction: 'desc', diff --git a/packages/edit-site/src/components/sidebar-dataviews/style.scss b/packages/edit-site/src/components/sidebar-dataviews/style.scss new file mode 100644 index 0000000000000..3e8812267b076 --- /dev/null +++ b/packages/edit-site/src/components/sidebar-dataviews/style.scss @@ -0,0 +1,8 @@ +.edit-site-sidebar-navigation-screen-dataviews__group-header { + margin-top: $grid-unit-40; + h2 { + font-size: 11px; + font-weight: 500; + text-transform: uppercase; + } +} diff --git a/packages/edit-site/src/components/sidebar-edit-mode/page-panels/page-slug.js b/packages/edit-site/src/components/sidebar-edit-mode/page-panels/page-slug.js new file mode 100644 index 0000000000000..d6ffa1991333e --- /dev/null +++ b/packages/edit-site/src/components/sidebar-edit-mode/page-panels/page-slug.js @@ -0,0 +1,161 @@ +/** + * WordPress dependencies + */ +import { useSelect, useDispatch } from '@wordpress/data'; +import { + safeDecodeURIComponent, + filterURLForDisplay, + cleanForSlug, +} from '@wordpress/url'; +import { useState, useMemo } from '@wordpress/element'; +import { __experimentalInspectorPopoverHeader as InspectorPopoverHeader } from '@wordpress/block-editor'; +import { __ } from '@wordpress/i18n'; +import { + __experimentalInputControl as InputControl, + __experimentalHStack as HStack, + __experimentalVStack as VStack, + __experimentalText as Text, + Dropdown, + Button, +} from '@wordpress/components'; +import { store as coreStore } from '@wordpress/core-data'; + +export const PERMALINK_POSTNAME_REGEX = /%(?:postname|pagename)%/; + +function getPostPermalink( record, isEditable ) { + if ( ! record?.permalink_template ) { + return; + } + const slug = record?.slug || record?.generated_slug; + const [ prefix, suffix ] = record.permalink_template.split( + PERMALINK_POSTNAME_REGEX + ); + const permalink = isEditable ? prefix + slug + suffix : record.link; + return filterURLForDisplay( safeDecodeURIComponent( permalink ) ); +} + +export default function PageSlug( { postType, postId } ) { + const { editEntityRecord } = useDispatch( coreStore ); + const { record, savedSlug } = useSelect( + ( select ) => { + const { getEntityRecord, getEditedEntityRecord } = + select( coreStore ); + const savedRecord = getEntityRecord( 'postType', postType, postId ); + return { + record: getEditedEntityRecord( 'postType', postType, postId ), + savedSlug: savedRecord?.slug || savedRecord?.generated_slug, + }; + }, + [ postType, postId ] + ); + const [ popoverAnchor, setPopoverAnchor ] = useState( null ); + const [ forceEmptyField, setForceEmptyField ] = useState( false ); + const isEditable = + PERMALINK_POSTNAME_REGEX.test( record?.permalink_template ) && + record?._links?.[ 'wp:action-publish' ]; + const popoverProps = useMemo( + () => ( { + // Anchor the popover to the middle of the entire row so that it doesn't + // move around when the label changes. + anchor: popoverAnchor, + 'aria-label': __( 'Change slug' ), + placement: 'bottom-end', + } ), + [ popoverAnchor ] + ); + if ( ! record || ! isEditable ) { + return null; + } + const recordSlug = safeDecodeURIComponent( + record?.slug || record?.generated_slug + ); + const permaLink = getPostPermalink( record, isEditable ); + const onSlugChange = ( newValue ) => { + editEntityRecord( 'postType', postType, postId, { + slug: newValue, + } ); + }; + return ( + + + { __( 'URL' ) } + + { + if ( forceEmptyField ) { + onSlugChange( cleanForSlug( savedSlug ) ); + setForceEmptyField( false ); + } + } } + renderToggle={ ( { onToggle } ) => ( + + ) } + renderContent={ ( { onClose } ) => { + return ( + <> + + +
+ { + onSlugChange( newValue ); + // When we delete the field the permalink gets + // reverted to the original value. + // The forceEmptyField logic allows the user to have + // the field temporarily empty while typing. + if ( ! newValue ) { + if ( ! forceEmptyField ) { + setForceEmptyField( true ); + } + return; + } + if ( forceEmptyField ) { + setForceEmptyField( false ); + } + } } + onBlur={ ( event ) => { + onSlugChange( + cleanForSlug( + event.target.value || + savedSlug + ) + ); + if ( forceEmptyField ) { + setForceEmptyField( false ); + } + } } + /> + +
+ + ); + } } + /> +
+ ); +} diff --git a/packages/edit-site/src/components/sidebar-edit-mode/page-panels/page-summary.js b/packages/edit-site/src/components/sidebar-edit-mode/page-panels/page-summary.js index c4dafeab6cb37..25b69985bcbd6 100644 --- a/packages/edit-site/src/components/sidebar-edit-mode/page-panels/page-summary.js +++ b/packages/edit-site/src/components/sidebar-edit-mode/page-panels/page-summary.js @@ -8,6 +8,7 @@ import { __experimentalVStack as VStack } from '@wordpress/components'; import PageStatus from './page-status'; import PublishDate from './publish-date'; import EditTemplate from './edit-template'; +import PageSlug from './page-slug'; export default function PageSummary( { status, @@ -32,6 +33,7 @@ export default function PageSummary( { postType={ postType } /> + ); } diff --git a/packages/edit-site/src/components/sidebar-edit-mode/page-panels/style.scss b/packages/edit-site/src/components/sidebar-edit-mode/page-panels/style.scss index 5501fe49e5876..e1a8e4acb7227 100644 --- a/packages/edit-site/src/components/sidebar-edit-mode/page-panels/style.scss +++ b/packages/edit-site/src/components/sidebar-edit-mode/page-panels/style.scss @@ -85,3 +85,10 @@ min-width: 240px; } } + +.edit-site-page-panels-edit-slug__dropdown { + .components-popover__content { + min-width: 320px; + padding: $grid-unit-20; + } +} diff --git a/packages/edit-site/src/components/start-template-options/index.js b/packages/edit-site/src/components/start-template-options/index.js index fe4179a338504..771f380db05a3 100644 --- a/packages/edit-site/src/components/start-template-options/index.js +++ b/packages/edit-site/src/components/start-template-options/index.js @@ -3,7 +3,7 @@ */ import { Modal, Flex, FlexItem, Button } from '@wordpress/components'; import { __ } from '@wordpress/i18n'; -import { useState, useEffect, useMemo } from '@wordpress/element'; +import { useState, useMemo } from '@wordpress/element'; import { __experimentalBlockPatternsList as BlockPatternsList, store as blockEditorStore, @@ -13,8 +13,6 @@ import { useAsyncList } from '@wordpress/compose'; import { store as preferencesStore } from '@wordpress/preferences'; import { parse } from '@wordpress/blocks'; import { store as coreStore, useEntityBlockEditor } from '@wordpress/core-data'; -import apiFetch from '@wordpress/api-fetch'; -import { addQueryArgs } from '@wordpress/url'; /** * Internal dependencies @@ -23,18 +21,22 @@ import { store as editSiteStore } from '../../store'; import { TEMPLATE_POST_TYPE } from '../../utils/constants'; function useFallbackTemplateContent( slug, isCustom = false ) { - const [ templateContent, setTemplateContent ] = useState( '' ); - - useEffect( () => { - apiFetch( { - path: addQueryArgs( '/wp/v2/templates/lookup', { + return useSelect( + ( select ) => { + const { getEntityRecord, getDefaultTemplateId } = + select( coreStore ); + const templateId = getDefaultTemplateId( { slug, is_custom: isCustom, ignore_empty: true, - } ), - } ).then( ( { content } ) => setTemplateContent( content.raw ) ); - }, [ isCustom, slug ] ); - return templateContent; + } ); + return templateId + ? getEntityRecord( 'postType', TEMPLATE_POST_TYPE, templateId ) + ?.content?.raw + : undefined; + }, + [ slug, isCustom ] + ); } function useStartPatterns( fallbackContent ) { diff --git a/packages/edit-site/src/components/template-part-converter/convert-to-regular.js b/packages/edit-site/src/components/template-part-converter/convert-to-regular.js index b1534ad88a999..4ca21f42fa944 100644 --- a/packages/edit-site/src/components/template-part-converter/convert-to-regular.js +++ b/packages/edit-site/src/components/template-part-converter/convert-to-regular.js @@ -26,7 +26,7 @@ export default function ConvertToRegularBlocks( { clientId, onClose } ) { onClose(); } } > - { __( 'Detach blocks from template part' ) } + { __( 'Detach' ) } ); } diff --git a/packages/edit-site/src/store/actions.js b/packages/edit-site/src/store/actions.js index ce698a757f6bb..30ee9e6aab01a 100644 --- a/packages/edit-site/src/store/actions.js +++ b/packages/edit-site/src/store/actions.js @@ -266,12 +266,22 @@ export const setPage = ( page ) => async ( { dispatch, registry } ) => { let template; - const getDefaultTemplate = async ( slug ) => - apiFetch( { - path: addQueryArgs( '/wp/v2/templates/lookup', { + const getDefaultTemplate = async ( slug ) => { + const templateId = await registry + .resolveSelect( coreStore ) + .getDefaultTemplateId( { slug: `page-${ slug }`, - } ), - } ); + } ); + return templateId + ? await registry + .resolveSelect( coreStore ) + .getEntityRecord( + 'postType', + TEMPLATE_POST_TYPE, + templateId + ) + : undefined; + }; if ( page.path ) { template = await registry diff --git a/packages/edit-site/src/style.scss b/packages/edit-site/src/style.scss index dadbf48d06e64..0b49f48a3e584 100644 --- a/packages/edit-site/src/style.scss +++ b/packages/edit-site/src/style.scss @@ -39,6 +39,7 @@ @import "./components/sidebar-navigation-screen-pattern/style.scss"; @import "./components/sidebar-navigation-screen-patterns/style.scss"; @import "./components/sidebar-navigation-screen-template/style.scss"; +@import "./components/sidebar-dataviews/style.scss"; @import "./components/site-hub/style.scss"; @import "./components/sidebar-navigation-screen-navigation-menus/style.scss"; @import "./components/site-icon/style.scss"; diff --git a/packages/primitives/src/svg/index.native.js b/packages/primitives/src/svg/index.native.js index c8c735283c05a..719f4d233cc3a 100644 --- a/packages/primitives/src/svg/index.native.js +++ b/packages/primitives/src/svg/index.native.js @@ -26,6 +26,7 @@ export { LinearGradient, Stop, Line, + SvgXml, } from 'react-native-svg'; const AnimatedSvg = Animated.createAnimatedComponent( diff --git a/packages/react-native-editor/__device-tests__/README.md b/packages/react-native-editor/__device-tests__/README.md index f4a4fcf89aed6..e917a297a491c 100644 --- a/packages/react-native-editor/__device-tests__/README.md +++ b/packages/react-native-editor/__device-tests__/README.md @@ -4,28 +4,9 @@ The Mobile Gutenberg (MG) project maintains a suite of automated end-to-end (E2E ## Setup -Before setting up the E2E test environment, the required iOS and Android dependencies must be installed. - -> **Note** -> The required dependencies change overtime. We do our best to update the scripts documented below, but it is best to review the [Appium capabilities](https://github.com/WordPress/gutenberg/blob/trunk/packages/react-native-editor/__device-tests__/helpers/caps.js) configuration to identify the currently required `deviceName` and `platformVersion` for each of the iOS and Android platforms. - -### iOS - -- Complete the [React Native Getting Started](https://reactnative.dev/docs/environment-setup) guide, which covers installing and setting up Xcode. -- Open [Xcode settings](https://developer.apple.com/documentation/xcode/installing-additional-simulator-runtimes#Install-and-manage-Simulator-runtimes-in-settings) to install the iOS 16.2 simulator runtime. - -### Android - -- Complete the [React Native Getting Started](https://reactnative.dev/docs/environment-setup) guide, which covers installing and setting up Android Studio and the Android SDK. -- Open Android Studio and [create an emulator](https://developer.android.com/studio/run/managing-avds) for a Pixel 3 XL running Android 11.0 with the “Enable Device Frame” option disabled. - -### Test Environment - -After installing the iOS and Android dependencies, the MG project provides a script to set up the testing environment, verifying necessary dependencies are available. - -```shell -npm run native test:e2e:setup -``` +1. Complete the [React Native Getting Started](https://reactnative.dev/docs/environment-setup) guide for both iOS and Android, which covers setting up Xcode, Android Studio, the Android SDK. +1. Open [Xcode settings](https://developer.apple.com/documentation/xcode/installing-additional-simulator-runtimes#Install-and-manage-Simulator-runtimes-in-settings) to install the iOS 16.2 simulator runtime. +1. `npm run native test:e2e:setup` ## Running Tests diff --git a/packages/react-native-editor/bin/test-e2e-setup.sh b/packages/react-native-editor/bin/test-e2e-setup.sh index 083748391bf73..ed896a03eacb8 100755 --- a/packages/react-native-editor/bin/test-e2e-setup.sh +++ b/packages/react-native-editor/bin/test-e2e-setup.sh @@ -1,4 +1,4 @@ -#!/bin/bash -eu +#!/bin/bash -e set -o pipefail @@ -24,14 +24,14 @@ function log_error() { output=$($APPIUM_CMD driver list --installed --json) if echo "$output" | grep -q 'uiautomator2'; then - log_info "UiAutomator2 is installed, skipping installation." + log_info "UiAutomator2 available." else log_info "UiAutomator2 not found, installing..." $APPIUM_CMD driver install uiautomator2 fi if echo "$output" | grep -q 'xcuitest'; then - log_info "XCUITest is installed, skipping installation." + log_info "XCUITest available." else log_info "XCUITest not found, installing..." $APPIUM_CMD driver install xcuitest @@ -54,7 +54,7 @@ function detect_or_create_simulator() { local simulators=$(xcrun simctl list devices -j | jq -r --arg runtime "$runtime_name" '.devices | to_entries[] | select(.key | contains($runtime)) | .value[] | .name + "," + .udid') if ! echo "$simulators" | grep -q "$simulator_name"; then - log_info "$simulator_name ($runtime_name_display) not available, creating..." + log_info "$simulator_name ($runtime_name_display) not found, creating..." xcrun simctl create "$simulator_name" "$simulator_name" "com.apple.CoreSimulator.SimRuntime.$runtime_name" > /dev/null log_success "$simulator_name ($runtime_name_display) created." else @@ -69,6 +69,37 @@ IOS_DEVICE_TABLET_NAME=$(jq -r '.ios.local.deviceTabletName' "$CONFIG_FILE") detect_or_create_simulator "$IOS_DEVICE_NAME" detect_or_create_simulator "$IOS_DEVICE_TABLET_NAME" +function detect_or_create_emulator() { + if [[ "${CI}" ]]; then + log_info "Detected CI server, skipping Android emulator creation." + return + fi + + if [[ -z $(command -v avdmanager) ]]; then + log_error "avdmanager not found! Please install the Android SDK command-line tools.\n https://developer.android.com/tools/" + exit 1; + fi + + local emulator_name=$1 + local emulator_id=$(echo "$emulator_name" | sed 's/ /_/g; s/\./_/g') + local device_id=$(echo "$emulator_id" | awk -F '_' '{print tolower($1)"_"tolower($2)"_"tolower($3)}') + local runtime_api=$(echo "$emulator_id" | awk -F '_' '{print $NF}') + local emulator=$(emulator -list-avds | grep "$emulator_id") + + if [[ -z $emulator ]]; then + log_info "$emulator_name not found, creating..." + avdmanager create avd -n "$emulator_id" -k "system-images;android-$runtime_api;google_apis;arm64-v8a" -d "$device_id" > /dev/null + log_success "$emulator_name created." + else + log_info "$emulator_name available." + fi +} + +ANDROID_DEVICE_NAME=$(jq -r '.android.local.deviceName' "$CONFIG_FILE") + +# Create the required Android emulators, if they don't exist +detect_or_create_emulator $ANDROID_DEVICE_NAME + # Mitigate conflicts between development server caches and E2E tests npm run clean:runtime > /dev/null -log_info 'Runtime cache cleaned.' +log_info 'Runtime cache cleared.' diff --git a/phpunit/blocks/render-query-test.php b/phpunit/blocks/render-query-test.php index 8a148e89be87b..2d81bfdb513b3 100644 --- a/phpunit/blocks/render-query-test.php +++ b/phpunit/blocks/render-query-test.php @@ -131,6 +131,49 @@ public function test_rendering_query_with_enhanced_pagination_auto_disabled_when $this->assertSame( 'true', $p->get_attribute( 'data-wp-navigation-disabled' ) ); } + + /** + * Tests that the `core/query` last tag is rendered with the tagName attribute + * if is defined, having a div as default. + */ + public function test_enhanced_query_markup_rendering_at_bottom_on_custom_html_element_tags() { + global $wp_query, $wp_the_query; + + $content = << + + + +HTML; + + // Set main query to single post. + $wp_query = new WP_Query( + array( + 'posts_per_page' => 1, + ) + ); + + $wp_the_query = $wp_query; + + $output = do_blocks( $content ); + + $p = new WP_HTML_Tag_Processor( $output ); + + $p->next_tag( 'span' ); + + // Test that there is a div added just after the last tag inside the aside. + $this->assertSame( $p->next_tag(), true ); + // Test that that div is the accesibility one. + $this->assertSame( 'screen-reader-text', $p->get_attribute( 'class' ) ); + $this->assertSame( 'context.core.query.message', $p->get_attribute( 'data-wp-text' ) ); + $this->assertSame( 'polite', $p->get_attribute( 'aria-live' ) ); + } + /** * Tests that the `core/query` block adds an extra attribute to disable the * enhanced pagination in the browser when a post content block is found inside. diff --git a/phpunit/experimental/interactivity-api/directive-processing-test.php b/phpunit/experimental/interactivity-api/directive-processing-test.php index 44838b4ce68d3..97dddba3c263f 100644 --- a/phpunit/experimental/interactivity-api/directive-processing-test.php +++ b/phpunit/experimental/interactivity-api/directive-processing-test.php @@ -28,10 +28,10 @@ function gutenberg_test_process_directives_helper_increment( $store ) { } /** - * Tests for the gutenberg_interactivity_process_directives function. + * Tests for the gutenberg_interactivity_process_rendered_html function. * * @group interactivity-api - * @covers gutenberg_interactivity_process_directives + * @covers gutenberg_interactivity_process_rendered_html */ class Tests_Process_Directives extends WP_UnitTestCase { public function test_correctly_call_attribute_directive_processor_on_closing_tag() { @@ -40,19 +40,19 @@ public function test_correctly_call_attribute_directive_processor_on_closing_tag $test_helper = $this->createMock( Helper_Class::class ); $test_helper->expects( $this->exactly( 2 ) ) - ->method( 'process_foo_test' ) - ->with( - $this->callback( - function ( $p ) { - return 'DIV' === $p->get_tag() && ( - // Either this is a closing tag... - $p->is_tag_closer() || - // ...or it is an open tag, and has the directive attribute set. - ( ! $p->is_tag_closer() && 'abc' === $p->get_attribute( 'foo-test' ) ) - ); - } - ) - ); + ->method( 'process_foo_test' ) + ->with( + $this->callback( + function ( $p ) { + return 'DIV' === $p->get_tag() && ( + // Either this is a closing tag... + $p->is_tag_closer() || + // ...or it is an open tag, and has the directive attribute set. + ( ! $p->is_tag_closer() && 'abc' === $p->get_attribute( 'foo-test' ) ) + ); + } + ) + ); $directives = array( 'foo-test' => array( $test_helper, 'process_foo_test' ), @@ -60,13 +60,13 @@ function ( $p ) { $markup = '
Example:
This is a test>
Here is a nested div
'; $tags = new WP_HTML_Tag_Processor( $markup ); - gutenberg_interactivity_process_directives( $tags, 'foo-', $directives ); + gutenberg_interactivity_process_rendered_html( $tags, 'foo-', $directives ); } public function test_directives_with_double_hyphen_processed_correctly() { $test_helper = $this->createMock( Helper_Class::class ); $test_helper->expects( $this->atLeastOnce() ) - ->method( 'process_foo_test' ); + ->method( 'process_foo_test' ); $directives = array( 'foo-test' => array( $test_helper, 'process_foo_test' ), @@ -74,10 +74,76 @@ public function test_directives_with_double_hyphen_processed_correctly() { $markup = '
'; $tags = new WP_HTML_Tag_Processor( $markup ); - gutenberg_interactivity_process_directives( $tags, 'foo-', $directives ); + gutenberg_interactivity_process_rendered_html( $tags, 'foo-', $directives ); + } + + public function test_interactivity_process_directives_in_root_blocks() { + $pattern_content = + '' . + '

Pattern Content Block 1

' . + '' . + '' . + '

Pattern Content Block 2

' . + ''; + register_block_pattern( + 'core/interactivity-pattern', + array( + 'title' => 'Interactivity Pattern', + 'content' => $pattern_content, + ) + ); + + $providers = $this->data_only_root_blocks_are_processed(); + foreach ( $providers as $provider ) { + do_blocks( $provider['page_content'] ); + $this->assertSame( $provider['root_blocks'], count( WP_Directive_Processor::$root_blocks ) ); + + } + } + + /** + * Data provider . + * + * @return array + **/ + public function data_only_root_blocks_are_processed() { + + return array( + array( + 'root_blocks' => 2, + 'page_content' => + '' . + '
+ ' . + '

The XYZ Doohickey Company was founded in 1971, and has been providing' . + 'quality doohickeys to the public ever since. Located in Gotham City, XYZ employs' . + 'over 2,000 people and does all kinds of awesome things for the Gotham community.

' . + ' +
' . + '' . + '' . + '
+ ' . + '

The XYZ Doohickey Company was founded in 1971, and has been providing' . + 'quality doohickeys to the public ever since. Located in Gotham City, XYZ employs' . + 'over 2,000 people and does all kinds of awesome things for the Gotham community.

' . + ' +
' . + '', + ), + array( + 'root_blocks' => 2, + 'page_content' => + '' . + '

Welcome to WordPress. This is your first post. Edit or delete it, then start writing!

' . + '' . + '', + ), + ); } } + /** * Tests for the gutenberg_interactivity_evaluate_reference function. * diff --git a/test/e2e/specs/editor/plugins/child-blocks.spec.js b/test/e2e/specs/editor/plugins/child-blocks.spec.js new file mode 100644 index 0000000000000..b3073b70a5409 --- /dev/null +++ b/test/e2e/specs/editor/plugins/child-blocks.spec.js @@ -0,0 +1,97 @@ +/** + * WordPress dependencies + */ +const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); + +test.describe( 'Child Blocks', () => { + test.beforeAll( async ( { requestUtils } ) => { + await requestUtils.activatePlugin( 'gutenberg-test-child-blocks' ); + } ); + + test.beforeEach( async ( { admin } ) => { + await admin.createNewPost(); + } ); + + test.afterAll( async ( { requestUtils } ) => { + await requestUtils.deactivatePlugin( 'gutenberg-test-child-blocks' ); + } ); + + test( 'are hidden from the global block inserter', async ( { page } ) => { + const blockInserter = page + .getByRole( 'toolbar', { name: 'Document tools' } ) + .getByRole( 'button', { name: 'Toggle block inserter' } ); + const blockLibrary = page.getByRole( 'region', { + name: 'Block Library', + } ); + + await blockInserter.click(); + await expect( blockLibrary ).toBeVisible(); + expect( blockLibrary.getByRole( 'option' ) ).not.toContain( [ + 'Child Blocks Child', + ] ); + } ); + + test( 'shows up in a parent block', async ( { page, editor } ) => { + await editor.insertBlock( { + name: 'test/child-blocks-unrestricted-parent', + } ); + + await page + .getByRole( 'document', { + name: 'Block: Child Blocks Unrestricted Parent', + } ) + .getByRole( 'button', { + name: 'Add default block', + } ) + .click(); + + const blockInserter = page + .getByRole( 'toolbar', { name: 'Document tools' } ) + .getByRole( 'button', { name: 'Toggle block inserter' } ); + const blockLibrary = page.getByRole( 'region', { + name: 'Block Library', + } ); + + await blockInserter.click(); + await expect( blockLibrary ).toBeVisible(); + await expect( blockLibrary.getByRole( 'option' ) ).toContainText( [ + 'Child Blocks Child', + ] ); + expect( + await blockLibrary.getByRole( 'option' ).count() + ).toBeGreaterThan( 10 ); + } ); + + test( 'display in a parent block with allowedItems', async ( { + page, + editor, + } ) => { + await editor.insertBlock( { + name: 'test/child-blocks-restricted-parent', + } ); + + await page + .getByRole( 'document', { + name: 'Block: Child Blocks Restricted Parent', + } ) + .getByRole( 'button', { + name: 'Add default block', + } ) + .click(); + + const blockInserter = page + .getByRole( 'toolbar', { name: 'Document tools' } ) + .getByRole( 'button', { name: 'Toggle block inserter' } ); + const blockLibrary = page.getByRole( 'region', { + name: 'Block Library', + } ); + + await blockInserter.click(); + await expect( blockLibrary ).toBeVisible(); + await expect( blockLibrary.getByRole( 'option' ) ).toHaveText( [ + 'Paragraph', + 'Child Blocks Child', + 'Image', + ] ); + } ); +} ); diff --git a/test/e2e/specs/editor/plugins/meta-boxes.spec.js b/test/e2e/specs/editor/plugins/meta-boxes.spec.js new file mode 100644 index 0000000000000..b901201ff6c1d --- /dev/null +++ b/test/e2e/specs/editor/plugins/meta-boxes.spec.js @@ -0,0 +1,123 @@ +/** + * WordPress dependencies + */ +const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); + +test.describe( 'Meta boxes', () => { + test.beforeAll( async ( { requestUtils } ) => { + await requestUtils.activatePlugin( 'gutenberg-test-plugin-meta-box' ); + await requestUtils.deleteAllPosts(); + } ); + + test.beforeEach( async ( { admin } ) => { + await admin.createNewPost(); + } ); + + test.afterAll( async ( { requestUtils } ) => { + await requestUtils.deactivatePlugin( 'gutenberg-test-plugin-meta-box' ); + } ); + + test( 'Should save the post', async ( { editor, page } ) => { + const saveDraft = page + .getByRole( 'region', { name: 'Editor top bar' } ) + .getByRole( 'button', { name: 'Save draft' } ); + + // Save should not be an option for new empty post. + await expect( saveDraft ).toBeDisabled(); + + // Add title to enable valid non-empty post save. + await page + .getByRole( 'textbox', { name: 'Add title' } ) + .fill( 'Hello Meta' ); + + await expect( saveDraft ).toBeEnabled(); + + await editor.saveDraft(); + + // After saving, affirm that the button returns to Save Draft. + await expect( saveDraft ).toBeEnabled(); + } ); + + test( 'Should render dynamic blocks when the meta box uses the excerpt for front end rendering', async ( { + admin, + editor, + page, + } ) => { + // Publish a post so there's something for the latest posts dynamic block to render. + await page + .getByRole( 'textbox', { name: 'Add title' } ) + .fill( 'A published post' ); + await page.keyboard.press( 'Enter' ); + await page.keyboard.type( 'Hello there!' ); + await editor.publishPost(); + + // Publish a post with the latest posts dynamic block. + await admin.createNewPost(); + await page + .getByRole( 'textbox', { name: 'Add title' } ) + .fill( 'Dynamic block test' ); + await editor.insertBlock( { name: 'core/latest-posts' } ); + + const postId = await editor.publishPost(); + await page.goto( `/?p=${ postId }` ); + + await expect( + page.locator( '.wp-block-latest-posts > li' ) + ).toContainText( [ 'A published post', 'Dynamic block test' ] ); + } ); + + test( 'Should render the excerpt in meta based on post content if no explicit excerpt exists', async ( { + editor, + page, + } ) => { + await page + .getByRole( 'textbox', { name: 'Add title' } ) + .fill( 'A published post' ); + await page.getByRole( 'button', { name: 'Add default block' } ).click(); + await page.keyboard.type( 'Excerpt from content.' ); + + const postId = await editor.publishPost(); + await page.goto( `/?p=${ postId }` ); + + await expect( + page.locator( 'meta[property="gutenberg:hello"]' ) + ).toHaveAttribute( 'content', 'Excerpt from content.' ); + } ); + + test( 'Should render the explicitly set excerpt in meta instead of the content based one', async ( { + editor, + page, + } ) => { + await editor.openDocumentSettingsSidebar(); + await page.getByRole( 'button', { name: 'Add default block' } ).click(); + await page.keyboard.type( 'Excerpt from content.' ); + await page + .getByRole( 'textbox', { name: 'Add title' } ) + .fill( 'A published post' ); + + const documentSettings = page.getByRole( 'region', { + name: 'Editor settings', + } ); + const excerptButton = documentSettings.getByRole( 'button', { + name: 'Excerpt', + } ); + + // eslint-disable-next-line playwright/no-conditional-in-test + if ( + ( await excerptButton.getAttribute( 'aria-expanded' ) ) === 'false' + ) { + await excerptButton.click(); + } + + await documentSettings + .getByRole( 'textbox', { name: 'Write an Excerpt' } ) + .fill( 'Explicitly set excerpt.' ); + + const postId = await editor.publishPost(); + await page.goto( `/?p=${ postId }` ); + + await expect( + page.locator( 'meta[property="gutenberg:hello"]' ) + ).toHaveAttribute( 'content', 'Explicitly set excerpt.' ); + } ); +} ); diff --git a/test/e2e/specs/site-editor/template-part.spec.js b/test/e2e/specs/site-editor/template-part.spec.js index d1c215ec2a494..ff4610a57ba27 100644 --- a/test/e2e/specs/site-editor/template-part.spec.js +++ b/test/e2e/specs/site-editor/template-part.spec.js @@ -211,9 +211,7 @@ test.describe( 'Template Part', () => { // Detach the paragraph from the header template part. await editor.selectBlocks( templatePartWithParagraph ); - await editor.clickBlockOptionsMenuItem( - 'Detach blocks from template part' - ); + await editor.clickBlockOptionsMenuItem( 'Detach' ); // There should be a paragraph but no header template part. await expect( paragraph ).toBeVisible(); diff --git a/test/native/setup.js b/test/native/setup.js index 00fb95070d84d..53ab28f861a1e 100644 --- a/test/native/setup.js +++ b/test/native/setup.js @@ -151,6 +151,7 @@ jest.mock( 'react-native-svg', () => { G: () => 'G', Polygon: () => 'Polygon', Rect: () => 'Rect', + SvgXml: jest.fn(), }; } ); diff --git a/test/performance/specs/site-editor.spec.js b/test/performance/specs/site-editor.spec.js index bd1a5f0b87cc0..38bcceb14edd6 100644 --- a/test/performance/specs/site-editor.spec.js +++ b/test/performance/specs/site-editor.spec.js @@ -58,19 +58,13 @@ test.describe( 'Site Editor Performance', () => { } ); test.describe( 'Loading', () => { - let draftURL = null; + let draftId = null; - test( 'Setup the test page', async ( { page, admin, perfUtils } ) => { + test( 'Setup the test page', async ( { admin, perfUtils } ) => { await admin.createNewPost( { postType: 'page' } ); await perfUtils.loadBlocksForLargePost(); - await perfUtils.saveDraft(); - await admin.visitSiteEditor( { - postId: new URL( page.url() ).searchParams.get( 'post' ), - postType: 'page', - } ); - - draftURL = page.url(); + draftId = await perfUtils.saveDraft(); } ); const samples = 10; @@ -78,15 +72,18 @@ test.describe( 'Site Editor Performance', () => { const iterations = samples + throwaway; for ( let i = 1; i <= iterations; i++ ) { test( `Run the test (${ i } of ${ iterations })`, async ( { - page, + admin, perfUtils, metrics, } ) => { // Go to the test draft. - await page.goto( draftURL ); - const canvas = await perfUtils.getCanvas(); + await admin.visitSiteEditor( { + postId: draftId, + postType: 'page', + } ); // Wait for the first block. + const canvas = await perfUtils.getCanvas(); await canvas.locator( '.wp-block' ).first().waitFor(); // Get the durations. @@ -109,44 +106,25 @@ test.describe( 'Site Editor Performance', () => { } ); test.describe( 'Typing', () => { - let draftURL = null; - - test( 'Setup the test post', async ( { - page, - admin, - editor, - perfUtils, - } ) => { + let draftId = null; + + test( 'Setup the test post', async ( { admin, editor, perfUtils } ) => { await admin.createNewPost( { postType: 'page' } ); await perfUtils.loadBlocksForLargePost(); await editor.insertBlock( { name: 'core/paragraph' } ); - await perfUtils.saveDraft(); + draftId = await perfUtils.saveDraft(); + } ); + + test( 'Run the test', async ( { admin, perfUtils, metrics } ) => { + // Go to the test draft. await admin.visitSiteEditor( { - postId: new URL( page.url() ).searchParams.get( 'post' ), + postId: draftId, postType: 'page', } ); - draftURL = page.url(); - } ); - test( 'Run the test', async ( { page, perfUtils, metrics } ) => { - await page.goto( draftURL ); - await perfUtils.disableAutosave(); - - // Wait for the loader overlay to disappear. This is necessary - // because the overlay is still visible for a while after the editor - // canvas is ready, and we don't want it to affect the typing - // timings. - await page - .locator( - // Spinner was used instead of the progress bar in an earlier version of the site editor. - '.edit-site-canvas-loader, .edit-site-canvas-spinner' - ) - .waitFor( { state: 'hidden' } ); - - const canvas = await perfUtils.getCanvas(); - // Enter edit mode (second click is needed for the legacy edit mode). + const canvas = await perfUtils.getCanvas(); await canvas.locator( 'body' ).click(); await canvas .getByRole( 'document', { name: /Block:( Post)? Content/ } ) @@ -210,19 +188,6 @@ test.describe( 'Site Editor Performance', () => { path: '/wp_template', } ); - // Wait for the loader overlay to disappear. This is necessary - // because the overlay is still visible for a while after the editor - // canvas is ready, and we don't want it to affect the typing - // timings. - await page - .locator( - // Spinner was used instead of the progress bar in an earlier version of the site editor. - '.edit-site-canvas-loader, .edit-site-canvas-spinner' - ) - .waitFor( { state: 'hidden' } ); - // Additional time to ensure the browser is completely idle. - // eslint-disable-next-line playwright/no-wait-for-timeout - // Start tracing. await metrics.startTracing();