From bd48f4146cf554cd6f2853dfc9205d19bfe2e80b Mon Sep 17 00:00:00 2001 From: margolisj <1588194+margolisj@users.noreply.github.com> Date: Sat, 7 Oct 2023 08:56:17 -0400 Subject: [PATCH 001/239] ConfirmDialog: ts unit test storybook (#54954) * Swaps file type. * Can't remember what point this served. * Attempting w/ functional component * Updates types wrt to PR notes, js -> TSX for storybook, fixes null check in tests. * Adds JSDocs to types and long form to component. * Adds changelog. * Skips null check in test * Exports and imports the correct one and edits the configs. * New changelog entry * Update packages/components/src/confirm-dialog/stories/index.story.tsx --------- Co-authored-by: Marco Ciampini --- packages/components/CHANGELOG.md | 4 + .../components/src/confirm-dialog/README.md | 2 +- .../src/confirm-dialog/component.tsx | 92 ++++++++++++++++--- .../{index.story.js => index.story.tsx} | 50 +++++----- .../test/{index.js => index.tsx} | 6 +- .../components/src/confirm-dialog/types.ts | 44 ++++++--- 6 files changed, 145 insertions(+), 53 deletions(-) rename packages/components/src/confirm-dialog/stories/{index.story.js => index.story.tsx} (75%) rename packages/components/src/confirm-dialog/test/{index.js => index.tsx} (98%) diff --git a/packages/components/CHANGELOG.md b/packages/components/CHANGELOG.md index 2df99e7413ffa6..6b3734dfdabcdf 100644 --- a/packages/components/CHANGELOG.md +++ b/packages/components/CHANGELOG.md @@ -6,6 +6,10 @@ - `Modal`: fix closing when contained iframe is focused ([#51602](https://github.com/WordPress/gutenberg/pull/51602)). +### Internal + +- `ConfirmDialog`: Migrate to TypeScript. ([#54954](https://github.com/WordPress/gutenberg/pull/54954)). + ## 25.9.0 (2023-10-05) ### Enhancements diff --git a/packages/components/src/confirm-dialog/README.md b/packages/components/src/confirm-dialog/README.md index 86d38bccdec3c6..4b0f37f5d35b39 100644 --- a/packages/components/src/confirm-dialog/README.md +++ b/packages/components/src/confirm-dialog/README.md @@ -137,4 +137,4 @@ The optional custom text to display as the confirmation button's label - Required: No - Default: "Cancel" -The optional custom text to display as the cancelation button's label +The optional custom text to display as the cancellation button's label diff --git a/packages/components/src/confirm-dialog/component.tsx b/packages/components/src/confirm-dialog/component.tsx index 4a8efd06e139c7..750e7030de13cd 100644 --- a/packages/components/src/confirm-dialog/component.tsx +++ b/packages/components/src/confirm-dialog/component.tsx @@ -1,8 +1,3 @@ -/** - * External dependencies - */ -import type { ForwardedRef, KeyboardEvent } from 'react'; - /** * WordPress dependencies */ @@ -13,7 +8,7 @@ import { useCallback, useEffect, useRef, useState } from '@wordpress/element'; * Internal dependencies */ import Modal from '../modal'; -import type { OwnProps, DialogInputEvent } from './types'; +import type { ConfirmDialogProps, DialogInputEvent } from './types'; import type { WordPressComponentProps } from '../context'; import { useContextSystem, contextConnect } from '../context'; import { Flex } from '../flex'; @@ -23,10 +18,10 @@ import { VStack } from '../v-stack'; import * as styles from './styles'; import { useCx } from '../utils/hooks/use-cx'; -function ConfirmDialog( - props: WordPressComponentProps< OwnProps, 'div', false >, - forwardedRef: ForwardedRef< any > -) { +const UnconnectedConfirmDialog = ( + props: WordPressComponentProps< ConfirmDialogProps, 'div', false >, + forwardedRef: React.ForwardedRef< any > +) => { const { isOpen: isOpenProp, onConfirm, @@ -67,7 +62,7 @@ function ConfirmDialog( ); const handleEnter = useCallback( - ( event: KeyboardEvent< HTMLDivElement > ) => { + ( event: React.KeyboardEvent< HTMLDivElement > ) => { // Avoid triggering the 'confirm' action when a button is focused, // as this can cause a double submission. const isConfirmOrCancelButton = @@ -120,6 +115,77 @@ function ConfirmDialog( ) } ); -} +}; -export default contextConnect( ConfirmDialog, 'ConfirmDialog' ); +/** + * `ConfirmDialog` is built of top of [`Modal`](/packages/components/src/modal/README.md) + * and displays a confirmation dialog, with _confirm_ and _cancel_ buttons. + * The dialog is confirmed by clicking the _confirm_ button or by pressing the `Enter` key. + * It is cancelled (closed) by clicking the _cancel_ button, by pressing the `ESC` key, or by + * clicking outside the dialog focus (i.e, the overlay). + * + * `ConfirmDialog` has two main implicit modes: controlled and uncontrolled. + * + * UnControlled: + * + * Allows the component to be used standalone, just by declaring it as part of another React's component render method: + * - It will be automatically open (displayed) upon mounting; + * - It will be automatically closed when clicking the _cancel_ button, by pressing the `ESC` key, or by clicking outside the dialog focus (i.e, the overlay); + * - `onCancel` is not mandatory but can be passed. Even if passed, the dialog will still be able to close itself. + * + * Activating this mode is as simple as omitting the `isOpen` prop. The only mandatory prop, in this case, is the `onConfirm` callback. The message is passed as the `children`. You can pass any JSX you'd like, which allows to further format the message or include sub-component if you'd like: + * + * ```jsx + * import { __experimentalConfirmDialog as ConfirmDialog } from '@wordpress/components'; + * + * function Example() { + * return ( + * console.debug( ' Confirmed! ' ) }> + * Are you sure? This action cannot be undone! + * + * ); + * } + * ``` + * + * + * Controlled mode: + * Let the parent component control when the dialog is open/closed. It's activated when a + * boolean value is passed to `isOpen`: + * - It will not be automatically closed. You need to let it know when to open/close by updating the value of the `isOpen` prop; + * - Both `onConfirm` and the `onCancel` callbacks are mandatory props in this mode; + * - You'll want to update the state that controls `isOpen` by updating it from the `onCancel` and `onConfirm` callbacks. + * + *```jsx + * import { __experimentalConfirmDialog as ConfirmDialog } from '@wordpress/components'; + * import { useState } from '@wordpress/element'; + * + * function Example() { + * const [ isOpen, setIsOpen ] = useState( true ); + * + * const handleConfirm = () => { + * console.debug( 'Confirmed!' ); + * setIsOpen( false ); + * }; + * + * const handleCancel = () => { + * console.debug( 'Cancelled!' ); + * setIsOpen( false ); + * }; + * + * return ( + * + * Are you sure? This action cannot be undone! + * + * ); + * } + * ``` + */ +export const ConfirmDialog = contextConnect( + UnconnectedConfirmDialog, + 'ConfirmDialog' +); +export default ConfirmDialog; diff --git a/packages/components/src/confirm-dialog/stories/index.story.js b/packages/components/src/confirm-dialog/stories/index.story.tsx similarity index 75% rename from packages/components/src/confirm-dialog/stories/index.story.js rename to packages/components/src/confirm-dialog/stories/index.story.tsx index ea561ff297c436..85636c0ddc81ed 100644 --- a/packages/components/src/confirm-dialog/stories/index.story.js +++ b/packages/components/src/confirm-dialog/stories/index.story.tsx @@ -1,3 +1,8 @@ +/** + * External dependencies + */ +import type { Meta, StoryFn } from '@storybook/react'; + /** * WordPress dependencies */ @@ -7,47 +12,41 @@ import { useState } from '@wordpress/element'; * Internal dependencies */ import Button from '../../button'; -import { ConfirmDialog } from '..'; +import { ConfirmDialog } from '../component'; -const meta = { +const meta: Meta< typeof ConfirmDialog > = { component: ConfirmDialog, title: 'Components (Experimental)/ConfirmDialog', argTypes: { - children: { - control: { type: 'text' }, - }, - confirmButtonText: { - control: { type: 'text' }, - }, - cancelButtonText: { - control: { type: 'text' }, - }, isOpen: { control: { type: null }, }, - onConfirm: { action: 'onConfirm' }, - onCancel: { action: 'onCancel' }, - }, - args: { - children: 'Would you like to privately publish the post now?', }, parameters: { + actions: { argTypesRegex: '^on.*' }, + controls: { + expanded: true, + }, docs: { canvas: { sourceState: 'shown' } }, }, }; export default meta; -const Template = ( { onConfirm, onCancel, ...args } ) => { +const Template: StoryFn< typeof ConfirmDialog > = ( { + onConfirm, + onCancel, + ...args +} ) => { const [ isOpen, setIsOpen ] = useState( false ); - const handleConfirm = ( ...confirmArgs ) => { - onConfirm( ...confirmArgs ); + const handleConfirm: typeof onConfirm = ( confirmArgs ) => { + onConfirm( confirmArgs ); setIsOpen( false ); }; - const handleCancel = ( ...cancelArgs ) => { - onCancel( ...cancelArgs ); + const handleCancel: typeof onCancel = ( cancelArgs ) => { + onCancel?.( cancelArgs ); setIsOpen( false ); }; @@ -70,7 +69,7 @@ const Template = ( { onConfirm, onCancel, ...args } ) => { }; // Simplest usage: just declare the component with the required `onConfirm` prop. Note: the `onCancel` prop is optional here, unless you'd like to render the component in Controlled mode (see below) -export const _default = Template.bind( {} ); +export const Default = Template.bind( {} ); const _defaultSnippet = `() => { const [ isOpen, setIsOpen ] = useState( false ); const [ confirmVal, setConfirmVal ] = useState(''); @@ -103,8 +102,10 @@ const _defaultSnippet = `() => { ); };`; -_default.args = {}; -_default.parameters = { +Default.args = { + children: 'Would you like to privately publish the post now?', +}; +Default.parameters = { docs: { source: { code: _defaultSnippet, @@ -117,6 +118,7 @@ _default.parameters = { // To customize button text, pass the `cancelButtonText` and/or `confirmButtonText` props. export const WithCustomButtonLabels = Template.bind( {} ); WithCustomButtonLabels.args = { + ...Default.args, cancelButtonText: 'No thanks', confirmButtonText: 'Yes please!', }; diff --git a/packages/components/src/confirm-dialog/test/index.js b/packages/components/src/confirm-dialog/test/index.tsx similarity index 98% rename from packages/components/src/confirm-dialog/test/index.js rename to packages/components/src/confirm-dialog/test/index.tsx index adf19b292898f8..27e1af66ce7429 100644 --- a/packages/components/src/confirm-dialog/test/index.js +++ b/packages/components/src/confirm-dialog/test/index.tsx @@ -113,7 +113,7 @@ describe( 'Confirm', () => { expect( onCancel ).toHaveBeenCalled(); } ); - it( 'should be dismissable even if an `onCancel` callback is not provided', async () => { + it( 'should be dismissible even if an `onCancel` callback is not provided', async () => { const user = userEvent.setup(); render( @@ -144,7 +144,7 @@ describe( 'Confirm', () => { // Disable reason: Semantic queries can’t reach the overlay. // eslint-disable-next-line testing-library/no-node-access - await user.click( confirmDialog.parentElement ); + await user.click( confirmDialog.parentElement! ); expect( confirmDialog ).not.toBeInTheDocument(); expect( onCancel ).toHaveBeenCalled(); @@ -325,7 +325,7 @@ describe( 'Confirm', () => { // Disable reason: Semantic queries can’t reach the overlay. // eslint-disable-next-line testing-library/no-node-access - await user.click( confirmDialog.parentElement ); + await user.click( confirmDialog.parentElement! ); expect( onCancel ).toHaveBeenCalled(); } ); diff --git a/packages/components/src/confirm-dialog/types.ts b/packages/components/src/confirm-dialog/types.ts index 72fef59dc20094..b456b9bc4df196 100644 --- a/packages/components/src/confirm-dialog/types.ts +++ b/packages/components/src/confirm-dialog/types.ts @@ -13,21 +13,41 @@ export type DialogInputEvent = | KeyboardEvent< HTMLDivElement > | MouseEvent< HTMLButtonElement >; -type BaseProps = { +export type ConfirmDialogProps = { + /** + * The actual message for the dialog. It's passed as children and any valid `ReactNode` is accepted. + */ children: ReactNode; + /** + * The callback that's called when the user confirms. + * A confirmation can happen when the `OK` button is clicked or when `Enter` is pressed. + */ onConfirm: ( event: DialogInputEvent ) => void; + /** + * The optional custom text to display as the confirmation button's label. + */ confirmButtonText?: string; + /** + * The optional custom text to display as the cancellation button's label. + */ cancelButtonText?: string; -}; - -type ControlledProps = BaseProps & { - onCancel: ( event: DialogInputEvent ) => void; - isOpen: boolean; -}; - -type UncontrolledProps = BaseProps & { + /** + * The callback that's called when the user cancels. A cancellation can happen + * when the `Cancel` button is clicked, when the `ESC` key is pressed, or when + * a click outside of the dialog focus is detected (i.e. in the overlay). + * + * It's not required if `isOpen` is not set (uncontrolled mode), as the component + * will take care of closing itself, but you can still pass a callback if something + * must be done upon cancelling (the component will still close itself in this case). + * + * If `isOpen` is set (controlled mode), then it's required, and you need to set + * the state that defines `isOpen` to `false` as part of this callback if you want the + * dialog to close when the user cancels. + */ onCancel?: ( event: DialogInputEvent ) => void; - isOpen?: never; + /** + * Defines if the dialog is open (displayed) or closed (not rendered/displayed). + * It also implicitly toggles the controlled mode if set or the uncontrolled mode if it's not set. + */ + isOpen?: boolean; }; - -export type OwnProps = ControlledProps | UncontrolledProps; From 6bd8578eab3cc8cdd074dc2c86d0c4b3901249f5 Mon Sep 17 00:00:00 2001 From: Glen Davies Date: Mon, 9 Oct 2023 13:35:35 +1300 Subject: [PATCH 002/239] Patterns: Add category selector to pattern creation modal (#55024) --------- Co-authored-by: Kai Hao --- .../src/components/category-selector.js | 70 ++++++++----------- .../src/components/create-pattern-modal.js | 51 ++++++++++++-- packages/patterns/src/components/style.scss | 30 +++++++- 3 files changed, 103 insertions(+), 48 deletions(-) diff --git a/packages/patterns/src/components/category-selector.js b/packages/patterns/src/components/category-selector.js index 397d851d3886b9..7f00350e278ecf 100644 --- a/packages/patterns/src/components/category-selector.js +++ b/packages/patterns/src/components/category-selector.js @@ -4,8 +4,6 @@ import { __ } from '@wordpress/i18n'; import { useMemo, useState } from '@wordpress/element'; import { FormTokenField } from '@wordpress/components'; -import { useSelect } from '@wordpress/data'; -import { store as coreStore } from '@wordpress/core-data'; import { useDebounce } from '@wordpress/compose'; import { decodeEntities } from '@wordpress/html-entities'; @@ -13,40 +11,29 @@ const unescapeString = ( arg ) => { return decodeEntities( arg ); }; -const EMPTY_ARRAY = []; -const MAX_TERMS_SUGGESTIONS = 20; -const DEFAULT_QUERY = { - per_page: MAX_TERMS_SUGGESTIONS, - _fields: 'id,name', - context: 'view', -}; export const CATEGORY_SLUG = 'wp_pattern_category'; -export default function CategorySelector( { values, onChange } ) { +export default function CategorySelector( { + categoryTerms, + onChange, + categoryMap, +} ) { const [ search, setSearch ] = useState( '' ); const debouncedSearch = useDebounce( setSearch, 500 ); - const { searchResults } = useSelect( - ( select ) => { - const { getEntityRecords } = select( coreStore ); - - return { - searchResults: !! search - ? getEntityRecords( 'taxonomy', CATEGORY_SLUG, { - ...DEFAULT_QUERY, - search, - } ) - : EMPTY_ARRAY, - }; - }, - [ search ] - ); - const suggestions = useMemo( () => { - return ( searchResults ?? [] ).map( ( term ) => - unescapeString( term.name ) - ); - }, [ searchResults ] ); + return Array.from( categoryMap.values() ) + .map( ( category ) => unescapeString( category.label ) ) + .filter( ( category ) => { + if ( search !== '' ) { + return category + .toLowerCase() + .includes( search.toLowerCase() ); + } + return true; + } ) + .sort( ( a, b ) => a.localeCompare( b ) ); + }, [ search, categoryMap ] ); function handleChange( termNames ) { const uniqueTerms = termNames.reduce( ( terms, newTerm ) => { @@ -64,17 +51,16 @@ export default function CategorySelector( { values, onChange } ) { } return ( - <> - - + ); } diff --git a/packages/patterns/src/components/create-pattern-modal.js b/packages/patterns/src/components/create-pattern-modal.js index 531936da5e5c28..37dd725ef9226a 100644 --- a/packages/patterns/src/components/create-pattern-modal.js +++ b/packages/patterns/src/components/create-pattern-modal.js @@ -10,8 +10,8 @@ import { ToggleControl, } from '@wordpress/components'; import { __ } from '@wordpress/i18n'; -import { useState } from '@wordpress/element'; -import { useDispatch } from '@wordpress/data'; +import { useState, useMemo } from '@wordpress/element'; +import { useDispatch, useSelect } from '@wordpress/data'; import { store as noticesStore } from '@wordpress/notices'; import { store as coreStore } from '@wordpress/core-data'; @@ -42,6 +42,42 @@ export default function CreatePatternModal( { const { saveEntityRecord, invalidateResolution } = useDispatch( coreStore ); const { createErrorNotice } = useDispatch( noticesStore ); + const { corePatternCategories, userPatternCategories } = useSelect( + ( select ) => { + const { getUserPatternCategories, getBlockPatternCategories } = + select( coreStore ); + + return { + corePatternCategories: getBlockPatternCategories(), + userPatternCategories: getUserPatternCategories(), + }; + } + ); + + const categoryMap = useMemo( () => { + // Merge the user and core pattern categories and remove any duplicates. + const uniqueCategories = new Map(); + [ ...userPatternCategories, ...corePatternCategories ].forEach( + ( category ) => { + if ( + ! uniqueCategories.has( category.label ) && + // There are two core categories with `Post` label so explicitly remove the one with + // the `query` slug to avoid any confusion. + category.name !== 'query' + ) { + // We need to store the name separately as this is used as the slug in the + // taxonomy and may vary from the label. + uniqueCategories.set( category.label, { + label: category.label, + value: category.label, + name: category.name, + } ); + } + } + ); + return uniqueCategories; + }, [ userPatternCategories, corePatternCategories ] ); + async function onCreate( patternTitle, sync ) { if ( ! title || isSaving ) { return; @@ -84,10 +120,16 @@ export default function CreatePatternModal( { */ async function findOrCreateTerm( term ) { try { + // We need to match any existing term to the correct slug to prevent duplicates, eg. + // the core `Headers` category uses the singular `header` as the slug. + const existingTerm = categoryMap.get( term ); + const termData = existingTerm + ? { name: existingTerm.label, slug: existingTerm.name } + : { name: term }; const newTerm = await saveEntityRecord( 'taxonomy', CATEGORY_SLUG, - { name: term }, + termData, { throwOnError: true } ); invalidateResolution( 'getUserPatternCategories' ); @@ -126,8 +168,9 @@ export default function CreatePatternModal( { className="patterns-create-modal__name-input" /> [role="document"] { + width: 350px; + } + .patterns-menu-items__convert-modal-categories { - max-width: 300px; + width: 100%; + position: relative; + min-height: 40px; + } + .components-form-token-field__suggestions-list { + position: absolute; + box-sizing: border-box; + z-index: 1; + background-color: $white; + // Account for the border width of the token field. + width: calc(100% + 2px); + left: -1px; + min-width: initial; + border: 1px solid var(--wp-admin-theme-color); + border-top: none; + box-shadow: 0 0 0 0.5px var(--wp-admin-theme-color); + border-bottom-left-radius: 2px; + border-bottom-right-radius: 2px; } } .patterns-create-modal__name-input input[type="text"] { - min-height: 34px; + // Match the minimal height of the category selector. + min-height: 40px; + // Override the default 1px margin-x. + margin: 0; } From 250ef17435602df098b3fc1353b7e19d228ef573 Mon Sep 17 00:00:00 2001 From: Andrew Serong <14988353+andrewserong@users.noreply.github.com> Date: Mon, 9 Oct 2023 16:52:01 +1100 Subject: [PATCH 003/239] Iframe: Fix positioning when dragging over an iframe (#55150) --- packages/block-editor/src/components/iframe/index.js | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/block-editor/src/components/iframe/index.js b/packages/block-editor/src/components/iframe/index.js index 9a6371dcaf4256..28697324aa8b83 100644 --- a/packages/block-editor/src/components/iframe/index.js +++ b/packages/block-editor/src/components/iframe/index.js @@ -38,7 +38,14 @@ function bubbleEvent( event, Constructor, frame ) { init[ key ] = event[ key ]; } - if ( event instanceof frame.ownerDocument.defaultView.MouseEvent ) { + // Check if the event is a MouseEvent generated within the iframe. + // If so, adjust the coordinates to be relative to the position of + // the iframe. This ensures that components such as Draggable + // receive coordinates relative to the window, instead of relative + // to the iframe. Without this, the Draggable event handler would + // result in components "jumping" position as soon as the user + // drags over the iframe. + if ( event instanceof frame.contentDocument.defaultView.MouseEvent ) { const rect = frame.getBoundingClientRect(); init.clientX += rect.left; init.clientY += rect.top; From 9cf5c2fd5af9ac776bfe4e9525b9610f9445f7b8 Mon Sep 17 00:00:00 2001 From: George Mamadashvili Date: Mon, 9 Oct 2023 11:04:51 +0400 Subject: [PATCH 004/239] Site Editor: Fix template part area listing when a template has no edits (#55115) * Alternative: Fix template part area listing when a template has no edits * Fix typos --- packages/edit-site/src/store/selectors.js | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/packages/edit-site/src/store/selectors.js b/packages/edit-site/src/store/selectors.js index 3f44ab57ba8072..9e861c7567e4ac 100644 --- a/packages/edit-site/src/store/selectors.js +++ b/packages/edit-site/src/store/selectors.js @@ -292,22 +292,21 @@ export function isSaveViewOpened( state ) { * @return {Array} Template parts and their blocks in an array. */ export const getCurrentTemplateTemplateParts = createRegistrySelector( - ( select ) => ( state ) => { - const templateType = getEditedPostType( state ); - const templateId = getEditedPostId( state ); - const template = select( coreDataStore ).getEditedEntityRecord( - 'postType', - templateType, - templateId - ); - + ( select ) => () => { const templateParts = select( coreDataStore ).getEntityRecords( 'postType', TEMPLATE_PART_POST_TYPE, { per_page: -1 } ); - return getFilteredTemplatePartBlocks( template.blocks, templateParts ); + const clientIds = + select( blockEditorStore ).__experimentalGetGlobalBlocksByName( + 'core/template-part' + ); + const blocks = + select( blockEditorStore ).getBlocksByClientId( clientIds ); + + return getFilteredTemplatePartBlocks( blocks, templateParts ); } ); From ccf42860431fe38377553024df0d1025925078e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9?= <583546+oandregal@users.noreply.github.com> Date: Mon, 9 Oct 2023 09:53:13 +0200 Subject: [PATCH 005/239] Initial documentation of entity configuration (#55103) Co-authored-by: Aaron Robertshaw <60436221+aaronrobertshaw@users.noreply.github.com> Co-authored-by: Marin Atanasov <8436925+tyxla@users.noreply.github.com> --- packages/core-data/README.md | 59 ++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/packages/core-data/README.md b/packages/core-data/README.md index 87233a2c206bf0..4c4237bbc1e0ef 100644 --- a/packages/core-data/README.md +++ b/packages/core-data/README.md @@ -40,6 +40,65 @@ function MyAuthorsListBase() { } ``` +## What's an entity? + +An entity represents a data source. Each item within the entity is called an entity record. Available entities are defined in `rootEntitiesConfig` at ./src/entities.js. + +As of right now, the default entities defined by this package map to the [REST API handbook](https://developer.wordpress.org/rest-api/reference/), though there is nothing in the design that prevents it from being used to interact with any other API. + +What follows is a description of some of the properties of `rootEntitiesConfig`. + +## baseURL + +- Type: string. +- Example: `'/wp/v2/users'`. + +This property maps the entity to a given endpoint, taking its relative URL as value. + +## baseURLParams + +- Type: `object`. +- Example: `{ context: 'edit' }`. + +Additional parameters to the request, added as a query string. Each property will be converted into a field/value pair. For example, given the `baseURL: '/wp/v2/users'` and the `baseURLParams: { context: 'edit' }` the URL would be `/wp/v2/users?context=edit`. + +## key + +- Type: `string`. +- Example: `'slug'`. + +The entity engine aims to convert the API response into a number of entity records. Responses can come in different shapes, which are processed differently. + +Responses that represent a single object map to a single entity record. For example: + +```json +{ + "title": "...", + "description": "...", + "...": "..." +} +``` + +Responses that represent a collection shaped as an array, map to as many entity records as elements of the array. For example: + +```json +[ + { "id": 1, "name": "...", "...": "..." }, + { "id": 2, "name": "...", "...": "..." }, + { "id": 3, "name": "...", "...": "..." } +] +``` + +There are also cases in which a response represents a collection shaped as an object, whose key is one of the property's values. Each of the nested objects should be its own entity record. For this case not to be confused with single object/entities, the entity configuration must provide the property key that holds the value acting as the object key. In the following example, the `slug` property's value is acting as the object key, hence the entity config must declare `key: 'slug'` for each nested object to be processed as an individual entity record: + +```json +{ + "publish": { "slug": "publish", "name": "Published", "...": "..." }, + "draft": { "slug": "draft", "name": "Draft", "...": "..." }, + "future": { "slug": "future", "name": "Future", "...": "..." } +} +``` + ## Actions The following set of dispatching action creators are available on the object returned by `wp.data.dispatch( 'core' )`: From cd0035d40bd693556c51b950b09493bab1ab83d3 Mon Sep 17 00:00:00 2001 From: Riad Benguella Date: Mon, 9 Oct 2023 09:24:37 +0100 Subject: [PATCH 006/239] Platform docs: Add a page to explain how to render HTML from a list of blocks (#55140) Co-authored-by: Aaron Robertshaw <60436221+aaronrobertshaw@users.noreply.github.com> Co-authored-by: Ramon --- .../docs/basic-concepts/rendering.md | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/platform-docs/docs/basic-concepts/rendering.md b/platform-docs/docs/basic-concepts/rendering.md index 9c823f4931aaa1..adf5a4992df4da 100644 --- a/platform-docs/docs/basic-concepts/rendering.md +++ b/platform-docs/docs/basic-concepts/rendering.md @@ -3,3 +3,38 @@ sidebar_position: 8 --- # Rendering blocks + +## HTML Serialization and Parsing + +After editing your blocks, you may choose to persist them as JSON or serialize them to HTML. + +Given a block list object, you can retrieve an initial HTML version like so: + +```js +import { serialize } from '@wordpress/blocks'; + +const blockList = [ + { + name: 'core/paragraph', + attributes: { + content: 'Hello world!', + }, + }, +]; + +const html = serialize( blockList ); +``` + +If needed, it is also possible to parse back the HTML into a block list object: + +```js +import { parse } from '@wordpress/blocks'; + +const blockList = parse( html ); +``` + +## Going further + +Some of the customizations that the core blocks offer, like layout styles, do not output the necessary CSS using the `serialize` function. Instead in the editor, an additional style element is appended to the document head. This is done to avoid bloating the HTML output with unnecessary CSS. + +We're currently working on providing a utility that allows you to render blocks with all the necessary CSS. From 0daa67ba73ae1a068003c2c91b05ec77afda7f7a Mon Sep 17 00:00:00 2001 From: Dave Smith Date: Mon, 9 Oct 2023 10:08:53 +0100 Subject: [PATCH 007/239] Remove value syncing in Link Control (#51387) * Remove the syncing * Update docs with best practices * Remove unnecessary test coverage * Implement suggestion sync with previous state * Apply update from Code Review --- .../src/components/link-control/README.md | 20 ++++++++++++++++ .../link-control/use-internal-value.js | 24 +++++++++++-------- 2 files changed, 34 insertions(+), 10 deletions(-) diff --git a/packages/block-editor/src/components/link-control/README.md b/packages/block-editor/src/components/link-control/README.md index 9479863e36f407..edab6b4ad488cd 100644 --- a/packages/block-editor/src/components/link-control/README.md +++ b/packages/block-editor/src/components/link-control/README.md @@ -4,6 +4,26 @@ Renders a link control. A link control is a controlled input which maintains a v It is designed to provide a standardized UI for the creation of a link throughout the Editor, see History section at bottom for further background. +## Best Practices + +### Ensuring unique instances + +It is possible that a given editor may render multiple instances of the `` component. As a result, it is important to ensure each instance is unique across the editor to avoid state "leaking" between components. + +Why would this happen? + +React's reconciliation algorithm means that if you return the same element from a component, it keeps the nodes around as an optimization, even if the props change. This means that if you render two (or more) ``s, it is possible that the `value` from one will appear in the other as you move between them. + +As a result it is recommended that consumers provide a `key` prop to each instance of ``: + +```jsx + +``` + +This will cause React to return the same component/element type but force remount a new instance, thus avoiding the issues described above. + +For more information see: https://github.com/WordPress/gutenberg/pull/34742. + ## Relationship to `` As of Gutenberg 7.4, `` became the default link creation interface within the Block Editor, replacing previous disparate uses of `` and standardizing the UI. diff --git a/packages/block-editor/src/components/link-control/use-internal-value.js b/packages/block-editor/src/components/link-control/use-internal-value.js index ac58c05b10a870..9df557be1460cf 100644 --- a/packages/block-editor/src/components/link-control/use-internal-value.js +++ b/packages/block-editor/src/components/link-control/use-internal-value.js @@ -1,21 +1,25 @@ /** * WordPress dependencies */ -import { useState, useEffect } from '@wordpress/element'; +import { useState } from '@wordpress/element'; + +/** + * External dependencies + */ +import fastDeepEqual from 'fast-deep-equal'; export default function useInternalValue( value ) { const [ internalValue, setInternalValue ] = useState( value || {} ); + const [ previousValue, setPreviousValue ] = useState( value ); // If the value prop changes, update the internal state. - useEffect( () => { - setInternalValue( ( prevValue ) => { - if ( value && value !== prevValue ) { - return value; - } - - return prevValue; - } ); - }, [ value ] ); + // See: + // - https://github.com/WordPress/gutenberg/pull/51387#issuecomment-1722927384. + // - https://react.dev/reference/react/useState#storing-information-from-previous-renders. + if ( ! fastDeepEqual( value, previousValue ) ) { + setPreviousValue( value ); + setInternalValue( value ); + } const setInternalURLInputValue = ( nextValue ) => { setInternalValue( { From ec8b13c41e142c094818641d386cdffd732fc358 Mon Sep 17 00:00:00 2001 From: Ari Stathopoulos Date: Mon, 9 Oct 2023 13:36:40 +0300 Subject: [PATCH 008/239] Allow using CSS level 4 viewport-relative units (#54415) * Allow using new-ish CSS viewport-relative units * Fix typo * Improve svi/svb description --- .../src/components/height-control/index.js | 52 +++++++- .../input-controls/spacing-input-control.js | 22 ++++ packages/block-editor/src/layouts/grid.js | 52 +++++++- packages/block-editor/src/layouts/utils.js | 3 +- .../src/utils/parse-css-unit-to-px.js | 20 +++ .../components/src/font-size-picker/utils.ts | 3 +- .../components/src/sandbox/index.native.js | 2 +- packages/components/src/sandbox/index.tsx | 4 +- packages/components/src/unit-control/utils.ts | 124 ++++++++++++++++++ packages/components/src/utils/unit-values.ts | 2 +- schemas/json/theme.json | 26 +++- 11 files changed, 299 insertions(+), 11 deletions(-) diff --git a/packages/block-editor/src/components/height-control/index.js b/packages/block-editor/src/components/height-control/index.js index 6d4fc1148cf2fc..538662c7626fcc 100644 --- a/packages/block-editor/src/components/height-control/index.js +++ b/packages/block-editor/src/components/height-control/index.js @@ -26,6 +26,28 @@ const RANGE_CONTROL_CUSTOM_SETTINGS = { vh: { max: 100, step: 1 }, em: { max: 50, step: 0.1 }, rem: { max: 50, step: 0.1 }, + svw: { max: 100, step: 1 }, + lvw: { max: 100, step: 1 }, + dvw: { max: 100, step: 1 }, + svh: { max: 100, step: 1 }, + lvh: { max: 100, step: 1 }, + dvh: { max: 100, step: 1 }, + vi: { max: 100, step: 1 }, + svi: { max: 100, step: 1 }, + lvi: { max: 100, step: 1 }, + dvi: { max: 100, step: 1 }, + vb: { max: 100, step: 1 }, + svb: { max: 100, step: 1 }, + lvb: { max: 100, step: 1 }, + dvb: { max: 100, step: 1 }, + vmin: { max: 100, step: 1 }, + svmin: { max: 100, step: 1 }, + lvmin: { max: 100, step: 1 }, + dvmin: { max: 100, step: 1 }, + vmax: { max: 100, step: 1 }, + svmax: { max: 100, step: 1 }, + lvmax: { max: 100, step: 1 }, + dvmax: { max: 100, step: 1 }, }; /** @@ -86,10 +108,36 @@ export default function HeightControl( { // Convert to pixel value assuming a root size of 16px. onChange( Math.round( currentValue * 16 ) + newUnit ); } else if ( - [ 'vh', 'vw', '%' ].includes( newUnit ) && + [ + '%', + 'vw', + 'svw', + 'lvw', + 'dvw', + 'vh', + 'svh', + 'lvh', + 'dvh', + 'vi', + 'svi', + 'lvi', + 'dvi', + 'vb', + 'svb', + 'lvb', + 'dvb', + 'vmin', + 'svmin', + 'lvmin', + 'dvmin', + 'vmax', + 'svmax', + 'lvmax', + 'dvmax', + ].includes( newUnit ) && currentValue > 100 ) { - // When converting to `vh`, `vw`, or `%` units, cap the new value at 100. + // When converting to `%` or viewport-relative units, cap the new value at 100. onChange( 100 + newUnit ); } }; diff --git a/packages/block-editor/src/components/spacing-sizes-control/input-controls/spacing-input-control.js b/packages/block-editor/src/components/spacing-sizes-control/input-controls/spacing-input-control.js index f423596daaa4a5..1b324835e362dc 100644 --- a/packages/block-editor/src/components/spacing-sizes-control/input-controls/spacing-input-control.js +++ b/packages/block-editor/src/components/spacing-sizes-control/input-controls/spacing-input-control.js @@ -38,6 +38,28 @@ const CUSTOM_VALUE_SETTINGS = { vh: { max: 100, steps: 1 }, em: { max: 10, steps: 0.1 }, rm: { max: 10, steps: 0.1 }, + svw: { max: 100, steps: 1 }, + lvw: { max: 100, steps: 1 }, + dvw: { max: 100, steps: 1 }, + svh: { max: 100, steps: 1 }, + lvh: { max: 100, steps: 1 }, + dvh: { max: 100, steps: 1 }, + vi: { max: 100, steps: 1 }, + svi: { max: 100, steps: 1 }, + lvi: { max: 100, steps: 1 }, + dvi: { max: 100, steps: 1 }, + vb: { max: 100, steps: 1 }, + svb: { max: 100, steps: 1 }, + lvb: { max: 100, steps: 1 }, + dvb: { max: 100, steps: 1 }, + vmin: { max: 100, steps: 1 }, + svmin: { max: 100, steps: 1 }, + lvmin: { max: 100, steps: 1 }, + dvmin: { max: 100, steps: 1 }, + vmax: { max: 100, steps: 1 }, + svmax: { max: 100, steps: 1 }, + lvmax: { max: 100, steps: 1 }, + dvmax: { max: 100, steps: 1 }, }; export default function SpacingInputControl( { diff --git a/packages/block-editor/src/layouts/grid.js b/packages/block-editor/src/layouts/grid.js index 55ac1e53bcd87e..6dd22cf87ba599 100644 --- a/packages/block-editor/src/layouts/grid.js +++ b/packages/block-editor/src/layouts/grid.js @@ -27,6 +27,28 @@ const RANGE_CONTROL_MAX_VALUES = { vh: 100, em: 38, rem: 38, + svw: 100, + lvw: 100, + dvw: 100, + svh: 100, + lvh: 100, + dvh: 100, + vi: 100, + svi: 100, + lvi: 100, + dvi: 100, + vb: 100, + svb: 100, + lvb: 100, + dvb: 100, + vmin: 100, + svmin: 100, + lvmin: 100, + dvmin: 100, + vmax: 100, + svmax: 100, + lvmax: 100, + dvmax: 100, }; export default { @@ -131,10 +153,36 @@ function GridLayoutMinimumWidthControl( { layout, onChange } ) { // Convert to pixel value assuming a root size of 16px. newValue = Math.round( quantity * 16 ) + newUnit; } else if ( - [ 'vh', 'vw', '%' ].includes( newUnit ) && + [ + 'vh', + 'vw', + '%', + 'svw', + 'lvw', + 'dvw', + 'svh', + 'lvh', + 'dvh', + 'vi', + 'svi', + 'lvi', + 'dvi', + 'vb', + 'svb', + 'lvb', + 'dvb', + 'vmin', + 'svmin', + 'lvmin', + 'dvmin', + 'vmax', + 'svmax', + 'lvmax', + 'dvmax', + ].includes( newUnit ) && quantity > 100 ) { - // When converting to `vh`, `vw`, or `%` units, cap the new value at 100. + // When converting to `%` or viewport-relative units, cap the new value at 100. newValue = 100 + newUnit; } diff --git a/packages/block-editor/src/layouts/utils.js b/packages/block-editor/src/layouts/utils.js index 51c92b5eb457e7..30280b8906e7a0 100644 --- a/packages/block-editor/src/layouts/utils.js +++ b/packages/block-editor/src/layouts/utils.js @@ -90,7 +90,8 @@ export function getBlockGapCSS( export function getAlignmentsInfo( layout ) { const { contentSize, wideSize, type = 'default' } = layout; const alignmentInfo = {}; - const sizeRegex = /^(?!0)\d+(px|em|rem|vw|vh|%)?$/i; + const sizeRegex = + /^(?!0)\d+(px|em|rem|vw|vh|%|svw|lvw|dvw|svh|lvh|dvh|vi|svi|lvi|dvi|vb|svb|lvb|dvb|vmin|svmin|lvmin|dvmin|vmax|svmax|lvmax|dvmax)?$/i; if ( sizeRegex.test( contentSize ) && type === 'constrained' ) { // translators: %s: container size (i.e. 600px etc) alignmentInfo.none = sprintf( __( 'Max %s wide' ), contentSize ); diff --git a/packages/block-editor/src/utils/parse-css-unit-to-px.js b/packages/block-editor/src/utils/parse-css-unit-to-px.js index da2337496f926e..8689de98696095 100644 --- a/packages/block-editor/src/utils/parse-css-unit-to-px.js +++ b/packages/block-editor/src/utils/parse-css-unit-to-px.js @@ -211,6 +211,26 @@ function convertParsedUnitToPx( parsedUnit, options ) { ex: 7.15625, // X-height of the element's font. Approximate. lh: setOptions.lineHeight, }; + relativeUnits.svw = relativeUnits.vmin; + relativeUnits.lvw = relativeUnits.vmax; + relativeUnits.dvw = relativeUnits.vw; + relativeUnits.svh = relativeUnits.vmin; + relativeUnits.lvh = relativeUnits.vmax; + relativeUnits.dvh = relativeUnits.vh; + relativeUnits.vi = relativeUnits.vh; + relativeUnits.svi = relativeUnits.vmin; + relativeUnits.lvi = relativeUnits.vmax; + relativeUnits.dvi = relativeUnits.vw; + relativeUnits.vb = relativeUnits.vh; + relativeUnits.svb = relativeUnits.vmin; + relativeUnits.lvb = relativeUnits.vmax; + relativeUnits.dvb = relativeUnits.vh; + relativeUnits.svmin = relativeUnits.vmin; + relativeUnits.lvmin = relativeUnits.vmin; + relativeUnits.dvmin = relativeUnits.vmin; + relativeUnits.svmax = relativeUnits.vmax; + relativeUnits.lvmax = relativeUnits.vmax; + relativeUnits.dvmax = relativeUnits.vmax; const absoluteUnits = { in: PIXELS_PER_INCH, diff --git a/packages/components/src/font-size-picker/utils.ts b/packages/components/src/font-size-picker/utils.ts index 492f47b7c62ace..cf81c7ed27b182 100644 --- a/packages/components/src/font-size-picker/utils.ts +++ b/packages/components/src/font-size-picker/utils.ts @@ -19,7 +19,8 @@ import { parseQuantityAndUnitFromRawValue } from '../unit-control'; export function isSimpleCssValue( value: NonNullable< FontSizePickerProps[ 'value' ] > ) { - const sizeRegex = /^[\d\.]+(px|em|rem|vw|vh|%)?$/i; + const sizeRegex = + /^[\d\.]+(px|em|rem|vw|vh|%|svw|lvw|dvw|svh|lvh|dvh|vi|svi|lvi|dvi|vb|svb|lvb|dvb|vmin|svmin|lvmin|dvmin|vmax|svmax|lvmax|dvmax)?$/i; return sizeRegex.test( String( value ) ); } diff --git a/packages/components/src/sandbox/index.native.js b/packages/components/src/sandbox/index.native.js index 0b1b0eb88f2def..b9bbc9a4e3281f 100644 --- a/packages/components/src/sandbox/index.native.js +++ b/packages/components/src/sandbox/index.native.js @@ -68,7 +68,7 @@ const observeAndResizeJS = ` style ) { if ( - /^\\d+(vmin|vmax|vh|vw)$/.test( ruleOrNode.style[ style ] ) + /^\\d+(vw|vh|svw|lvw|dvw|svh|lvh|dvh|vi|svi|lvi|dvi|vb|svb|lvb|dvb|vmin|svmin|lvmin|dvmin|vmax|svmax|lvmax|dvmax)$/.test( ruleOrNode.style[ style ] ) ) { ruleOrNode.style[ style ] = ''; } diff --git a/packages/components/src/sandbox/index.tsx b/packages/components/src/sandbox/index.tsx index 66c2c9cd865634..4ad971cb63ca90 100644 --- a/packages/components/src/sandbox/index.tsx +++ b/packages/components/src/sandbox/index.tsx @@ -55,7 +55,9 @@ const observeAndResizeJS = function () { [ 'width', 'height', 'minHeight', 'maxHeight' ] as const ).forEach( function ( style ) { if ( - /^\\d+(vmin|vmax|vh|vw)$/.test( ruleOrNode.style[ style ] ) + /^\\d+(vw|vh|svw|lvw|dvw|svh|lvh|dvh|vi|svi|lvi|dvi|vb|svb|lvb|dvb|vmin|svmin|lvmin|dvmin|vmax|svmax|lvmax|dvmax)$/.test( + ruleOrNode.style[ style ] + ) ) { ruleOrNode.style[ style ] = ''; } diff --git a/packages/components/src/unit-control/utils.ts b/packages/components/src/unit-control/utils.ts index cbca50459086c0..c80b34ef3af7b0 100644 --- a/packages/components/src/unit-control/utils.ts +++ b/packages/components/src/unit-control/utils.ts @@ -102,6 +102,130 @@ const allUnits: Record< string, WPUnitControlUnit > = { a11yLabel: __( 'Points (pt)' ), step: 1, }, + svw: { + value: 'svw', + label: isWeb ? 'svw' : __( 'Small viewport width (svw)' ), + a11yLabel: __( 'Small viewport width (svw)' ), + step: 0.1, + }, + svh: { + value: 'svh', + label: isWeb ? 'svh' : __( 'Small viewport height (svh)' ), + a11yLabel: __( 'Small viewport height (svh)' ), + step: 0.1, + }, + svi: { + value: 'svi', + label: isWeb + ? 'svi' + : __( 'Viewport smallest size in the inline direction (svi)' ), + a11yLabel: __( 'Small viewport width or height (svi)' ), + step: 0.1, + }, + svb: { + value: 'svb', + label: isWeb + ? 'svb' + : __( 'Viewport smallest size in the block direction (svb)' ), + a11yLabel: __( 'Small viewport width or height (svb)' ), + step: 0.1, + }, + svmin: { + value: 'svmin', + label: isWeb + ? 'svmin' + : __( 'Small viewport smallest dimension (svmin)' ), + a11yLabel: __( 'Small viewport smallest dimension (svmin)' ), + step: 0.1, + }, + lvw: { + value: 'lvw', + label: isWeb ? 'lvw' : __( 'Large viewport width (lvw)' ), + a11yLabel: __( 'Large viewport width (lvw)' ), + step: 0.1, + }, + lvh: { + value: 'lvh', + label: isWeb ? 'lvh' : __( 'Large viewport height (lvh)' ), + a11yLabel: __( 'Large viewport height (lvh)' ), + step: 0.1, + }, + lvi: { + value: 'lvi', + label: isWeb ? 'lvi' : __( 'Large viewport width or height (lvi)' ), + a11yLabel: __( 'Large viewport width or height (lvi)' ), + step: 0.1, + }, + lvb: { + value: 'lvb', + label: isWeb ? 'lvb' : __( 'Large viewport width or height (lvb)' ), + a11yLabel: __( 'Large viewport width or height (lvb)' ), + step: 0.1, + }, + lvmin: { + value: 'lvmin', + label: isWeb + ? 'lvmin' + : __( 'Large viewport smallest dimension (lvmin)' ), + a11yLabel: __( 'Large viewport smallest dimension (lvmin)' ), + step: 0.1, + }, + dvw: { + value: 'dvw', + label: isWeb ? 'dvw' : __( 'Dynamic viewport width (dvw)' ), + a11yLabel: __( 'Dynamic viewport width (dvw)' ), + step: 0.1, + }, + dvh: { + value: 'dvh', + label: isWeb ? 'dvh' : __( 'Dynamic viewport height (dvh)' ), + a11yLabel: __( 'Dynamic viewport height (dvh)' ), + step: 0.1, + }, + dvi: { + value: 'dvi', + label: isWeb ? 'dvi' : __( 'Dynamic viewport width or height (dvi)' ), + a11yLabel: __( 'Dynamic viewport width or height (dvi)' ), + step: 0.1, + }, + dvb: { + value: 'dvb', + label: isWeb ? 'dvb' : __( 'Dynamic viewport width or height (dvb)' ), + a11yLabel: __( 'Dynamic viewport width or height (dvb)' ), + step: 0.1, + }, + dvmin: { + value: 'dvmin', + label: isWeb + ? 'dvmin' + : __( 'Dynamic viewport smallest dimension (dvmin)' ), + a11yLabel: __( 'Dynamic viewport smallest dimension (dvmin)' ), + step: 0.1, + }, + dvmax: { + value: 'dvmax', + label: isWeb + ? 'dvmax' + : __( 'Dynamic viewport largest dimension (dvmax)' ), + a11yLabel: __( 'Dynamic viewport largest dimension (dvmax)' ), + step: 0.1, + }, + svmax: { + value: 'svmax', + label: isWeb + ? 'svmax' + : __( 'Small viewport largest dimension (svmax)' ), + a11yLabel: __( 'Small viewport largest dimension (svmax)' ), + step: 0.1, + }, + lvmax: { + value: 'lvmax', + label: isWeb + ? 'lvmax' + : __( 'Large viewport largest dimension (lvmax)' ), + a11yLabel: __( 'Large viewport largest dimension (lvmax)' ), + step: 0.1, + }, }; /** diff --git a/packages/components/src/utils/unit-values.ts b/packages/components/src/utils/unit-values.ts index e38c65494c1a36..7bd09ab67ec97f 100644 --- a/packages/components/src/utils/unit-values.ts +++ b/packages/components/src/utils/unit-values.ts @@ -1,5 +1,5 @@ const UNITED_VALUE_REGEX = - /^([\d.\-+]*)\s*(fr|cm|mm|Q|in|pc|pt|px|em|ex|ch|rem|lh|vw|vh|vmin|vmax|%|cap|ic|rlh|vi|vb|deg|grad|rad|turn|s|ms|Hz|kHz|dpi|dpcm|dppx)?$/; + /^([\d.\-+]*)\s*(fr|cm|mm|Q|in|pc|pt|px|em|ex|ch|rem|lh|vw|vh|vmin|vmax|%|cap|ic|rlh|vi|vb|deg|grad|rad|turn|s|ms|Hz|kHz|dpi|dpcm|dppx|svw|lvw|dvw|svh|lvh|dvh|svi|lvi|dvi|svb|lvb|dvb|svmin|lvmin|dvmin|svmax|lvmax|dvmax)?$/; /** * Parses a number and unit from a value. diff --git a/schemas/json/theme.json b/schemas/json/theme.json index 5a0b049f6d0d5d..8414c939c8c7ae 100644 --- a/schemas/json/theme.json +++ b/schemas/json/theme.json @@ -425,9 +425,31 @@ "px", "em", "rem", - "vh", + "%", "vw", - "%" + "svw", + "lvw", + "dvw", + "vh", + "svh", + "lvh", + "dvh", + "vi", + "svi", + "lvi", + "dvi", + "vb", + "svb", + "lvb", + "dvb", + "vmin", + "svmin", + "lvmin", + "dvmin", + "vmax", + "svmax", + "lvmax", + "dvmax" ], "default": "rem" } From 5cf045dac18ebccb66f257d2e9ef6f43e2aee007 Mon Sep 17 00:00:00 2001 From: John Blackbourn Date: Mon, 9 Oct 2023 13:50:17 +0200 Subject: [PATCH 009/239] Correct the documented type for the `$w` parameter of `block_core_navigation_add_directives_to_submenu()` (#53585) --- packages/block-library/src/navigation/index.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/block-library/src/navigation/index.php b/packages/block-library/src/navigation/index.php index a07235b601abb1..036e6250d65763 100644 --- a/packages/block-library/src/navigation/index.php +++ b/packages/block-library/src/navigation/index.php @@ -72,8 +72,8 @@ function block_core_navigation_sort_menu_items_by_parent_id( $menu_items ) { * Add Interactivity API directives to the navigation-submenu and page-list * blocks markup using the Tag Processor. * - * @param string $w Markup of the navigation block. - * @param array $block_attributes Block attributes. + * @param WP_HTML_Tag_Processor $w Markup of the navigation block. + * @param array $block_attributes Block attributes. * * @return string Submenu markup with the directives injected. */ From 4a6c1892c5e638e3c988f8b0fd516a612cd224a7 Mon Sep 17 00:00:00 2001 From: Nik Tsekouras Date: Mon, 9 Oct 2023 14:57:13 +0300 Subject: [PATCH 010/239] Fix wrong notification message shown when an entity is moved to trash (#55155) --- packages/editor/src/store/test/actions.js | 8 +++++++- packages/editor/src/store/utils/notice-builder.js | 12 ++++++------ .../editor/src/store/utils/test/notice-builder.js | 7 ++++++- 3 files changed, 19 insertions(+), 8 deletions(-) diff --git a/packages/editor/src/store/test/actions.js b/packages/editor/src/store/test/actions.js index 5f9a33b2479d6a..b842450b733b38 100644 --- a/packages/editor/src/store/test/actions.js +++ b/packages/editor/src/store/test/actions.js @@ -33,6 +33,7 @@ const postTypeEntity = { item_updated: 'Updated Post', item_published: 'Post published', item_reverted_to_draft: 'Post reverted to draft.', + item_trashed: 'Post trashed.', }, }; @@ -286,7 +287,12 @@ describe( 'Post actions', () => { // Check that there are no notices. const notices = registry.select( noticesStore ).getNotices(); - expect( notices ).toEqual( [] ); + expect( notices ).toMatchObject( [ + { + status: 'success', + content: 'Post trashed.', + }, + ] ); // Check the new status. const { status } = registry.select( editorStore ).getCurrentPost(); diff --git a/packages/editor/src/store/utils/notice-builder.js b/packages/editor/src/store/utils/notice-builder.js index e7bb29741ffc62..58fc9ca0d747eb 100644 --- a/packages/editor/src/store/utils/notice-builder.js +++ b/packages/editor/src/store/utils/notice-builder.js @@ -23,21 +23,21 @@ export function getNotificationArgumentsForSaveSuccess( data ) { return []; } - // No notice is shown after trashing a post - if ( post.status === 'trash' && previousPost.status !== 'trash' ) { - return []; - } - const publishStatus = [ 'publish', 'private', 'future' ]; const isPublished = publishStatus.includes( previousPost.status ); const willPublish = publishStatus.includes( post.status ); + const willTrash = + post.status === 'trash' && previousPost.status !== 'trash'; let noticeMessage; let shouldShowLink = postType?.viewable ?? false; let isDraft; // Always should a notice, which will be spoken for accessibility. - if ( ! isPublished && ! willPublish ) { + if ( willTrash ) { + noticeMessage = postType.labels.item_trashed; + shouldShowLink = false; + } else if ( ! isPublished && ! willPublish ) { // If saving a non-published post, don't show notice. noticeMessage = __( 'Draft saved.' ); isDraft = true; diff --git a/packages/editor/src/store/utils/test/notice-builder.js b/packages/editor/src/store/utils/test/notice-builder.js index 75fe7d675fc995..e66a96259680f7 100644 --- a/packages/editor/src/store/utils/test/notice-builder.js +++ b/packages/editor/src/store/utils/test/notice-builder.js @@ -17,6 +17,7 @@ describe( 'getNotificationArgumentsForSaveSuccess()', () => { item_scheduled: 'scheduled', item_updated: 'updated', view_item: 'view', + item_trashed: 'trash', }, viewable: false, }; @@ -74,7 +75,11 @@ describe( 'getNotificationArgumentsForSaveSuccess()', () => { }, ], ], - [ 'when post will be trashed', [ 'publish', 'trash', true ], [] ], + [ + 'when post will be trashed', + [ 'publish', 'trash', true ], + [ 'trash', defaultExpectedAction ], + ], ].forEach( ( [ description, From ae5c4915227f8d505ff76c9efea64912da5e856f Mon Sep 17 00:00:00 2001 From: Riad Benguella Date: Mon, 9 Oct 2023 13:18:03 +0100 Subject: [PATCH 011/239] DataViews: Update the data views component to pass an view object (#55154) Co-authored-by: ntsekouras --- .../src/components/dataviews/dataviews.js | 67 +++++++++++++++- .../src/components/page-pages/index.js | 76 ++++++------------- 2 files changed, 89 insertions(+), 54 deletions(-) diff --git a/packages/edit-site/src/components/dataviews/dataviews.js b/packages/edit-site/src/components/dataviews/dataviews.js index bf492cb5691a36..4628d850a023c2 100644 --- a/packages/edit-site/src/components/dataviews/dataviews.js +++ b/packages/edit-site/src/components/dataviews/dataviews.js @@ -28,18 +28,81 @@ import TextFilter from './text-filter'; export default function DataViews( { data, fields, + view, + onChangeView, isLoading, paginationInfo, - options, + options: { pageCount }, } ) { const dataView = useReactTable( { data, columns: fields, - ...options, + manualSorting: true, + manualFiltering: true, + manualPagination: true, + enableRowSelection: true, + state: { + sorting: view.sort + ? [ + { + id: view.sort.field, + desc: view.sort.direction === 'desc', + }, + ] + : [], + globalFilter: view.search, + pagination: { + pageIndex: view.page, + pageSize: view.perPage, + }, + }, + onSortingChange: ( sortingUpdater ) => { + onChangeView( ( currentView ) => { + const sort = + typeof sortingUpdater === 'function' + ? sortingUpdater( + currentView.sort + ? [ + { + id: currentView.sort.field, + desc: + currentView.sort + .direction === 'desc', + }, + ] + : [] + ) + : sortingUpdater; + if ( ! sort.length ) { + return { + ...currentView, + sort: {}, + }; + } + const [ { id, desc } ] = sort; + return { + ...currentView, + sort: { field: id, direction: desc ? 'desc' : 'asc' }, + }; + } ); + }, + onGlobalFilterChange: ( value ) => { + onChangeView( { ...view, search: value, page: 0 } ); + }, + onPaginationChange: ( paginationUpdater ) => { + onChangeView( ( currentView ) => { + const { pageIndex, pageSize } = paginationUpdater( { + pageIndex: currentView.page, + pageSize: currentView.perPage, + } ); + return { ...view, page: pageIndex, perPage: pageSize }; + } ); + }, getCoreRowModel: getCoreRowModel(), getFilteredRowModel: getFilteredRowModel(), getSortedRowModel: getSortedRowModel(), getPaginationRowModel: getPaginationRowModel(), + pageCount, } ); return (
diff --git a/packages/edit-site/src/components/page-pages/index.js b/packages/edit-site/src/components/page-pages/index.js index b767ad77adc6d9..eea646c1c7326c 100644 --- a/packages/edit-site/src/components/page-pages/index.js +++ b/packages/edit-site/src/components/page-pages/index.js @@ -19,19 +19,23 @@ import { useState, useEffect, useMemo } from '@wordpress/element'; import Page from '../page'; import Link from '../routes/link'; import PageActions from '../page-actions'; -import { DataViews, PAGE_SIZE_VALUES } from '../dataviews'; +import { DataViews } from '../dataviews'; const EMPTY_ARRAY = []; const EMPTY_OBJECT = {}; export default function PagePages() { - const [ reset, setResetQuery ] = useState( ( v ) => ! v ); - const [ globalFilter, setGlobalFilter ] = useState( '' ); - const [ paginationInfo, setPaginationInfo ] = useState(); - const [ { pageIndex, pageSize }, setPagination ] = useState( { - pageIndex: 0, - pageSize: PAGE_SIZE_VALUES[ 0 ], + const [ view, setView ] = useState( { + type: 'list', + search: '', + page: 0, + perPage: 5, + sort: { + field: 'date', + direction: 'desc', + }, } ); + const [ paginationInfo, setPaginationInfo ] = useState(); // Request post statuses to get the proper labels. const { records: statuses } = useEntityRecords( 'root', 'status' ); const postStatuses = @@ -42,32 +46,17 @@ export default function PagePages() { return acc; }, EMPTY_OBJECT ); - // TODO: probably memo other objects passed as state(ex:https://tanstack.com/table/v8/docs/examples/react/pagination-controlled). - const pagination = useMemo( - () => ( { pageIndex, pageSize } ), - [ pageIndex, pageSize ] - ); - const [ sorting, setSorting ] = useState( [ - { order: 'desc', orderby: 'date' }, - ] ); const queryArgs = useMemo( () => ( { - per_page: pageSize, - page: pageIndex + 1, // tanstack starts from zero. + per_page: view.perPage, + page: view.page + 1, // tanstack starts from zero. _embed: 'author', - order: sorting[ 0 ]?.desc ? 'desc' : 'asc', - orderby: sorting[ 0 ]?.id, - search: globalFilter, + order: view.sort.direction, + orderby: view.sort.field, + search: view.search, status: [ 'publish', 'draft' ], } ), - [ - globalFilter, - sorting[ 0 ]?.id, - sorting[ 0 ]?.desc, - pageSize, - pageIndex, - reset, - ] + [ view ] ); const { records: pages, isResolving: isLoadingPages } = useEntityRecords( 'postType', @@ -84,6 +73,9 @@ export default function PagePages() { method: 'HEAD', parse: false, } ).then( ( res ) => { + // TODO: store this in core-data reducer and + // make sure it's returned as part of useEntityRecords + // (to avoid double requests). const totalPages = parseInt( res.headers.get( 'X-WP-TotalPages' ) ); const totalItems = parseInt( res.headers.get( 'X-WP-Total' ) ); setPaginationInfo( { @@ -92,7 +84,7 @@ export default function PagePages() { } ); } ); // Status should not make extra request if already did.. - }, [ globalFilter, pageSize, reset ] ); + }, [ queryArgs ] ); const fields = useMemo( () => [ @@ -147,12 +139,7 @@ export default function PagePages() { id: 'actions', cell: ( props ) => { const page = props.row.original; - return ( - setResetQuery() } - /> - ); + return ; }, enableHiding: false, }, @@ -168,25 +155,10 @@ export default function PagePages() { data={ pages || EMPTY_ARRAY } isLoading={ isLoadingPages } fields={ fields } + view={ view } + onChangeView={ setView } options={ { - manualSorting: true, - manualFiltering: true, - manualPagination: true, - enableRowSelection: true, - state: { - sorting, - globalFilter, - pagination, - }, pageCount: paginationInfo?.totalPages, - onSortingChange: setSorting, - onGlobalFilterChange: ( value ) => { - setGlobalFilter( value ); - setPagination( { pageIndex: 0, pageSize } ); - }, - // TODO: check these callbacks and maybe reset the query when needed... - onPaginationChange: setPagination, - meta: { resetQuery: setResetQuery }, } } /> From 6e37736d1176e039b531c31a3d616c8ec5b88c83 Mon Sep 17 00:00:00 2001 From: Carlos Garcia Date: Mon, 9 Oct 2023 16:24:04 +0200 Subject: [PATCH 012/239] [RNMobile] Use `UnsupportedBlockDetails` component in Missing block (#55133) * Use `UnsupportedBlockDetails` in unsupported blocks * Fix missing outline in Classic block * Add missing mock style used `UnsupportedBlockDetails` component * Mock RN bridge elements related to UBE * Expand Missing block integration tests * Remove Missing block unit tests related to UBE These tests have been superseded by integration tests. * Update UBE test descriptions and comments * Use default action label of `UnsupportedBlockDetails` compoment * Update Missing block integration tests --- .../block-list/block-outline.native.js | 2 +- .../src/components/index.native.js | 1 + .../block-library/src/missing/edit.native.js | 132 ++----------- .../src/missing/style.native.scss | 67 ------- .../missing/test/edit-integration.native.js | 184 +++++++++++++----- .../src/missing/test/edit.native.js | 41 ---- test/native/__mocks__/styleMock.js | 3 + test/native/setup.js | 4 + 8 files changed, 161 insertions(+), 273 deletions(-) diff --git a/packages/block-editor/src/components/block-list/block-outline.native.js b/packages/block-editor/src/components/block-list/block-outline.native.js index 83c6a58bac365f..76f0f8cb947941 100644 --- a/packages/block-editor/src/components/block-list/block-outline.native.js +++ b/packages/block-editor/src/components/block-list/block-outline.native.js @@ -13,7 +13,7 @@ import { usePreferredColorSchemeStyle } from '@wordpress/compose'; */ import styles from './block.scss'; -const TEXT_BLOCKS_WITH_OUTLINE = [ 'core/missing' ]; +const TEXT_BLOCKS_WITH_OUTLINE = [ 'core/missing', 'core/freeform' ]; function BlockOutline( { blockCategory, diff --git a/packages/block-editor/src/components/index.native.js b/packages/block-editor/src/components/index.native.js index 1f675daaa8ca1d..a89fdb9d6ac637 100644 --- a/packages/block-editor/src/components/index.native.js +++ b/packages/block-editor/src/components/index.native.js @@ -67,6 +67,7 @@ export { export { default as Warning } from './warning'; export { default as ContrastChecker } from './contrast-checker'; export { default as useMultipleOriginColorsAndGradients } from './colors-gradients/use-multiple-origin-colors-and-gradients'; +export { default as UnsupportedBlockDetails } from './unsupported-block-details'; export { BottomSheetSettings, diff --git a/packages/block-library/src/missing/edit.native.js b/packages/block-library/src/missing/edit.native.js index cf590dc0181c4f..8aa4738aeea85d 100644 --- a/packages/block-library/src/missing/edit.native.js +++ b/packages/block-library/src/missing/edit.native.js @@ -11,12 +11,7 @@ import { /** * WordPress dependencies */ -import { - requestUnsupportedBlockFallback, - sendActionButtonPressedAction, - actionButtons, -} from '@wordpress/react-native-bridge'; -import { BottomSheet, Icon, TextControl } from '@wordpress/components'; +import { Icon } from '@wordpress/components'; import { compose, withPreferredColorScheme } from '@wordpress/compose'; import { coreBlocks } from '@wordpress/block-library'; import { normalizeIconObject } from '@wordpress/blocks'; @@ -25,7 +20,10 @@ import { __, _x, sprintf } from '@wordpress/i18n'; import { help, plugins } from '@wordpress/icons'; import { withSelect, withDispatch } from '@wordpress/data'; import { applyFilters } from '@wordpress/hooks'; -import { store as blockEditorStore } from '@wordpress/block-editor'; +import { + UnsupportedBlockDetails, + store as blockEditorStore, +} from '@wordpress/block-editor'; /** * Internal dependencies @@ -121,122 +119,26 @@ export class UnsupportedBlockEdit extends Component { } renderSheet( blockTitle, blockName ) { - const { - getStylesFromColorScheme, - attributes, - clientId, - isUnsupportedBlockEditorSupported, - canEnableUnsupportedBlockEditor, - isEditableInUnsupportedBlockEditor, - } = this.props; - const infoTextStyle = getStylesFromColorScheme( - styles.infoText, - styles.infoTextDark - ); - const infoTitleStyle = getStylesFromColorScheme( - styles.infoTitle, - styles.infoTitleDark - ); - const infoDescriptionStyle = getStylesFromColorScheme( - styles.infoDescription, - styles.infoDescriptionDark - ); - const infoSheetIconStyle = getStylesFromColorScheme( - styles.infoSheetIcon, - styles.infoSheetIconDark - ); - + const { clientId } = this.props; + const { showHelp } = this.state; /* translators: Missing block alert title. %s: The localized block name */ const titleFormat = __( "'%s' is not fully-supported" ); - const infoTitle = sprintf( titleFormat, blockTitle ); - const missingBlockDetail = applyFilters( + const title = sprintf( titleFormat, blockTitle ); + const description = applyFilters( 'native.missing_block_detail', __( 'We are working hard to add more blocks with each release.' ), blockName ); - const missingBlockActionButton = applyFilters( - 'native.missing_block_action_button', - __( 'Edit using web editor' ) - ); - - const actionButtonStyle = getStylesFromColorScheme( - styles.actionButton, - styles.actionButtonDark - ); return ( - { - if ( this.state.sendFallbackMessage ) { - // On iOS, onModalHide is called when the controller is still part of the hierarchy. - // A small delay will ensure that the controller has already been removed. - this.timeout = setTimeout( () => { - // For the Classic block, the content is kept in the `content` attribute. - const content = - blockName === 'core/freeform' - ? attributes.content - : attributes.originalContent; - requestUnsupportedBlockFallback( - content, - clientId, - blockName, - blockTitle - ); - }, 100 ); - this.setState( { sendFallbackMessage: false } ); - } else if ( this.state.sendButtonPressMessage ) { - this.timeout = setTimeout( () => { - sendActionButtonPressedAction( - actionButtons.missingBlockAlertActionButton - ); - }, 100 ); - this.setState( { sendButtonPressMessage: false } ); - } - } } - > - - - - { infoTitle } - - { isEditableInUnsupportedBlockEditor && - missingBlockDetail && ( - - { missingBlockDetail } - - ) } - - { ( isUnsupportedBlockEditorSupported || - canEnableUnsupportedBlockEditor ) && - isEditableInUnsupportedBlockEditor && ( - <> - - - - ) } - + ); } diff --git a/packages/block-library/src/missing/style.native.scss b/packages/block-library/src/missing/style.native.scss index 9a56f82f7e3f0d..5d83c67f78b818 100644 --- a/packages/block-library/src/missing/style.native.scss +++ b/packages/block-library/src/missing/style.native.scss @@ -1,13 +1,3 @@ -/** @format */ -.content { - padding-top: 8; - padding-bottom: 0; - padding-left: 24; - padding-right: 24; - align-items: center; - justify-content: space-evenly; -} - .helpIconContainer { position: absolute; top: 0; @@ -20,12 +10,6 @@ align-items: flex-end; } -.infoContainer { - flex-direction: column; - align-items: center; - justify-content: flex-end; -} - .infoIcon { size: 36; height: 36; @@ -38,49 +22,6 @@ color: $dark-tertiary; } -.infoSheetIcon { - size: 36; - height: 36; - padding-top: 8; - padding-bottom: 8; - color: $gray; -} - -.infoSheetIconDark { - color: $gray-20; -} - -.infoText { - text-align: center; - color: $gray-dark; -} - -.infoTextDark { - color: $white; -} - -.infoTitle { - padding-top: 8; - padding-bottom: 12; - font-size: 20; - font-weight: bold; - color: $gray-dark; -} - -.infoTitleDark { - color: $white; -} - -.infoDescription { - padding-bottom: 24; - font-size: 16; - color: $gray-darken-20; -} - -.infoDescriptionDark { - color: $gray-20; -} - .unsupportedBlock { height: 142; background-color: #e0e0e0; // $light-dim @@ -136,11 +77,3 @@ .unsupportedBlockSubtitleDark { color: $gray-20; } - -.actionButton { - color: $blue-50; -} - -.actionButtonDark { - color: $blue-30; -} diff --git a/packages/block-library/src/missing/test/edit-integration.native.js b/packages/block-library/src/missing/test/edit-integration.native.js index 4e1a4779572f2f..04b0bce688c3d2 100644 --- a/packages/block-library/src/missing/test/edit-integration.native.js +++ b/packages/block-library/src/missing/test/edit-integration.native.js @@ -1,81 +1,167 @@ /** * External dependencies */ -import { initializeEditor, fireEvent, within } from 'test/helpers'; +import { + fireEvent, + getBlock, + initializeEditor, + screen, + setupCoreBlocks, + withFakeTimers, + within, +} from 'test/helpers'; +import { Platform } from 'react-native'; /** * WordPress dependencies */ -import { getBlockTypes, unregisterBlockType } from '@wordpress/blocks'; +import { unregisterBlockType } from '@wordpress/blocks'; import { setLocaleData } from '@wordpress/i18n'; +import { requestUnsupportedBlockFallback } from '@wordpress/react-native-bridge'; + +// Override modal mock to prevent unmounting it when is not visible. +// This is required to be able to trigger onClose and onDismiss events when +// the modal is dismissed. +jest.mock( 'react-native-modal', () => { + const mockComponent = require( 'react-native/jest/mockComponent' ); + return mockComponent( 'react-native-modal' ); +} ); -/** - * Internal dependencies - */ -import { registerCoreBlocks } from '../..'; +const TABLE_BLOCK_HTML = ` +
12
34
+`; +const MODAL_DISMISS_EVENT = Platform.OS === 'ios' ? 'onDismiss' : 'onModalHide'; -beforeAll( () => { - // Mock translations. - setLocaleData( { - 'block title\u0004Table': [ 'Tabla' ], - "'%s' is not fully-supported": [ '«%s» no es totalmente compatible' ], - } ); +setupCoreBlocks(); - // Register all core blocks. - registerCoreBlocks(); +beforeAll( () => { + // For the purpose of this test suite we consider Reusable blocks/Patterns as unsupported. + // For this reason we unregister it to force it to be rendered as an unsupported block. + unregisterBlockType( 'core/block' ); } ); -afterAll( () => { - // Clean up translations. - setLocaleData( {} ); +describe( 'Unsupported block', () => { + describe( 'localized elements', () => { + beforeEach( () => { + // Mock translations. + setLocaleData( { + 'block title\u0004Table': [ 'Tabla' ], + "'%s' is not fully-supported": [ + '«%s» no es totalmente compatible', + ], + } ); + } ); + + afterEach( () => { + // Clean up translations. + setLocaleData( {} ); + } ); + + it( 'requests translated block title in block placeholder', async () => { + await initializeEditor( { + initialHtml: TABLE_BLOCK_HTML, + } ); + + const missingBlock = getBlock( screen, 'Unsupported' ); + + const translatedTableTitle = + within( missingBlock ).getByText( 'Tabla' ); + + expect( translatedTableTitle ).toBeDefined(); + } ); + + it( 'requests translated block title in bottom sheet', async () => { + await initializeEditor( { + initialHtml: TABLE_BLOCK_HTML, + } ); + + const missingBlock = getBlock( screen, 'Unsupported' ); + + fireEvent.press( missingBlock ); - // Clean up registered blocks. - getBlockTypes().forEach( ( block ) => { - unregisterBlockType( block.name ); + const [ helpButton ] = + await screen.findAllByLabelText( 'Help button' ); + + fireEvent.press( helpButton ); + + const bottomSheetTitle = await screen.findByText( + '«Tabla» no es totalmente compatible' + ); + + expect( bottomSheetTitle ).toBeDefined(); + } ); } ); -} ); -describe( 'Unsupported block', () => { - it( 'requests translated block title in block placeholder', async () => { - const initialHtml = ` -
12
34
- `; - const screen = await initializeEditor( { - initialHtml, + it( 'requests web editor when UBE is available', async () => { + await initializeEditor( { + initialHtml: TABLE_BLOCK_HTML, + capabilities: { + unsupportedBlockEditor: true, + canEnableUnsupportedBlockEditor: true, + }, } ); - const [ missingBlock ] = await screen.findAllByLabelText( - /Unsupported Block\. Row 1/ - ); + const missingBlock = getBlock( screen, 'Unsupported' ); + fireEvent.press( missingBlock ); + + // Tap the block to open the unsupported block details + fireEvent.press( within( missingBlock ).getByText( 'Unsupported' ) ); - const translatedTableTitle = - within( missingBlock ).getByText( 'Tabla' ); + const actionButton = screen.getByText( 'Edit using web editor' ); + expect( actionButton ).toBeVisible(); - expect( translatedTableTitle ).toBeDefined(); + // UBE is requested after the modal hides and running a timeout + await withFakeTimers( async () => { + fireEvent.press( actionButton ); + fireEvent( + screen.getByTestId( 'bottom-sheet' ), + MODAL_DISMISS_EVENT + ); + jest.runOnlyPendingTimers(); + } ); + expect( requestUnsupportedBlockFallback ).toHaveBeenCalled(); } ); - it( 'requests translated block title in bottom sheet', async () => { - const initialHtml = ` -
12
34
- `; - const screen = await initializeEditor( { - initialHtml, + it( 'does not show web editor option when UBE is not available', async () => { + await initializeEditor( { + initialHtml: TABLE_BLOCK_HTML, + capabilities: { + unsupportedBlockEditor: false, + canEnableUnsupportedBlockEditor: false, + }, } ); - const [ missingBlock ] = await screen.findAllByLabelText( - /Unsupported Block\. Row 1/ + const missingBlock = getBlock( screen, 'Unsupported' ); + fireEvent.press( missingBlock ); + + // Tap the block to open the unsupported block details + fireEvent.press( within( missingBlock ).getByText( 'Unsupported' ) ); + + const actionButton = await screen.queryByText( + 'Edit using web editor' ); + expect( actionButton ).toBeNull(); + } ); - fireEvent.press( missingBlock ); + it( 'does not show web editor option when block is incompatible with UBE', async () => { + await initializeEditor( { + // Reusable blocks/Patterns is a block type unsupported by UBE + initialHtml: '', + capabilities: { + unsupportedBlockEditor: true, + canEnableUnsupportedBlockEditor: true, + }, + } ); - const [ helpButton ] = await screen.findAllByLabelText( 'Help button' ); + const missingBlock = getBlock( screen, 'Unsupported' ); + fireEvent.press( missingBlock ); - fireEvent.press( helpButton ); + // Tap the block to open the unsupported block details + fireEvent.press( within( missingBlock ).getByText( 'Unsupported' ) ); - const bottomSheetTitle = await screen.findByText( - '«Tabla» no es totalmente compatible' + const actionButton = await screen.queryByText( + 'Edit using web editor' ); - - expect( bottomSheetTitle ).toBeDefined(); + expect( actionButton ).toBeNull(); } ); } ); diff --git a/packages/block-library/src/missing/test/edit.native.js b/packages/block-library/src/missing/test/edit.native.js index 5905a98cd88906..47d0da572b7c88 100644 --- a/packages/block-library/src/missing/test/edit.native.js +++ b/packages/block-library/src/missing/test/edit.native.js @@ -63,47 +63,6 @@ describe( 'Missing block', () => { "' is not fully-supported" ); } ); - - describe( 'Unsupported block editor (UBE)', () => { - beforeEach( () => { - // By default we set the web editor as available. - storeConfig.selectors.getSettings.mockReturnValue( { - capabilities: { unsupportedBlockEditor: true }, - } ); - } ); - - it( 'renders edit action if UBE is available', () => { - const testInstance = getTestComponentWithContent(); - const bottomSheet = - testInstance.UNSAFE_getByType( BottomSheet ); - const bottomSheetCells = bottomSheet.props.children[ 1 ]; - expect( bottomSheetCells ).toBeTruthy(); - expect( bottomSheetCells.props.children.length ).toBe( 2 ); - expect( bottomSheetCells.props.children[ 0 ].props.label ).toBe( - 'Edit using web editor' - ); - } ); - - it( 'does not render edit action if UBE is not available', () => { - storeConfig.selectors.getSettings.mockReturnValue( { - capabilities: { unsupportedBlockEditor: false }, - } ); - - const testInstance = getTestComponentWithContent(); - const bottomSheet = - testInstance.UNSAFE_getByType( BottomSheet ); - expect( bottomSheet.props.children[ 1 ] ).toBeFalsy(); - } ); - - it( 'does not render edit action if the block is incompatible with UBE', () => { - const testInstance = getTestComponentWithContent( { - originalName: 'core/block', - } ); - const bottomSheet = - testInstance.UNSAFE_getByType( BottomSheet ); - expect( bottomSheet.props.children[ 1 ] ).toBeFalsy(); - } ); - } ); } ); it( 'renders admin plugins icon', () => { diff --git a/test/native/__mocks__/styleMock.js b/test/native/__mocks__/styleMock.js index 8b682ef005e496..55942d977ad00e 100644 --- a/test/native/__mocks__/styleMock.js +++ b/test/native/__mocks__/styleMock.js @@ -208,4 +208,7 @@ module.exports = { marginLeft: 16, minWidth: 32, }, + 'unsupported-block-details__icon': { + color: 'gray', + }, }; diff --git a/test/native/setup.js b/test/native/setup.js index 60b42dc25d2fe1..00fb95070d84d7 100644 --- a/test/native/setup.js +++ b/test/native/setup.js @@ -129,6 +129,10 @@ jest.mock( '@wordpress/react-native-bridge', () => { generateHapticFeedback: jest.fn(), toggleUndoButton: jest.fn(), toggleRedoButton: jest.fn(), + sendActionButtonPressedAction: jest.fn(), + actionButtons: { + missingBlockAlertActionButton: 'missing_block_alert_action_button', + }, }; } ); From 0512fca7238362a8e9ea10c6593df70227cd7535 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9?= <583546+oandregal@users.noreply.github.com> Date: Mon, 9 Oct 2023 16:34:57 +0200 Subject: [PATCH 013/239] Update status entity label to `Status` and plural to `getStatuses` (#55160) --- packages/core-data/src/entities.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core-data/src/entities.js b/packages/core-data/src/entities.js index 3fb3af96eb9ad0..1c952af4a05a86 100644 --- a/packages/core-data/src/entities.js +++ b/packages/core-data/src/entities.js @@ -224,12 +224,12 @@ export const rootEntitiesConfig = [ key: 'plugin', }, { - label: __( 'Post status' ), + label: __( 'Status' ), name: 'status', kind: 'root', baseURL: '/wp/v2/statuses', baseURLParams: { context: 'edit' }, - plural: 'postStatuses', + plural: 'statuses', key: 'slug', }, ]; From 4ce163686d80f51cf97ccfc9b5a1b2873d8c9e11 Mon Sep 17 00:00:00 2001 From: Aki Hamano <54422211+t-hamano@users.noreply.github.com> Date: Mon, 9 Oct 2023 23:39:33 +0900 Subject: [PATCH 014/239] LinkControl: Prevent horizontally long preview image from being stretched vertically (#55156) --- .../block-editor/src/components/link-control/style.scss | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/block-editor/src/components/link-control/style.scss b/packages/block-editor/src/components/link-control/style.scss index b97745b72c4e2f..8883af42ee2ca6 100644 --- a/packages/block-editor/src/components/link-control/style.scss +++ b/packages/block-editor/src/components/link-control/style.scss @@ -297,9 +297,9 @@ $preview-image-height: 140px; img { display: block; // remove unwanted space below image - max-width: 100%; - height: $preview-image-height; // limit height - max-height: $preview-image-height; // limit height + width: 100%; + height: 100%; + object-fit: contain; } } } From eb77e48d27d16f6f82925fe4d06a76b62f33f572 Mon Sep 17 00:00:00 2001 From: Ella <4710635+ellatrix@users.noreply.github.com> Date: Mon, 9 Oct 2023 20:27:09 +0300 Subject: [PATCH 015/239] Writing flow: preserve block when merging into empty paragraph (#55134) --- .../src/components/block-list/block.js | 2 ++ .../src/components/rich-text/use-delete.js | 2 +- packages/block-editor/src/store/actions.js | 33 ++++++++++++++----- 3 files changed, 28 insertions(+), 9 deletions(-) diff --git a/packages/block-editor/src/components/block-list/block.js b/packages/block-editor/src/components/block-list/block.js index 4bf9bb634dbf6f..a95075c6f9b42c 100644 --- a/packages/block-editor/src/components/block-list/block.js +++ b/packages/block-editor/src/components/block-list/block.js @@ -494,6 +494,8 @@ const applyWithDispatch = withDispatch( ( dispatch, ownProps, registry ) => { } moveFirstItemUp( rootClientId ); + } else { + removeBlock( clientId ); } } }, diff --git a/packages/block-editor/src/components/rich-text/use-delete.js b/packages/block-editor/src/components/rich-text/use-delete.js index f09a15265bd2be..fbf025a5d4ea4d 100644 --- a/packages/block-editor/src/components/rich-text/use-delete.js +++ b/packages/block-editor/src/components/rich-text/use-delete.js @@ -43,7 +43,7 @@ export function useDelete( props ) { // an intentional user interaction distinguishing between Backspace and // Delete to remove the empty field, but also to avoid merge & remove // causing destruction of two fields (merge, then removed merged). - if ( onRemove && isEmpty( value ) && isReverse ) { + else if ( onRemove && isEmpty( value ) && isReverse ) { onRemove( ! isReverse ); } diff --git a/packages/block-editor/src/store/actions.js b/packages/block-editor/src/store/actions.js index ae4b64a645d3ed..2975a41dbb9d99 100644 --- a/packages/block-editor/src/store/actions.js +++ b/packages/block-editor/src/store/actions.js @@ -13,6 +13,7 @@ import { switchToBlockType, synchronizeBlocksWithTemplate, getBlockSupport, + isUnmodifiedDefaultBlock, } from '@wordpress/blocks'; import { speak } from '@wordpress/a11y'; import { __, _n, sprintf } from '@wordpress/i18n'; @@ -1013,17 +1014,12 @@ export const mergeBlocks = if ( ! blockAType ) return; + const blockB = select.getBlock( clientIdB ); + if ( ! blockAType.merge && - ! getBlockSupport( blockA.name, '__experimentalOnMerge' ) + getBlockSupport( blockA.name, '__experimentalOnMerge' ) ) { - dispatch.selectBlock( blockA.clientId ); - return; - } - - const blockB = select.getBlock( clientIdB ); - - if ( ! blockAType.merge ) { // If there's no merge function defined, attempt merging inner // blocks. const blocksWithTheSameType = switchToBlockType( @@ -1090,6 +1086,27 @@ export const mergeBlocks = return; } + if ( isUnmodifiedDefaultBlock( blockA ) ) { + dispatch.removeBlock( + clientIdA, + select.isBlockSelected( clientIdA ) + ); + return; + } + + if ( isUnmodifiedDefaultBlock( blockB ) ) { + dispatch.removeBlock( + clientIdB, + select.isBlockSelected( clientIdB ) + ); + return; + } + + if ( ! blockAType.merge ) { + dispatch.selectBlock( blockA.clientId ); + return; + } + const blockBType = getBlockType( blockB.name ); const { clientId, attributeKey, offset } = select.getSelectionStart(); const selectedBlockType = From 5939b4de1dd9085bd0137db2ab2b6a747e7b559e Mon Sep 17 00:00:00 2001 From: tomoki shimomura Date: Tue, 10 Oct 2023 02:50:22 +0900 Subject: [PATCH 016/239] Make No color selected translatable text (#54814) --- packages/components/src/color-palette/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/components/src/color-palette/index.tsx b/packages/components/src/color-palette/index.tsx index 86f52305587c06..f87d46e64bc093 100644 --- a/packages/components/src/color-palette/index.tsx +++ b/packages/components/src/color-palette/index.tsx @@ -307,7 +307,7 @@ function UnforwardedColorPalette( { value ? buttonLabelName - : 'No color selected' } + : __( 'No color selected' ) } { /* This `Truncate` is always rendered, even if From 75835acf8cc5b45fc9ca68d0e2e350a584b07202 Mon Sep 17 00:00:00 2001 From: Maggie Date: Mon, 9 Oct 2023 20:47:18 +0200 Subject: [PATCH 017/239] Migrate remaining Link UI tests to Playwright (#52828) * migrate test: will use Post title as link text if link to existing post is created without any text selected * removed old test * migrated test: can be created by selecting text and clicking Link * removed old test * migrated test: will not automatically create a link if selected text is not a valid HTTP based URL * updated snapshots and migrated test: can be created without any text selected * migrated test: can be created instantly when a URL is selected * migrated test: is not created when we click away from the link input * migrated test: can be edited * migrated test: can be removed * test migrated: allows Left to be pressed during creation when the toolbar is fixed to top * test migrated: allows Left to be pressed during creation in Docked toolbar mode * migrate test: can be edited with collapsed selection * migrated test: allows use of escape key to dismiss the url popover * migrated test: can be modified using the keyboard once a link has been set * removed comment * improved selector * migrated test: adds an assertive message for screenreader users when an invalid link is set * migrated test: should not display text input when initially creating the link * migrated test: should display text input when the link has a valid URL value * migrated test: should preserve trailing/leading whitespace from linked text in text input * migrated test: should allow for modification of link text via Link UI * migrated test: capture the text from the currently active link even if there is a rich text selection * migrated test: should not show the Link UI when selection extends beyond link boundary * migrated test: should not show the Link UI when selection extends into another link * migrated test: should correctly replace targetted links text within rich text value when multiple matching values exist * Apply re-wording suggestions from Code Review * Fix broken test due to upsteam changes * Create test post using REST * Prefer concretions in tests * Use requestUtils to create post * Improve test resilience by using more precise selector * Further improvements to test resilience by using more precise selectors * Provide context to usage of `getByPlaceholder` * Update other uses of getByPlaceholder * Remove redundant comment * Simplify test to essentials and provide context * Add util to cover inaccessible selection of Link Popover * Improve arrow key under link test * Improve test desc * Abstract direct usage of CSS locator in anticipation of refactor * Make sure test is testing what is says it will * Use standard helper * Shuffle test order * Improve test resilience by avoiding reliance on tab stops * Use standard utils * Replace custom query with standard util * Make it clear which button is being targetted * Improve test comprehension by using more than simply a period * Increase test comprehensibility but improving assertions * Avoid use of keyboard where not necessary * Apply nits from code review Co-authored-by: Ben Dwyer * Improve comment --------- Co-authored-by: Dave Smith Co-authored-by: Ben Dwyer --- .../various/__snapshots__/links.test.js.snap | 49 - .../specs/editor/various/links.test.js | 916 --------------- test/e2e/specs/editor/blocks/links.spec.js | 1001 ++++++++++++++++- 3 files changed, 979 insertions(+), 987 deletions(-) delete mode 100644 packages/e2e-tests/specs/editor/various/__snapshots__/links.test.js.snap delete mode 100644 packages/e2e-tests/specs/editor/various/links.test.js diff --git a/packages/e2e-tests/specs/editor/various/__snapshots__/links.test.js.snap b/packages/e2e-tests/specs/editor/various/__snapshots__/links.test.js.snap deleted file mode 100644 index 541a5456fd4d53..00000000000000 --- a/packages/e2e-tests/specs/editor/various/__snapshots__/links.test.js.snap +++ /dev/null @@ -1,49 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Links allows use of escape key to dismiss the url popover 1`] = ` -" -

This is Gutenberg.

-" -`; - -exports[`Links can be created by selecting text and clicking Link 1`] = ` -" -

This is Gutenberg

-" -`; - -exports[`Links can be created instantly when a URL is selected 1`] = ` -" -

This is Gutenberg: https://wordpress.org/gutenberg

-" -`; - -exports[`Links can be created without any text selected 1`] = ` -" -

This is Gutenberg: https://wordpress.org/gutenberg

-" -`; - -exports[`Links can be edited 1`] = ` -" -

This is Gutenberg

-" -`; - -exports[`Links can be edited with collapsed selection 1`] = ` -" -

This is Gutenberg

-" -`; - -exports[`Links can be modified using the keyboard once a link has been set 1`] = ` -" -

This is Gutenberg.

-" -`; - -exports[`Links can be removed 1`] = ` -" -

This is Gutenberg

-" -`; diff --git a/packages/e2e-tests/specs/editor/various/links.test.js b/packages/e2e-tests/specs/editor/various/links.test.js deleted file mode 100644 index 719d00afe076bb..00000000000000 --- a/packages/e2e-tests/specs/editor/various/links.test.js +++ /dev/null @@ -1,916 +0,0 @@ -/** - * WordPress dependencies - */ -import { - clickBlockAppender, - clickBlockToolbarButton, - getEditedPostContent, - createNewPost, - pressKeyWithModifier, - showBlockToolbar, - pressKeyTimes, - canvas, -} from '@wordpress/e2e-test-utils'; - -describe( 'Links', () => { - beforeEach( async () => { - await createNewPost(); - } ); - - const waitForURLFieldAutoFocus = async () => { - await page.waitForFunction( () => { - const input = document.querySelector( - '.block-editor-url-input__input' - ); - if ( input ) { - input.focus(); - return true; - } - return false; - } ); - }; - - it( 'will use Post title as link text if link to existing post is created without any text selected', async () => { - const titleText = 'Post to create a link to'; - await createPostWithTitle( titleText ); - - await createNewPost(); - await clickBlockAppender(); - - // Now in a new post and try to create a link from an autocomplete suggestion using the keyboard. - await page.keyboard.type( 'Here comes a link: ' ); - - // Press Cmd+K to insert a link. - await pressKeyWithModifier( 'primary', 'K' ); - - // Wait for the URL field to auto-focus. - await waitForURLFieldAutoFocus(); - expect( - await page.$( - '.components-popover__content .block-editor-link-control' - ) - ).not.toBeNull(); - - // Trigger the autocomplete suggestion list and select the first suggestion. - await page.keyboard.type( titleText.substr( 0, titleText.length - 2 ) ); - await page.waitForSelector( '.block-editor-link-control__search-item' ); - await page.keyboard.press( 'ArrowDown' ); - - await page.keyboard.press( 'Enter' ); - - const actualText = await canvas().evaluate( - () => - document.querySelector( '.block-editor-rich-text__editable a' ) - .textContent - ); - expect( actualText ).toBe( titleText ); - } ); - - it( 'can be created by selecting text and clicking Link', async () => { - // Create a block with some text. - await clickBlockAppender(); - await page.keyboard.type( 'This is Gutenberg' ); - - // Select some text. - await pressKeyWithModifier( 'shiftAlt', 'ArrowLeft' ); - - // Click on the Link button. - await page.click( 'button[aria-label="Link"]' ); - - // Wait for the URL field to auto-focus. - await waitForURLFieldAutoFocus(); - - // Type a URL. - await page.keyboard.type( 'https://wordpress.org/gutenberg' ); - - // Submit the link. - await page.keyboard.press( 'Enter' ); - - // The link should have been inserted. - expect( await getEditedPostContent() ).toMatchSnapshot(); - } ); - - it( 'will not automatically create a link if selected text is not a valid HTTP based URL', async () => { - // Create a block with some text. - await clickBlockAppender(); - await page.keyboard.type( 'This: is not a link' ); - - // Select some text. - await pressKeyWithModifier( 'shiftAlt', 'ArrowLeft' ); - - // Click on the Link button. - await page.click( 'button[aria-label="Link"]' ); - - // Wait for the URL field to auto-focus. - await waitForURLFieldAutoFocus(); - - const urlInputValue = await page.evaluate( - () => - document.querySelector( '.block-editor-url-input__input' ).value - ); - - expect( urlInputValue ).toBe( '' ); - } ); - - it( 'can be created without any text selected', async () => { - // Create a block with some text. - await clickBlockAppender(); - await page.keyboard.type( 'This is Gutenberg: ' ); - - // Press Cmd+K to insert a link. - await pressKeyWithModifier( 'primary', 'K' ); - - // Wait for the URL field to auto-focus. - await waitForURLFieldAutoFocus(); - - // Type a URL. - await page.keyboard.type( 'https://wordpress.org/gutenberg' ); - - // Press Enter to apply the link. - await page.keyboard.press( 'Enter' ); - - // A link with the URL as its text should have been inserted. - expect( await getEditedPostContent() ).toMatchSnapshot(); - } ); - - it( 'can be created instantly when a URL is selected', async () => { - // Create a block with some text. - await clickBlockAppender(); - await page.keyboard.type( - 'This is Gutenberg: https://wordpress.org/gutenberg' - ); - - // Select the URL. - await pressKeyWithModifier( 'shiftAlt', 'ArrowLeft' ); - await pressKeyWithModifier( 'shiftAlt', 'ArrowLeft' ); - await pressKeyWithModifier( 'shiftAlt', 'ArrowLeft' ); - await pressKeyWithModifier( 'shiftAlt', 'ArrowLeft' ); - await pressKeyWithModifier( 'shiftAlt', 'ArrowLeft' ); - await pressKeyWithModifier( 'shiftAlt', 'ArrowLeft' ); - await pressKeyWithModifier( 'shiftAlt', 'ArrowLeft' ); - - // Click on the Link button. - await page.click( 'button[aria-label="Link"]' ); - - // A link with the selected URL as its href should have been inserted. - expect( await getEditedPostContent() ).toMatchSnapshot(); - } ); - - it( 'is not created when we click away from the link input', async () => { - // Create a block with some text. - await clickBlockAppender(); - await page.keyboard.type( 'This is Gutenberg' ); - - // Select some text. - await pressKeyWithModifier( 'shiftAlt', 'ArrowLeft' ); - - // Click on the Link button. - await page.click( 'button[aria-label="Link"]' ); - - // Wait for the URL field to auto-focus. - await waitForURLFieldAutoFocus(); - - // Type a URL. - await page.keyboard.type( 'https://wordpress.org/gutenberg' ); - - // Click somewhere else - it doesn't really matter where. - await canvas().click( '.editor-post-title' ); - } ); - - const createAndReselectLink = async () => { - // Create a block with some text. - await clickBlockAppender(); - await page.keyboard.type( 'This is Gutenberg' ); - - // Select some text. - await pressKeyWithModifier( 'shiftAlt', 'ArrowLeft' ); - - // Click on the Link button. - await page.click( 'button[aria-label="Link"]' ); - - // Wait for the URL field to auto-focus. - await waitForURLFieldAutoFocus(); - - // Type a URL. - await page.keyboard.type( 'https://wordpress.org/gutenberg' ); - - // Click on the Submit button. - await page.keyboard.press( 'Enter' ); - - // Reselect the link. - await pressKeyWithModifier( 'shiftAlt', 'ArrowLeft' ); - }; - - it( 'can be edited', async () => { - await createAndReselectLink(); - - // Click on the Edit button. - const [ editButton ] = await page.$x( - '//button[contains(@aria-label, "Edit")]' - ); - await editButton.click(); - - // Wait for the URL field to auto-focus. - await waitForURLFieldAutoFocus(); - - // Change the URL. - await page.keyboard.type( '/handbook' ); - - // Submit the link. - await page.keyboard.press( 'Enter' ); - - // The link should have been updated. - expect( await getEditedPostContent() ).toMatchSnapshot(); - } ); - - it( 'can be removed', async () => { - await createAndReselectLink(); - - // Click on the Unlink button - // await page.click( 'button[aria-label="Unlink"]' ); - - // Unlick via shortcut - // we do this to avoid an layout edge case whereby - // the rich link preview popover will obscure the block toolbar - // under very specific circumstances and screensizes. - await pressKeyWithModifier( 'primaryShift', 'K' ); - - // The link should have been removed. - expect( await getEditedPostContent() ).toMatchSnapshot(); - } ); - - const toggleFixedToolbar = async ( isFixed ) => { - await page.evaluate( ( _isFixed ) => { - const { select, dispatch } = wp.data; - const isCurrentlyFixed = - select( 'core/edit-post' ).isFeatureActive( 'fixedToolbar' ); - if ( isCurrentlyFixed !== _isFixed ) { - dispatch( 'core/edit-post' ).toggleFeature( 'fixedToolbar' ); - } - }, isFixed ); - }; - - it( 'allows Left to be pressed during creation when the toolbar is fixed to top', async () => { - await toggleFixedToolbar( true ); - - await clickBlockAppender(); - await page.keyboard.type( 'Text' ); - await page.click( 'button[aria-label="Link"]' ); - - // Typing "left" should not close the dialog. - await page.keyboard.press( 'ArrowLeft' ); - let popover = await page.$( - '.components-popover__content .block-editor-link-control' - ); - expect( popover ).not.toBeNull(); - - // Escape should close the dialog still. - await page.keyboard.press( 'Escape' ); - popover = await page.$( - '.components-popover__content .block-editor-link-control' - ); - expect( popover ).toBeNull(); - } ); - - it( 'allows Left to be pressed during creation in "Docked Toolbar" mode', async () => { - await toggleFixedToolbar( false ); - - await clickBlockAppender(); - await page.keyboard.type( 'Text' ); - - await clickBlockToolbarButton( 'Link' ); - - // Typing "left" should not close the dialog. - await page.keyboard.press( 'ArrowLeft' ); - let popover = await page.$( - '.components-popover__content .block-editor-link-control' - ); - expect( popover ).not.toBeNull(); - - // Escape should close the dialog still. - await page.keyboard.press( 'Escape' ); - popover = await page.$( - '.components-popover__content .block-editor-link-control' - ); - expect( popover ).toBeNull(); - } ); - - it( 'can be edited with collapsed selection', async () => { - await createAndReselectLink(); - // Make a collapsed selection inside the link - await page.keyboard.press( 'ArrowLeft' ); - await page.keyboard.press( 'ArrowRight' ); - await showBlockToolbar(); - const [ editButton ] = await page.$x( - '//button[contains(@aria-label, "Edit")]' - ); - await editButton.click(); - await waitForURLFieldAutoFocus(); - await page.keyboard.type( '/handbook' ); - await page.keyboard.press( 'Enter' ); - expect( await getEditedPostContent() ).toMatchSnapshot(); - } ); - - const createPostWithTitle = async ( titleText ) => { - await createNewPost(); - await canvas().type( '.editor-post-title__input', titleText ); - await page.click( '.editor-post-publish-panel__toggle' ); - - // Disable reason: Wait for the animation to complete, since otherwise the - // click attempt may occur at the wrong point. - // eslint-disable-next-line no-restricted-syntax - await page.waitForTimeout( 100 ); - - // Publish the post. - await page.click( '.editor-post-publish-button' ); - - // Return the URL of the new post. - await page.waitForSelector( - '.post-publish-panel__postpublish-post-address input' - ); - return page.evaluate( - () => - document.querySelector( - '.post-publish-panel__postpublish-post-address input' - ).value - ); - }; - - it( 'allows use of escape key to dismiss the url popover', async () => { - const titleText = 'Test post escape'; - await createPostWithTitle( titleText ); - - await createNewPost(); - await clickBlockAppender(); - - // Now in a new post and try to create a link from an autocomplete suggestion using the keyboard. - await page.keyboard.type( 'This is Gutenberg' ); - await pressKeyWithModifier( 'shiftAlt', 'ArrowLeft' ); - - // Press Cmd+K to insert a link. - await pressKeyWithModifier( 'primary', 'K' ); - - // Wait for the URL field to auto-focus. - await waitForURLFieldAutoFocus(); - expect( - await page.$( - '.components-popover__content .block-editor-link-control' - ) - ).not.toBeNull(); - - // Trigger the autocomplete suggestion list and select the first suggestion. - await page.keyboard.type( titleText ); - await page.waitForSelector( '.block-editor-link-control__search-item' ); - await page.keyboard.press( 'ArrowDown' ); - - // Expect the escape key to dismiss the popover when the autocomplete suggestion list is open. - await page.keyboard.press( 'Escape' ); - expect( - await page.$( - '.components-popover__content .block-editor-link-control' - ) - ).toBeNull(); - - // Confirm that selection is returned to where it was before launching - // the link editor, with "Gutenberg" as an uncollapsed selection. - await page.keyboard.press( 'ArrowRight' ); - await page.keyboard.type( '.' ); - expect( await getEditedPostContent() ).toMatchSnapshot(); - - // Press Cmd+K to insert a link. - await pressKeyWithModifier( 'primary', 'K' ); - - // Wait for the URL field to auto-focus. - await waitForURLFieldAutoFocus(); - expect( - await page.$( - '.components-popover__content .block-editor-link-control' - ) - ).not.toBeNull(); - - // Expect the escape key to dismiss the popover normally. - await page.keyboard.press( 'Escape' ); - expect( - await page.$( - '.components-popover__content .block-editor-link-control' - ) - ).toBeNull(); - - // Press Cmd+K to insert a link. - await pressKeyWithModifier( 'primary', 'K' ); - - // Wait for the URL field to auto-focus. - await waitForURLFieldAutoFocus(); - expect( - await page.$( - '.components-popover__content .block-editor-link-control' - ) - ).not.toBeNull(); - - // Tab to the "Open in new tab" toggle. - await page.keyboard.press( 'Tab' ); - await page.keyboard.press( 'Tab' ); - - // Expect the escape key to dismiss the popover normally. - await page.keyboard.press( 'Escape' ); - expect( - await page.$( - '.components-popover__content .block-editor-link-control' - ) - ).toBeNull(); - } ); - - it( 'can be modified using the keyboard once a link has been set', async () => { - const URL = 'https://wordpress.org/gutenberg'; - - // Create a block with some text and format it as a link. - await clickBlockAppender(); - await page.keyboard.type( 'This is Gutenberg' ); - await pressKeyWithModifier( 'shiftAlt', 'ArrowLeft' ); - await pressKeyWithModifier( 'primary', 'K' ); - await waitForURLFieldAutoFocus(); - await page.keyboard.type( URL ); - await page.keyboard.press( 'Enter' ); - - // Deselect the link text by moving the caret to the end of the line - // and the link popover should not be displayed. - await page.keyboard.press( 'End' ); - expect( - await page.$( - '.components-popover__content .block-editor-link-control' - ) - ).toBeNull(); - - // Move the caret back into the link text and the link popover - // should be displayed. - await page.keyboard.press( 'ArrowLeft' ); - expect( - await page.$( - '.components-popover__content .block-editor-link-control' - ) - ).not.toBeNull(); - - // Press Cmd+K to edit the link and the url-input should become - // focused with the value previously inserted. - await pressKeyWithModifier( 'primary', 'K' ); - await waitForURLFieldAutoFocus(); - const isInURLInput = await page.evaluate( - () => !! document.activeElement.closest( '.block-editor-url-input' ) - ); - expect( isInURLInput ).toBe( true ); - const activeElementValue = await page.evaluate( - () => document.activeElement.value - ); - expect( activeElementValue ).toBe( URL ); - - // Confirm that submitting the input without any changes keeps the same - // value and moves focus back to the paragraph. - await page.keyboard.press( 'Enter' ); - await page.keyboard.press( 'ArrowRight' ); - await page.keyboard.type( '.' ); - expect( await getEditedPostContent() ).toMatchSnapshot(); - } ); - - it( 'adds an assertive message for screenreader users when an invalid link is set', async () => { - await clickBlockAppender(); - await page.keyboard.type( 'This is Gutenberg' ); - await pressKeyWithModifier( 'shiftAlt', 'ArrowLeft' ); - await pressKeyWithModifier( 'primary', 'K' ); - await waitForURLFieldAutoFocus(); - await page.keyboard.type( 'http://#test.com' ); - await page.keyboard.press( 'Enter' ); - const assertiveContent = await page.evaluate( - () => document.querySelector( '#a11y-speak-assertive' ).textContent - ); - expect( assertiveContent.trim() ).toBe( - 'Warning: the link has been inserted but may have errors. Please test it.' - ); - } ); - - describe( 'Editing link text', () => { - it( 'should not display text input when initially creating the link', async () => { - // Create a block with some text. - await clickBlockAppender(); - await page.keyboard.type( 'This is Gutenberg: ' ); - - // Press Cmd+K to insert a link. - await pressKeyWithModifier( 'primary', 'K' ); - - const [ settingsToggle ] = await page.$x( - '//button[contains(text(), "Advanced")]' - ); - await settingsToggle.click(); - - const textInput = await page - .waitForXPath( - '//[contains(@class, "block-editor-link-control__search-input-wrapper")]//label[contains(text(), "Text")]', - { - timeout: 1000, - } - ) - .catch( () => false ); - - expect( textInput ).toBeFalsy(); - } ); - - it( 'should display text input when the link has a valid URL value', async () => { - await createAndReselectLink(); - - // Make a collapsed selection inside the link. This is used - // as a stress test to ensure we can find the link text from a - // collapsed RichTextValue that contains a link format. - await page.keyboard.press( 'ArrowLeft' ); - await page.keyboard.press( 'ArrowRight' ); - - const [ editButton ] = await page.$x( - '//button[contains(@aria-label, "Edit")]' - ); - await editButton.click(); - - await waitForURLFieldAutoFocus(); - - await pressKeyWithModifier( 'shift', 'Tab' ); - - // Tabbing should land us in the text input. - const { isTextInput, textValue } = await page.evaluate( () => { - const el = document.activeElement; - - return { - isTextInput: el.matches( 'input[type="text"]' ), - textValue: el.value, - }; - } ); - - // Let's check we've focused a text input. - expect( isTextInput ).toBe( true ); - - // Link was created on text value "Gutenberg". We expect - // the text input to reflect that value. - expect( textValue ).toBe( 'Gutenberg' ); - } ); - - it( 'should preserve trailing/leading whitespace from linked text in text input', async () => { - const textToSelect = ` spaces `; - const textWithWhitespace = `Text with leading and trailing${ textToSelect }`; - - // Create a block with some text. - await clickBlockAppender(); - await page.keyboard.type( textWithWhitespace ); - - // Use arrow keys to select only the text with the leading - // and trailing whitespace. - for ( let index = 0; index < textToSelect.length; index++ ) { - await pressKeyWithModifier( 'shift', 'ArrowLeft' ); - } - - // Click on the Link button. - await page.click( 'button[aria-label="Link"]' ); - - // Wait for the URL field to auto-focus. - await waitForURLFieldAutoFocus(); - - // Type a URL. - await page.keyboard.type( 'https://wordpress.org/gutenberg' ); - - // Click on the Submit button. - await page.keyboard.press( 'Enter' ); - - // Reselect the link. - await page.keyboard.press( 'ArrowLeft' ); - - await showBlockToolbar(); - - const [ editButton ] = await page.$x( - '//button[contains(@aria-label, "Edit")]' - ); - - await editButton.click(); - - await waitForURLFieldAutoFocus(); - - // Tabbing backward should land us in the "Text" input. - await pressKeyWithModifier( 'shift', 'Tab' ); - - const textInputValue = await page.evaluate( - () => document.activeElement.value - ); - - expect( textInputValue ).toBe( textToSelect ); - } ); - - it( 'should allow for modification of link text via Link UI', async () => { - const originalLinkText = 'Gutenberg'; - const changedLinkText = - ' link text that was modified via the Link UI to include spaces '; - - await createAndReselectLink(); - - // Make a collapsed selection inside the link. This is used - // as a stress test to ensure we can find the link text from a - // collapsed RichTextValue that contains a link format. - await page.keyboard.press( 'ArrowLeft' ); - await page.keyboard.press( 'ArrowRight' ); - - await showBlockToolbar(); - const [ editButton ] = await page.$x( - '//button[contains(@aria-label, "Edit")]' - ); - await editButton.click(); - - await waitForURLFieldAutoFocus(); - - await pressKeyWithModifier( 'shift', 'Tab' ); - - const textInputValue = await page.evaluate( - () => document.activeElement.value - ); - - // At this point, we still expect the text input - // to reflect the original value with no modifications. - expect( textInputValue ).toBe( originalLinkText ); - - // Select all the link text in the input. - await pressKeyWithModifier( 'primary', 'a' ); - - // Modify the link text value. - await page.keyboard.type( changedLinkText ); - - // Submit the change. - await page.keyboard.press( 'Enter' ); - - // Check the created link reflects the link text. - const actualLinkText = await canvas().evaluate( - () => - document.querySelector( - '.block-editor-rich-text__editable a' - ).textContent - ); - expect( actualLinkText ).toBe( changedLinkText ); - } ); - - it( 'should display (capture the) text from the currently active link even if there is a rich text selection', async () => { - const originalLinkText = 'Gutenberg'; - - await createAndReselectLink(); - - // Make a collapsed selection inside the link in order - // to activate the Link UI. - await page.keyboard.press( 'ArrowLeft' ); - await page.keyboard.press( 'ArrowRight' ); - - const [ editButton ] = await page.$x( - '//button[contains(@aria-label, "Edit")]' - ); - await editButton.click(); - await waitForURLFieldAutoFocus(); - - const [ settingsToggle ] = await page.$x( - '//button[contains(text(), "Advanced")]' - ); - await settingsToggle.click(); - - // Wait for settings to open. - await page.waitForXPath( `//label[text()='Open in new tab']` ); - - // Move focus back to RichText for the underlying link. - await pressKeyWithModifier( 'shift', 'Tab' ); - await pressKeyWithModifier( 'shift', 'Tab' ); - await pressKeyWithModifier( 'shift', 'Tab' ); - - // Make a selection within the RichText. - await pressKeyWithModifier( 'shift', 'ArrowRight' ); - await pressKeyWithModifier( 'shift', 'ArrowRight' ); - await pressKeyWithModifier( 'shift', 'ArrowRight' ); - - // Move back to the text input. - await pressKeyTimes( 'Tab', 1 ); - - // Tabbing back should land us in the text input. - const textInputValue = await page.evaluate( - () => document.activeElement.value - ); - - // Making a selection within the link text whilst the Link UI - // is open should not alter the value in the Link UI's text - // input. It should remain as the full text of the currently - // focused link format. - expect( textInputValue ).toBe( originalLinkText ); - } ); - } ); - - describe( 'Disabling Link UI active state', () => { - it( 'should not show the Link UI when selection extends beyond link boundary', async () => { - const linkedText = `Gutenberg`; - const textBeyondLinkedText = ` and more text.`; - - // Create a block with some text. - await clickBlockAppender(); - await page.keyboard.type( - `This is ${ linkedText }${ textBeyondLinkedText }` - ); - - // Move cursor next to end of `linkedText` - for ( - let index = 0; - index < textBeyondLinkedText.length; - index++ - ) { - await page.keyboard.press( 'ArrowLeft' ); - } - - // Select the linkedText. - await pressKeyWithModifier( 'shiftAlt', 'ArrowLeft' ); - - // Click on the Link button. - await page.click( 'button[aria-label="Link"]' ); - - // Wait for the URL field to auto-focus. - await waitForURLFieldAutoFocus(); - - // Type a URL. - await page.keyboard.type( 'https://wordpress.org/gutenberg' ); - - // Update the link. - await page.keyboard.press( 'Enter' ); - - await page.keyboard.press( 'ArrowLeft' ); - await page.keyboard.press( 'ArrowLeft' ); - - expect( - await page.$( - '.components-popover__content .block-editor-link-control' - ) - ).not.toBeNull(); - - // Make selection starting within the link and moving beyond boundary to the left. - for ( let index = 0; index < linkedText.length; index++ ) { - await pressKeyWithModifier( 'shift', 'ArrowLeft' ); - } - - // The Link UI should have disappeared (i.e. be inactive). - expect( - await page.$( - '.components-popover__content .block-editor-link-control' - ) - ).toBeNull(); - - // Cancel selection and move back within the Link. - await page.keyboard.press( 'ArrowRight' ); - - // We should see the Link UI displayed again. - expect( - await page.$( - '.components-popover__content .block-editor-link-control' - ) - ).not.toBeNull(); - - // Make selection starting within the link and moving beyond boundary to the right. - await pressKeyWithModifier( 'shift', 'ArrowRight' ); - await pressKeyWithModifier( 'shift', 'ArrowRight' ); - await pressKeyWithModifier( 'shift', 'ArrowRight' ); - - // The Link UI should have disappeared (i.e. be inactive). - expect( - await page.$( - '.components-popover__content .block-editor-link-control' - ) - ).toBeNull(); - } ); - - it( 'should not show the Link UI when selection extends into another link', async () => { - const linkedTextOne = `Gutenberg`; - const linkedTextTwo = `Block Editor`; - const linkOneURL = 'https://wordpress.org'; - const linkTwoURL = 'https://wordpress.org/gutenberg'; - - // Create a block with some text. - await clickBlockAppender(); - await page.keyboard.type( - `This is the ${ linkedTextOne }${ linkedTextTwo }` - ); - - // Select the linkedTextTwo. - for ( let index = 0; index < linkedTextTwo.length; index++ ) { - await pressKeyWithModifier( 'shift', 'ArrowLeft' ); - } - - // Click on the Link button. - await page.click( 'button[aria-label="Link"]' ); - - // Wait for the URL field to auto-focus. - await waitForURLFieldAutoFocus(); - - // Type a URL. - await page.keyboard.type( linkTwoURL ); - - // Update the link. - await page.keyboard.press( 'Enter' ); - - // Move cursor next to the **end** of `linkTextOne` - for ( let index = 0; index < linkedTextTwo.length + 2; index++ ) { - await page.keyboard.press( 'ArrowLeft' ); - } - - // Select `linkTextOne` - await pressKeyWithModifier( 'shiftAlt', 'ArrowLeft' ); - - // Click on the Link button. - await page.click( 'button[aria-label="Link"]' ); - - // Wait for the URL field to auto-focus. - await waitForURLFieldAutoFocus(); - - // Type a URL. - await page.keyboard.type( linkOneURL ); - - // Update the link. - await page.keyboard.press( 'Enter' ); - - // Move cursor within `linkTextOne` - for ( let index = 0; index < 3; index++ ) { - await page.keyboard.press( 'ArrowLeft' ); - } - - // Link UI should activate for `linkTextOne` - expect( - await page.$( - '.components-popover__content .block-editor-link-control' - ) - ).not.toBeNull(); - - // Expand selection so that it overlaps with `linkTextTwo` - for ( let index = 0; index < 3; index++ ) { - await pressKeyWithModifier( 'shift', 'ArrowRight' ); - } - - // Link UI should be inactive. - expect( - await page.$( - '.components-popover__content .block-editor-link-control' - ) - ).toBeNull(); - } ); - - // Based on issue reported in https://github.com/WordPress/gutenberg/issues/41771/. - it( 'should correctly replace targetted links text within rich text value when multiple matching values exist', async () => { - // Create a block with some text. - await clickBlockAppender(); - - // Note the two instances of the string "a". - await page.keyboard.type( `a b c a` ); - - // Select the last "a" only. - await pressKeyWithModifier( 'shift', 'ArrowLeft' ); - - // Click on the Link button. - await page.click( 'button[aria-label="Link"]' ); - - // Wait for the URL field to auto-focus. - await waitForURLFieldAutoFocus(); - - // Type a URL. - await page.keyboard.type( 'www.wordpress.org' ); - - // Update the link. - await page.keyboard.press( 'Enter' ); - - await page.keyboard.press( 'ArrowLeft' ); - - // Move to "Edit" button in Link UI - await page.keyboard.press( 'Tab' ); - await page.keyboard.press( 'Tab' ); - await page.keyboard.press( 'Enter' ); - - await waitForURLFieldAutoFocus(); - - // Move to "Text" field. - await pressKeyWithModifier( 'shift', 'Tab' ); - - // Delete existing value from "Text" field - await page.keyboard.press( 'Delete' ); - - // Change text to "z" - await page.keyboard.type( 'z' ); - - await page.keyboard.press( 'Enter' ); - - const richTextText = await canvas().evaluate( - () => - document.querySelector( - '.block-editor-rich-text__editable' - ).textContent - ); - // Check that the correct (i.e. last) instance of "a" was replaced with "z". - expect( richTextText ).toBe( 'a b c z' ); - - const richTextLink = await canvas().evaluate( - () => - document.querySelector( - '.block-editor-rich-text__editable a' - ).textContent - ); - // Check that the correct (i.e. last) instance of "a" was replaced with "z". - expect( richTextLink ).toBe( 'z' ); - } ); - } ); -} ); diff --git a/test/e2e/specs/editor/blocks/links.spec.js b/test/e2e/specs/editor/blocks/links.spec.js index 55d126314d1fc6..7e654ca12790f7 100644 --- a/test/e2e/specs/editor/blocks/links.spec.js +++ b/test/e2e/specs/editor/blocks/links.spec.js @@ -8,10 +8,525 @@ test.describe( 'Links', () => { await admin.createNewPost(); } ); + test.afterEach( async ( { requestUtils } ) => { + await requestUtils.deleteAllPosts(); + } ); + + test.use( { + LinkUtils: async ( { editor, page, pageUtils }, use ) => { + await use( new LinkUtils( { editor, page, pageUtils } ) ); + }, + } ); + + test( `will use Post title as link text if link to existing post is created without any text selected`, async ( { + admin, + page, + editor, + requestUtils, + } ) => { + const titleText = 'Post to create a link to'; + const { id: postId } = await requestUtils.createPost( { + title: titleText, + status: 'publish', + } ); + + await admin.createNewPost(); + + // Now in a new post and try to create a link from an autocomplete suggestion using the keyboard. + await editor.insertBlock( { + name: 'core/paragraph', + } ); + await page.keyboard.type( 'Here comes a link: ' ); + + // Insert a link deliberately not selecting any text. + await editor.clickBlockToolbarButton( 'Link' ); + + // Trigger the autocomplete suggestion list and select the first suggestion. + await page.keyboard.type( 'Post to create a' ); + await page.getByRole( 'option', { name: titleText } ).click(); + + await expect.poll( editor.getBlocks ).toMatchObject( [ + { + name: 'core/paragraph', + attributes: { + content: + 'Here comes a link: ' + + titleText + + '', + }, + }, + ] ); + } ); + + test( `can be created by selecting text and clicking link insertion button in block toolbar`, async ( { + page, + editor, + pageUtils, + } ) => { + // Create a block with some text. + await editor.insertBlock( { + name: 'core/paragraph', + } ); + await page.keyboard.type( 'This is Gutenberg' ); + + // Select some text. + await pageUtils.pressKeys( 'shiftAlt+ArrowLeft' ); + + // Click on the Link button in the Block Toolbar. + await editor.clickBlockToolbarButton( 'Link' ); + + // Type a URL. + await page.keyboard.type( 'https://wordpress.org/gutenberg' ); + + // Submit the link. + await pageUtils.pressKeys( 'Enter' ); + + // The link should have been inserted. + await expect.poll( editor.getBlocks ).toMatchObject( [ + { + name: 'core/paragraph', + attributes: { + content: + 'This is Gutenberg', + }, + }, + ] ); + } ); + + test( `will not automatically create a link if selected text is not a valid HTTP based URL`, async ( { + page, + editor, + pageUtils, + } ) => { + // Create a block with some text. + await editor.insertBlock( { + name: 'core/paragraph', + } ); + await page.keyboard.type( 'This: is not a link' ); + + // Select some text. + await pageUtils.pressKeys( 'shiftAlt+ArrowLeft' ); + + // Click on the Link button. + await editor.clickBlockToolbarButton( 'Link' ); + + await expect( + page.getByRole( 'combobox', { + name: 'Link', + } ) + ).toHaveValue( '' ); + } ); + + test( `can be created without any text selected`, async ( { + page, + editor, + pageUtils, + } ) => { + // Create a block with some text. + await editor.insertBlock( { + name: 'core/paragraph', + } ); + await page.keyboard.type( 'This is Gutenberg: ' ); + + // Press Cmd+K to insert a link. + await pageUtils.pressKeys( 'primary+K' ); + + // Type a URL. + await page.keyboard.type( 'https://wordpress.org/gutenberg' ); + + // Press Enter to apply the link. + await pageUtils.pressKeys( 'Enter' ); + + // A link with the URL as its text should have been inserted. + await expect.poll( editor.getBlocks ).toMatchObject( [ + { + name: 'core/paragraph', + attributes: { + content: + 'This is Gutenberg: https://wordpress.org/gutenberg', + }, + }, + ] ); + } ); + + test( `will automatically create a link if selected text is a valid HTTP based URL`, async ( { + page, + editor, + pageUtils, + } ) => { + // Create a block with some text. + await editor.insertBlock( { + name: 'core/paragraph', + } ); + await page.keyboard.type( + 'This is Gutenberg: https://wordpress.org/gutenberg' + ); + + // Select the URL. + await pageUtils.pressKeys( 'shiftAlt+ArrowLeft', { times: 7 } ); + + // Click on the Link button. + await editor.clickBlockToolbarButton( 'Link' ); + + // A link with the selected URL as its href should have been inserted. + await expect.poll( editor.getBlocks ).toMatchObject( [ + { + name: 'core/paragraph', + attributes: { + content: + 'This is Gutenberg: https://wordpress.org/gutenberg', + }, + }, + ] ); + } ); + + test( `does not create link when link ui is closed without submission`, async ( { + page, + editor, + pageUtils, + } ) => { + // Create a block with some text. + await editor.insertBlock( { + name: 'core/paragraph', + } ); + await page.keyboard.type( 'This is Gutenberg' ); + + // Select some text. + await pageUtils.pressKeys( 'shiftAlt+ArrowLeft' ); + + // Click on the Link button. + await editor.clickBlockToolbarButton( 'Link' ); + + // Type a URL. + await page.keyboard.type( 'https://wordpress.org/gutenberg' ); + + // Click somewhere else - it doesn't really matter where. + await editor.canvas + .getByRole( 'textbox', { name: 'Add title' } ) + .focus(); + + await expect.poll( editor.getBlocks ).toMatchObject( [ + { + name: 'core/paragraph', + attributes: { + content: 'This is Gutenberg', + }, + }, + ] ); + } ); + + test( `can edit existing links`, async ( { + page, + editor, + pageUtils, + LinkUtils, + } ) => { + await LinkUtils.createAndReselectLink(); + + // Click on the Edit button. + await page.getByRole( 'button', { name: 'Edit' } ).click(); + + // Change the URL. + // getByPlaceholder required in order to handle Link Control component + // managing focus onto other inputs within the control. + await page.getByPlaceholder( 'Search or type url' ).fill( '' ); + await page.keyboard.type( '/handbook' ); + + // Submit the link. + await pageUtils.pressKeys( 'Enter' ); + + // The link should have been updated. + await expect.poll( editor.getBlocks ).toMatchObject( [ + { + name: 'core/paragraph', + attributes: { + content: 'This is Gutenberg', + }, + }, + ] ); + } ); + + test( `can remove existing links`, async ( { editor, LinkUtils } ) => { + await LinkUtils.createAndReselectLink(); + + const linkPopover = LinkUtils.getLinkPopover(); + + await linkPopover.getByRole( 'button', { name: 'Unlink' } ).click(); + + // The link should have been removed. + await expect.poll( editor.getBlocks ).toMatchObject( [ + { + name: 'core/paragraph', + attributes: { + content: 'This is Gutenberg', + }, + }, + ] ); + } ); + + test( `allows arrow keys to be pressed during link creation when the toolbar is fixed to top`, async ( { + page, + editor, + pageUtils, + LinkUtils, + } ) => { + await LinkUtils.toggleFixedToolbar( true ); + + // Create a block with some text. + await editor.insertBlock( { + name: 'core/paragraph', + } ); + await page.keyboard.type( 'Text' ); + await editor.clickBlockToolbarButton( 'Link' ); + + const linkPopover = LinkUtils.getLinkPopover(); + await expect( linkPopover ).toBeVisible(); + + // Pressing "left" should not close the dialog. + await pageUtils.pressKeys( 'ArrowLeft' ); + await expect( linkPopover ).toBeVisible(); + + // Escape should close the dialog. + await page.keyboard.press( 'Escape' ); + + await expect( linkPopover ).toBeHidden(); + } ); + + test( `allows arrow keys to be pressed during link creation in "Docked Toolbar" mode`, async ( { + page, + editor, + pageUtils, + LinkUtils, + } ) => { + await LinkUtils.toggleFixedToolbar( false ); + + // Create a block with some text. + await editor.insertBlock( { + name: 'core/paragraph', + } ); + await page.keyboard.type( 'Text' ); + + await editor.clickBlockToolbarButton( 'Link' ); + + const linkPopover = LinkUtils.getLinkPopover(); + + await expect( linkPopover ).toBeVisible(); + + // Pressing arrow key should not close the dialog. + await pageUtils.pressKeys( 'ArrowLeft' ); + await expect( linkPopover ).toBeVisible(); + + // Escape should close the dialog still. + await page.keyboard.press( 'Escape' ); + + await expect( linkPopover ).toBeHidden(); + } ); + + test( `can be edited when within a link but no selection has been made ("collapsed")`, async ( { + page, + editor, + LinkUtils, + pageUtils, + } ) => { + await LinkUtils.createAndReselectLink(); + // Make a collapsed selection inside the link. + await pageUtils.pressKeys( 'ArrowLeft' ); + await pageUtils.pressKeys( 'ArrowRight' ); + + const linkPopover = LinkUtils.getLinkPopover(); + await linkPopover.getByRole( 'button', { name: 'Edit' } ).click(); + + // Change the URL. + // getByPlaceholder required in order to handle Link Control component + // managing focus onto other inputs within the control. + await page.getByPlaceholder( 'Search or type url' ).fill( '' ); + await page.keyboard.type( '/handbook' ); + + // Submit the link. + await pageUtils.pressKeys( 'Enter' ); + + await expect.poll( editor.getBlocks ).toMatchObject( [ + { + name: 'core/paragraph', + attributes: { + content: 'This is Gutenberg', + }, + }, + ] ); + } ); + + test( `escape dismisses the Link UI popover and returns focus`, async ( { + admin, + page, + editor, + pageUtils, + requestUtils, + LinkUtils, + } ) => { + const titleText = 'Test post escape'; + await requestUtils.createPost( { + title: titleText, + status: 'publish', + } ); + + await admin.createNewPost(); + + // Now in a new post and try to create a link from an autocomplete suggestion using the keyboard. + await editor.insertBlock( { + name: 'core/paragraph', + } ); + + await page.keyboard.type( 'This is Gutenberg' ); + await pageUtils.pressKeys( 'shiftAlt+ArrowLeft' ); + + // Insert a link. + await editor.clickBlockToolbarButton( 'Link' ); + + const urlInput = page.getByRole( 'combobox', { + name: 'Link', + } ); + + // Expect the "Link" combobox to be visible and focused + await expect( urlInput ).toBeVisible(); + await expect( urlInput ).toBeFocused(); + + // Trigger the autocomplete suggestion list. + await page.keyboard.type( titleText ); + await expect( + page.getByRole( 'option', { + // "post" disambiguates from the "Create page" option. + name: `${ titleText } post`, + } ) + ).toBeVisible(); + + // Move into the suggestions list. + await page.keyboard.press( 'ArrowDown' ); + + // Expect the escape key to dismiss the popover when the autocomplete suggestion list is open. + // Note that these have their own keybindings thus why we need to assert on this behaviour. + await page.keyboard.press( 'Escape' ); + await expect( LinkUtils.getLinkPopover() ).toBeHidden(); + + // Confirm that selection is returned to where it was before launching + // the link editor, with "Gutenberg" as an uncollapsed selection. + await page.keyboard.press( 'ArrowRight' ); + await page.keyboard.type( ' and more!' ); + + await expect.poll( editor.getBlocks ).toMatchObject( [ + { + name: 'core/paragraph', + attributes: { + content: 'This is Gutenberg and more!', + }, + }, + ] ); + } ); + + test( `can be created and modified using only the keyboard`, async ( { + page, + editor, + pageUtils, + LinkUtils, + } ) => { + const URL = 'https://wordpress.org/gutenberg'; + + // Create a block with some text and format it as a link. + await editor.insertBlock( { + name: 'core/paragraph', + } ); + await page.keyboard.type( 'This is Gutenberg' ); + await pageUtils.pressKeys( 'shiftAlt+ArrowLeft' ); + await pageUtils.pressKeys( 'primary+K' ); + await page.keyboard.type( URL ); + await pageUtils.pressKeys( 'Enter' ); + + const linkPopover = LinkUtils.getLinkPopover(); + + // Deselect the link text by moving the caret to the end of the line + // and the link popover should not be displayed. + await pageUtils.pressKeys( 'End' ); + await expect( linkPopover ).toBeHidden(); + + // Move the caret back into the link text and the link popover + // should be displayed. + await pageUtils.pressKeys( 'ArrowLeft' ); + await expect( linkPopover ).toBeVisible(); + + // Switch the Link UI into "Edit" mode via keyboard shortcut + // and check that the input has the correct value. + await pageUtils.pressKeys( 'primary+K' ); + + await expect( + linkPopover.getByRole( 'combobox', { + name: 'Link', + } ) + ).toHaveValue( URL ); + + // Confirm that submitting the input without any changes keeps the same + // value and moves focus back to the paragraph. + + // Submit without changes - should return to preview mode. + await pageUtils.pressKeys( 'Enter' ); + + // Move back into the RichText. + await pageUtils.pressKeys( 'Escape' ); + + // ...but the Link Popover should still be active because we are within the link. + await expect( linkPopover ).toBeVisible(); + + // Move outside of the link entirely. + await pageUtils.pressKeys( 'ArrowRight' ); + + // Link Popover should now disappear because we are no longer within the link. + await expect( linkPopover ).toBeHidden(); + + // Append some text to the paragraph to assert that focus has been returned + // to the correct location within the RichText. + await page.keyboard.type( ' and more!' ); + await expect.poll( editor.getBlocks ).toMatchObject( [ + { + name: 'core/paragraph', + attributes: { + content: + 'This is Gutenberg and more!', + }, + }, + ] ); + } ); + + test( `adds an assertive message for screenreader users when an invalid link is set`, async ( { + page, + editor, + pageUtils, + } ) => { + await editor.insertBlock( { + name: 'core/paragraph', + } ); + await page.keyboard.type( 'This is Gutenberg' ); + // Select some text. + await pageUtils.pressKeys( 'shiftAlt+ArrowLeft' ); + + // Insert a Link. + await editor.clickBlockToolbarButton( 'Link' ); + + await page.keyboard.type( 'http://#test.com' ); + await pageUtils.pressKeys( 'Enter' ); + expect( + page.getByText( + 'Warning: the link has been inserted but may have errors. Please test it.' + ) + ).toBeTruthy(); + } ); + test( `can be created by selecting text and using keyboard shortcuts`, async ( { page, editor, pageUtils, + LinkUtils, } ) => { // Create a block with some text. await editor.insertBlock( { @@ -63,12 +578,10 @@ test.describe( 'Links', () => { await expect( checkbox ).toBeChecked(); await expect( checkbox ).toBeFocused(); + const linkPopover = LinkUtils.getLinkPopover(); + // Tab back to the Submit and apply the link. - await page - //TODO: change to a better selector when https://github.com/WordPress/gutenberg/issues/51060 is resolved. - .locator( '.block-editor-link-control' ) - .getByRole( 'button', { name: 'Save' } ) - .click(); + await linkPopover.getByRole( 'button', { name: 'Save' } ).click(); // The link should have been inserted. await expect.poll( editor.getBlocks ).toMatchObject( [ @@ -86,6 +599,7 @@ test.describe( 'Links', () => { page, editor, pageUtils, + LinkUtils, } ) => { // Create a block with some text. await editor.insertBlock( { @@ -114,15 +628,16 @@ test.describe( 'Links', () => { // Edit link. await pageUtils.pressKeys( 'primary+k' ); + + // getByPlaceholder required in order to handle Link Control component + // managing focus onto other inputs within the control. await page.getByPlaceholder( 'Search or type url' ).fill( '' ); await page.keyboard.type( 'wordpress.org' ); + const linkPopover = LinkUtils.getLinkPopover(); + // Update the link. - await page - //TODO: change to a better selector when https://github.com/WordPress/gutenberg/issues/51060 is resolved. - .locator( '.block-editor-link-control' ) - .getByRole( 'button', { name: 'Save' } ) - .click(); + await linkPopover.getByRole( 'button', { name: 'Save' } ).click(); // Navigate back to the popover. await page.keyboard.press( 'ArrowLeft' ); @@ -130,10 +645,14 @@ test.describe( 'Links', () => { // Navigate back to inputs to verify appears as changed. await pageUtils.pressKeys( 'primary+k' ); - const urlInputValue = await page - .getByPlaceholder( 'Search or type url' ) - .inputValue(); - expect( urlInputValue ).toContain( 'wordpress.org' ); + + expect( + await page + .getByRole( 'combobox', { + name: 'Link', + } ) + .inputValue() + ).toContain( 'wordpress.org' ); await expect.poll( editor.getBlocks ).toMatchObject( [ { @@ -238,6 +757,7 @@ test.describe( 'Links', () => { page, editor, pageUtils, + LinkUtils, } ) => { await editor.insertBlock( { name: 'core/paragraph', @@ -271,11 +791,10 @@ test.describe( 'Links', () => { await page.getByLabel( 'Open in new tab' ).click(); await page.getByLabel( 'nofollow' ).click(); + const linkPopover = LinkUtils.getLinkPopover(); + // Save the link - await page - .locator( '.block-editor-link-control' ) - .getByRole( 'button', { name: 'Save' } ) - .click(); + await linkPopover.getByRole( 'button', { name: 'Save' } ).click(); // Expect correct attributes to be set on the underlying link. await expect.poll( editor.getBlocks ).toMatchObject( [ @@ -300,10 +819,7 @@ test.describe( 'Links', () => { await page.getByLabel( 'nofollow' ).click(); // Save the link - await page - .locator( '.block-editor-link-control' ) - .getByRole( 'button', { name: 'Save' } ) - .click(); + await linkPopover.getByRole( 'button', { name: 'Save' } ).click(); // Expect correct attributes to be set on the underlying link. await expect.poll( editor.getBlocks ).toMatchObject( [ @@ -315,4 +831,445 @@ test.describe( 'Links', () => { }, ] ); } ); + + test.describe( 'Editing link text', () => { + test( 'should allow for modification of link text via the Link UI', async ( { + page, + pageUtils, + editor, + LinkUtils, + } ) => { + await LinkUtils.createAndReselectLink(); + + const originalLinkText = 'Gutenberg'; + const changedLinkText = + ' link text that was modified via the Link UI to include spaces '; + + // Make a collapsed selection inside the link. This is used + // as a stress test to ensure we can find the link text from a + // collapsed RichTextValue that contains a link format. + await pageUtils.pressKeys( 'ArrowLeft' ); + await pageUtils.pressKeys( 'ArrowRight' ); + + await editor.showBlockToolbar(); + await page.getByRole( 'button', { name: 'Edit' } ).click(); + + const textInput = page.getByLabel( 'Text', { exact: true } ); + + // At this point, we still expect the text input + // to reflect the original value with no modifications. + await expect( textInput ).toHaveValue( originalLinkText ); + + // Select all the link text in the input. + await pageUtils.pressKeys( 'primary+a' ); + + // Modify the link text value. + await page.keyboard.type( changedLinkText ); + + // Submit the change. + await pageUtils.pressKeys( 'Enter' ); + + // Check the created link reflects the link text. + await expect.poll( editor.getBlocks ).toMatchObject( [ + { + name: 'core/paragraph', + attributes: { + content: + 'This is ' + + changedLinkText + + '', + }, + }, + ] ); + } ); + + test( 'should not display text input when initially creating the link', async ( { + page, + editor, + pageUtils, + LinkUtils, + } ) => { + // Create a block with some text. + await editor.insertBlock( { + name: 'core/paragraph', + } ); + await page.keyboard.type( 'This is Gutenberg: ' ); + + // Press Cmd+K to insert a link. + await pageUtils.pressKeys( 'primary+k' ); + + const linkPopover = LinkUtils.getLinkPopover(); + + // Check the Link UI is open before asserting on presence of text input + // within that control. + await expect( linkPopover ).toBeVisible(); + + // Let's check we've focused a text input. + const textInput = linkPopover.getByLabel( 'Text', { exact: true } ); + await expect( textInput ).toBeHidden(); + } ); + + test( 'should display text input when the link has a valid URL value', async ( { + pageUtils, + LinkUtils, + } ) => { + await LinkUtils.createAndReselectLink(); + + // Make a collapsed selection inside the link. This is used + // as a stress test to ensure we can find the link text from a + // collapsed RichTextValue that contains a link format. + await pageUtils.pressKeys( 'ArrowLeft' ); + await pageUtils.pressKeys( 'ArrowRight' ); + + const linkPopover = LinkUtils.getLinkPopover(); + + await linkPopover.getByRole( 'button', { name: 'Edit' } ).click(); + + // Check Text input is visible and is the focused field. + const textInput = linkPopover.getByLabel( 'Text', { exact: true } ); + await expect( textInput ).toBeVisible(); + await expect( textInput ).toBeFocused(); + + // Link was created on text value "Gutenberg". We expect + // the text input to reflect that value. + await expect( textInput ).toHaveValue( 'Gutenberg' ); + } ); + + test( 'should show any trailing and/or leading whitespace from linked text within the text input', async ( { + page, + pageUtils, + editor, + } ) => { + const textToSelect = ` spaces `; + const textWithWhitespace = `Text with leading and trailing${ textToSelect }`; + + // Create a block with some text. + await editor.insertBlock( { + name: 'core/paragraph', + } ); + await page.keyboard.type( textWithWhitespace ); + + // Use arrow keys to select only the text with the leading + // and trailing whitespace. + await pageUtils.pressKeys( 'shift+ArrowLeft', { + times: textToSelect.length, + } ); + + // Click on the Link button. + await editor.clickBlockToolbarButton( 'Link' ); + + // Type a URL. + await page.keyboard.type( 'https://wordpress.org/gutenberg' ); + + // Click on the Submit button. + await pageUtils.pressKeys( 'Enter' ); + + await expect.poll( editor.getBlocks ).toMatchObject( [ + { + name: 'core/paragraph', + attributes: { + content: + 'Text with leading and trailing' + + textToSelect + + '', + }, + }, + ] ); + } ); + + test( 'should display (capture the) text from the currently active link even if there is a rich text selection', async ( { + editor, + pageUtils, + LinkUtils, + } ) => { + const originalLinkText = 'Gutenberg'; + + await LinkUtils.createAndReselectLink(); + + // Make a collapsed selection inside the link in order + // to activate the Link UI. + await pageUtils.pressKeys( 'ArrowLeft' ); + await pageUtils.pressKeys( 'ArrowRight' ); + + const linkPopover = LinkUtils.getLinkPopover(); + + await linkPopover.getByRole( 'button', { name: 'Edit' } ).click(); + + // Place cursor within the underling RichText link (not the Link UI). + await editor.canvas + .getByRole( 'document', { + name: 'Block: Paragraph', + } ) + .getByRole( 'link', { + name: 'Gutenberg', + } ) + .click(); + + // Make a selection within the RichText. + await pageUtils.pressKeys( 'shift+ArrowRight', { + times: 3, + } ); + + // Making a selection within the link text whilst the Link UI + // is open should not alter the value in the Link UI's "Text" + // field. It should remain as the full text of the currently + // focused link format. + await expect( + linkPopover.getByLabel( 'Text', { exact: true } ) + ).toHaveValue( originalLinkText ); + } ); + } ); + + test.describe( 'Disabling Link UI active state', () => { + test( 'should not show the Link UI when selection extends beyond link boundary', async ( { + page, + pageUtils, + editor, + LinkUtils, + } ) => { + const linkedText = `Gutenberg`; + const textBeyondLinkedText = ` and more text.`; + + // Create a block with some text. + await editor.insertBlock( { + name: 'core/paragraph', + } ); + await page.keyboard.type( + `This is ${ linkedText }${ textBeyondLinkedText }` + ); + + // Move cursor next to end of `linkedText`. + await pageUtils.pressKeys( 'ArrowLeft', { + times: textBeyondLinkedText.length, + } ); + + // Select the linkedText. + await pageUtils.pressKeys( 'shiftAlt+ArrowLeft' ); + + // Click on the Link button. + await editor.clickBlockToolbarButton( 'Link' ); + + // Type a URL. + await page.keyboard.type( 'https://wordpress.org/gutenberg' ); + + // Update the link. + await pageUtils.pressKeys( 'Enter' ); + // Reactivate the link. + await pageUtils.pressKeys( 'ArrowLeft' ); + await pageUtils.pressKeys( 'ArrowLeft' ); + + const linkPopover = LinkUtils.getLinkPopover(); + + await expect( linkPopover ).toBeVisible(); + + // Make selection starting within the link and moving beyond boundary to the left. + await pageUtils.pressKeys( 'shiftAlt+ArrowLeft', { + times: linkedText.length, + } ); + + // The Link UI should have disappeared (i.e. be inactive). + await expect( linkPopover ).toBeHidden(); + + // Cancel selection and move back within the Link. + await pageUtils.pressKeys( 'ArrowRight' ); + + // We should see the Link UI displayed again. + await expect( linkPopover ).toBeVisible(); + + // Make selection starting within the link and moving beyond boundary to the right. + await pageUtils.pressKeys( 'shift+ArrowRight', { + times: 3, + } ); + + // The Link UI should have disappeared (i.e. be inactive). + await expect( linkPopover ).toBeHidden(); + } ); + + test( 'should not show the Link UI when selection extends into another link', async ( { + page, + pageUtils, + editor, + LinkUtils, + } ) => { + const linkedTextOne = `Gutenberg`; + const linkedTextTwo = `Block Editor`; + const linkOneURL = 'https://wordpress.org'; + const linkTwoURL = 'https://wordpress.org/gutenberg'; + + // Create a block with some text. + await editor.insertBlock( { + name: 'core/paragraph', + } ); + await page.keyboard.type( + `This is the ${ linkedTextOne }${ linkedTextTwo }` + ); + + // Select the linkedTextTwo. + await pageUtils.pressKeys( 'shift+ArrowLeft', { + times: linkedTextTwo.length, + } ); + + // Click on the Link button. + await editor.clickBlockToolbarButton( 'Link' ); + + // Type a URL. + await page.keyboard.type( linkTwoURL ); + + // Update the link. + await pageUtils.pressKeys( 'Enter' ); + + // Move cursor next to the **end** of `linkTextOne` + await pageUtils.pressKeys( 'ArrowLeft', { + times: linkedTextTwo.length, + } ); + + // Select `linkTextOne` + await pageUtils.pressKeys( 'shiftAlt+ArrowLeft' ); + + // Click on the Link button. + await editor.clickBlockToolbarButton( 'Link' ); + + // Type a URL. + await page.keyboard.type( linkOneURL ); + + // Update the link. + await pageUtils.pressKeys( 'Enter' ); + + // Move cursor within `linkTextOne` + await pageUtils.pressKeys( 'ArrowLeft', { + times: 3, + } ); + + const linkPopover = LinkUtils.getLinkPopover(); + + // Link UI should activate for `linkTextOne` + await expect( linkPopover ).toBeVisible(); + + // Expand selection so that it overlaps with `linkTextTwo` + await pageUtils.pressKeys( 'ArrowRight', { + times: 3, + } ); + + // Link UI should be inactive. + await expect( linkPopover ).toBeHidden(); + } ); + + // Based on issue reported in https://github.com/WordPress/gutenberg/issues/41771/. + test( `should correctly replace active link's text value within rich text even when multiple matching text values exist within the rich text`, async ( { + page, + editor, + pageUtils, + LinkUtils, + } ) => { + // Create a block with some text. + await editor.insertBlock( { + name: 'core/paragraph', + } ); + + // Note the two instances of the string "a". + await page.keyboard.type( `a b c a` ); + + // Select the last "a" only. + await pageUtils.pressKeys( 'shift+ArrowLeft' ); + + // Click on the Link button. + await editor.clickBlockToolbarButton( 'Link' ); + + // Type a URL. + await page.keyboard.type( 'www.wordpress.org' ); + + // Update the link. + await pageUtils.pressKeys( 'Enter' ); + + await pageUtils.pressKeys( 'ArrowLeft' ); + + const linkPopover = LinkUtils.getLinkPopover(); + + // Click the "Edit" button in Link UI + await linkPopover.getByRole( 'button', { name: 'Edit' } ).click(); + + // Focus the "Text" field within the linkPopover + await linkPopover + .getByRole( 'textbox', { + name: 'Text', + } ) + .focus(); + + // Delete existing value from "Text" field + await pageUtils.pressKeys( 'Backspace' ); + + // Change text to "z" + await page.keyboard.type( 'z' ); + + await pageUtils.pressKeys( 'Enter' ); + + // Check that the correct (i.e. last) instance of "a" was replaced with "z". + await expect.poll( editor.getBlocks ).toMatchObject( [ + { + name: 'core/paragraph', + attributes: { + content: + 'a b c z', + }, + }, + ] ); + } ); + } ); } ); + +class LinkUtils { + constructor( { editor, page, pageUtils } ) { + this.page = page; + this.editor = editor; + this.pageUtils = pageUtils; + } + + async toggleFixedToolbar( isFixed ) { + await this.page.evaluate( ( _isFixed ) => { + const { select, dispatch } = window.wp.data; + const isCurrentlyFixed = + select( 'core/edit-post' ).isFeatureActive( 'fixedToolbar' ); + + if ( isCurrentlyFixed !== _isFixed ) { + dispatch( 'core/edit-post' ).toggleFeature( 'fixedToolbar' ); + } + }, isFixed ); + } + + async createAndReselectLink() { + // Create a block with some text. + await this.editor.insertBlock( { + name: 'core/paragraph', + } ); + await this.page.keyboard.type( 'This is Gutenberg' ); + + // Select some text. + await this.pageUtils.pressKeys( 'shiftAlt+ArrowLeft' ); + + // Click on the Link button. + await this.page.getByRole( 'button', { name: 'Link' } ).click(); + + // Type a URL. + await this.page.keyboard.type( 'https://wordpress.org/gutenberg' ); + + // Click on the Submit button. + await this.pageUtils.pressKeys( 'Enter' ); + + // Reselect the link. + await this.pageUtils.pressKeys( 'shiftAlt+ArrowLeft' ); + } + + /** + * This method is used as a temporary workaround for retriveing the + * LinkControl component. This is because it currently does not expose + * any accessible attributes. In general we should avoid using this method + * and instead rely on locating the sub elements of the component directly. + * Remove / update method once the following PR has landed: + * https://github.com/WordPress/gutenberg/pull/54063. + */ + getLinkPopover() { + return this.page.locator( + '.components-popover__content .block-editor-link-control' + ); + } +} From 6db6ffc39571ae44c3507cd4054733d4e22a8882 Mon Sep 17 00:00:00 2001 From: Gan Eng Chin Date: Tue, 10 Oct 2023 02:51:34 +0800 Subject: [PATCH 018/239] Remove margins from Notice component (#54800) --- packages/components/CHANGELOG.md | 4 ++++ packages/components/src/notice/style.scss | 1 - 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/components/CHANGELOG.md b/packages/components/CHANGELOG.md index 6b3734dfdabcdf..3dfc268ad21636 100644 --- a/packages/components/CHANGELOG.md +++ b/packages/components/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Enhancements + +- `Notice`: Remove margins from `Notice` component ([#54800](https://github.com/WordPress/gutenberg/pull/54800)). + ### Bug Fix - `Modal`: fix closing when contained iframe is focused ([#51602](https://github.com/WordPress/gutenberg/pull/51602)). diff --git a/packages/components/src/notice/style.scss b/packages/components/src/notice/style.scss index ebd76d35ce7065..a2d6aca530a93c 100644 --- a/packages/components/src/notice/style.scss +++ b/packages/components/src/notice/style.scss @@ -4,7 +4,6 @@ font-size: $default-font-size; background-color: $white; border-left: 4px solid $components-color-accent; - margin: 5px 15px 2px; padding: 8px 12px; align-items: center; From 800575a91bde0cc07302f55c5b38cac7ac00ec67 Mon Sep 17 00:00:00 2001 From: Gutenberg Repository Automation Date: Tue, 10 Oct 2023 00:10:29 +0000 Subject: [PATCH 019/239] Bump plugin version to 16.8.0-rc.2 --- gutenberg.php | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/gutenberg.php b/gutenberg.php index eea2231a57183a..1bfcb11db361bd 100644 --- a/gutenberg.php +++ b/gutenberg.php @@ -5,7 +5,7 @@ * Description: Printing since 1440. This is the development plugin for the block editor, site editor, and other future WordPress core functionality. * Requires at least: 6.2 * Requires PHP: 7.0 - * Version: 16.8.0-rc.1 + * Version: 16.8.0-rc.2 * Author: Gutenberg Team * Text Domain: gutenberg * diff --git a/package-lock.json b/package-lock.json index e09b41441af663..341db8407614bd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "gutenberg", - "version": "16.8.0-rc.1", + "version": "16.8.0-rc.2", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "gutenberg", - "version": "16.8.0-rc.1", + "version": "16.8.0-rc.2", "hasInstallScript": true, "license": "GPL-2.0-or-later", "dependencies": { diff --git a/package.json b/package.json index 8dc209f87351ab..cf6ce78f22e7f1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gutenberg", - "version": "16.8.0-rc.1", + "version": "16.8.0-rc.2", "private": true, "description": "A new WordPress editor experience.", "author": "The WordPress Contributors", From 416bab68238be99fa77429d9d6a1e0174fdd9507 Mon Sep 17 00:00:00 2001 From: Gutenberg Repository Automation Date: Tue, 10 Oct 2023 00:20:45 +0000 Subject: [PATCH 020/239] Update Changelog for 16.8.0-rc.2 --- changelog.txt | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/changelog.txt b/changelog.txt index 07dd4757450562..1b7d1150efdaf0 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,5 +1,26 @@ == Changelog == += 16.8.0-rc.2 = + +## Changelog +This copies the commits from the 16.7.1 patch release into the 16.8.0 main release. + +### Tools + +#### Build Tooling +- Fix incorrect resource URL in source map for sources coming from @wordpress packages. ([51401](https://github.com/WordPress/gutenberg/pull/51401)) + +### Various + +- Add missing schema `type` attribute for in WP 6.4 compat's `block-hooks.php`. ([55138](https://github.com/WordPress/gutenberg/pull/55138)) + +## Contributors + +The following contributors merged PRs in this release: + +@fullofcaffeine @torounit + + = 16.7.1 = ## Changelog From fff451dcca8ffef807852379fb1efd7566a1c84e Mon Sep 17 00:00:00 2001 From: Andrew Serong <14988353+andrewserong@users.noreply.github.com> Date: Tue, 10 Oct 2023 14:31:03 +1100 Subject: [PATCH 021/239] Site Editor Styles Screen: Fix dancing styles previews (#55183) --- .../components/sidebar-navigation-screen/style.scss | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/edit-site/src/components/sidebar-navigation-screen/style.scss b/packages/edit-site/src/components/sidebar-navigation-screen/style.scss index 687326fbebd332..4efdbad33a5430 100644 --- a/packages/edit-site/src/components/sidebar-navigation-screen/style.scss +++ b/packages/edit-site/src/components/sidebar-navigation-screen/style.scss @@ -76,6 +76,19 @@ } .edit-site-sidebar-navigation-screen__content .edit-site-global-styles-style-variations-container { + @include break-medium() { + // Safari does not currently support `scrollbar-gutter: stable`, so at + // particular viewport sizes it's possible for previews to render prior to a + // scrollbar appearing. This then causes a scrollbar to appear, which reduces + // the width of the container and can cause the preview's width to change. + // As a result, the preview can go into an endless loop of resizing, causing + // the preview elements to appear to "dance". A workaround is to provide a + // max-width for the container, which prevents the introduction of the scrollbar + // from causing the preview's width to change. + // See: https://github.com/WordPress/gutenberg/issues/55112 + max-width: 292px; + } + .edit-site-global-styles-variations_item-preview { border: $gray-900 $border-width solid; } From 2d86e9053e79e7d2ab37e00c24ac7145980ad959 Mon Sep 17 00:00:00 2001 From: Ramon Date: Tue, 10 Oct 2023 14:45:10 +1100 Subject: [PATCH 022/239] Pulling across changes from https://github.com/WordPress/wordpress-develop/pull/5441 (#55181) Removed var --- packages/block-library/src/latest-posts/edit.js | 7 ++++--- packages/block-library/src/latest-posts/index.php | 5 ++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/block-library/src/latest-posts/edit.js b/packages/block-library/src/latest-posts/edit.js index 586ecc59432730..0efe538b01f629 100644 --- a/packages/block-library/src/latest-posts/edit.js +++ b/packages/block-library/src/latest-posts/edit.js @@ -484,9 +484,10 @@ export default function LatestPostsEdit( { attributes, setAttributes } ) { .join( ' ' ) } { createInterpolateElement( sprintf( - /* translators: 1: The static string "Read more", 2: The post title only visible to screen readers. */ - __( '… %1$s: %2$s' ), - __( 'Read more' ), + /* translators: 1: Hidden accessibility text: Post title */ + __( + '… Read more: %1$s' + ), titleTrimmed || __( '(no title)' ) ), { diff --git a/packages/block-library/src/latest-posts/index.php b/packages/block-library/src/latest-posts/index.php index d5f759c0c0e259..adc51d0c4fecb9 100644 --- a/packages/block-library/src/latest-posts/index.php +++ b/packages/block-library/src/latest-posts/index.php @@ -152,10 +152,9 @@ function render_block_core_latest_posts( $attributes ) { if ( $excerpt_length <= $block_core_latest_posts_excerpt_length ) { $trimmed_excerpt = substr( $trimmed_excerpt, 0, -11 ); $trimmed_excerpt .= sprintf( - /* translators: 1: A URL to a post, 2: The static string "Read more", 3: The post title only visible to screen readers. */ - __( '… %2$s: %3$s' ), + /* translators: 1: A URL to a post, 2: Hidden accessibility text: Post title */ + __( '… Read more: %2$s' ), esc_url( $post_link ), - __( 'Read more' ), esc_html( $title ) ); } From 3a123e76a042d65dde8328d2571006b3e2eae295 Mon Sep 17 00:00:00 2001 From: Bart Kalisz Date: Tue, 10 Oct 2023 04:03:53 +0000 Subject: [PATCH 023/239] Fix media type check in the onSelectMedia hook (#55168) --- packages/block-library/src/cover/edit/index.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/block-library/src/cover/edit/index.js b/packages/block-library/src/cover/edit/index.js index 6fac6e8b4a5060..512794ee3f0ec8 100644 --- a/packages/block-library/src/cover/edit/index.js +++ b/packages/block-library/src/cover/edit/index.js @@ -166,9 +166,12 @@ function CoverEdit( { const onSelectMedia = async ( newMedia ) => { const mediaAttributes = attributesFromMedia( newMedia ); + const isImage = [ newMedia?.type, newMedia?.media_type ].includes( + IMAGE_BACKGROUND_TYPE + ); const averageBackgroundColor = await getMediaColor( - newMedia?.type === IMAGE_BACKGROUND_TYPE ? newMedia?.url : undefined + isImage ? newMedia?.url : undefined ); let newOverlayColor = overlayColor.color; From f6b21dbcd6c9f5970119177b597849f31da6d2f2 Mon Sep 17 00:00:00 2001 From: Mario Santos <34552881+SantosGuillamot@users.noreply.github.com> Date: Tue, 10 Oct 2023 06:14:28 +0200 Subject: [PATCH 024/239] Add `aria-label` attribute to navigation block only when it is open (#54816) * Add `aria-label` only when is open * Remove unnecessary `label` property in edit * Escape translation * Move navigation context to `wp_json_encode` * Add `wp_json_encode` flags --- .../src/navigation/edit/index.js | 1 - .../block-library/src/navigation/index.php | 25 ++++++++++++++----- packages/block-library/src/navigation/view.js | 7 ++++++ packages/block-library/src/query/index.php | 3 ++- 4 files changed, 28 insertions(+), 8 deletions(-) diff --git a/packages/block-library/src/navigation/edit/index.js b/packages/block-library/src/navigation/edit/index.js index 7c29f18d4940d4..f12d83e2fe2eae 100644 --- a/packages/block-library/src/navigation/edit/index.js +++ b/packages/block-library/src/navigation/edit/index.js @@ -884,7 +884,6 @@ function Navigation( { array( + 'navigation' => array( + 'overlayOpenedBy' => array(), + 'type' => 'overlay', + 'roleAttribute' => '', + 'ariaLabel' => __( 'Menu' ), + ), + ), + ), + JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP + ); $nav_element_directives = ' data-wp-interactive - data-wp-context=\'{ "core": { "navigation": { "overlayOpenedBy": {}, "type": "overlay", "roleAttribute": "" } } }\' + data-wp-context=\'' . $nav_element_context . '\' '; $open_button_directives = ' data-wp-on--click="actions.core.navigation.openMenuOnClick" @@ -714,6 +727,7 @@ function render_block_core_navigation( $attributes, $content, $block ) { '; $responsive_dialog_directives = ' data-wp-bind--aria-modal="selectors.core.navigation.ariaModal" + data-wp-bind--aria-label="selectors.core.navigation.ariaLabel" data-wp-bind--role="selectors.core.navigation.roleAttribute" data-wp-effect="effects.core.navigation.focusFirstElement" '; @@ -723,11 +737,11 @@ function render_block_core_navigation( $attributes, $content, $block ) { } $responsive_container_markup = sprintf( - ' -
+ ' +
-
- +
+
%2$s
@@ -741,7 +755,6 @@ function render_block_core_navigation( $attributes, $content, $block ) { esc_attr( implode( ' ', $responsive_container_classes ) ), esc_attr( implode( ' ', $open_button_classes ) ), esc_attr( safecss_filter_attr( $colors['overlay_inline_styles'] ) ), - __( 'Menu' ), $toggle_button_content, $toggle_close_button_content, $open_button_directives, diff --git a/packages/block-library/src/navigation/view.js b/packages/block-library/src/navigation/view.js index 6a8e4979983b8a..c0853b2814e2b3 100644 --- a/packages/block-library/src/navigation/view.js +++ b/packages/block-library/src/navigation/view.js @@ -87,6 +87,13 @@ wpStore( { ? 'true' : null; }, + ariaLabel: ( store ) => { + const { context, selectors } = store; + return context.core.navigation.type === 'overlay' && + selectors.core.navigation.isMenuOpen( store ) + ? context.core.navigation.ariaLabel + : null; + }, isMenuOpen: ( { context } ) => // The menu is opened if either `click`, `hover` or `focus` is true. Object.values( diff --git a/packages/block-library/src/query/index.php b/packages/block-library/src/query/index.php index c35bf2ba00af21..1b05e9c92c95e3 100644 --- a/packages/block-library/src/query/index.php +++ b/packages/block-library/src/query/index.php @@ -34,7 +34,8 @@ function render_block_core_query( $attributes, $content, $block ) { 'loadedText' => __( 'Page Loaded.' ), ), ), - ) + ), + JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP ) ); $content = $p->get_updated_html(); From 27964574bbf4828a415555a456baeaf61fbdffb0 Mon Sep 17 00:00:00 2001 From: Ella <4710635+ellatrix@users.noreply.github.com> Date: Tue, 10 Oct 2023 08:08:05 +0300 Subject: [PATCH 025/239] Paste: fix MS Word list paste (#55127) * Paste: fix MS Word list paste * Match mso-list:Ignore * Fix inline paste --- .../src/api/raw-handling/ms-list-converter.js | 31 +++++++------------ .../src/api/raw-handling/ms-list-ignore.js | 27 ++++++++++++++++ .../src/api/raw-handling/paste-handler.js | 2 ++ .../raw-handling/test/ms-list-converter.js | 18 +++++------ .../blocks-raw-handling.test.js.snap | 4 ++- test/integration/blocks-raw-handling.test.js | 1 + .../fixtures/documents/ms-word-list-in.html | 28 +++++++++++++++++ .../fixtures/documents/ms-word-list-out.html | 25 +++++++++++++++ 8 files changed, 106 insertions(+), 30 deletions(-) create mode 100644 packages/blocks/src/api/raw-handling/ms-list-ignore.js create mode 100644 test/integration/fixtures/documents/ms-word-list-in.html create mode 100644 test/integration/fixtures/documents/ms-word-list-out.html diff --git a/packages/blocks/src/api/raw-handling/ms-list-converter.js b/packages/blocks/src/api/raw-handling/ms-list-converter.js index 8b45a5ab53fdb7..fdbc48398a1cc6 100644 --- a/packages/blocks/src/api/raw-handling/ms-list-converter.js +++ b/packages/blocks/src/api/raw-handling/ms-list-converter.js @@ -3,6 +3,12 @@ */ const { parseInt } = window; +/** + * Internal dependencies + */ +import { deepFilterHTML } from './utils'; +import msListIgnore from './ms-list-ignore'; + function isList( node ) { return node.nodeName === 'OL' || node.nodeName === 'UL'; } @@ -14,23 +20,10 @@ export default function msListConverter( node, doc ) { const style = node.getAttribute( 'style' ); - if ( ! style ) { - return; - } - - // Quick check. - if ( style.indexOf( 'mso-list' ) === -1 ) { - return; - } - - const matches = /mso-list\s*:[^;]+level([0-9]+)/i.exec( style ); - - if ( ! matches ) { + if ( ! style || ! style.includes( 'mso-list' ) ) { return; } - let level = parseInt( matches[ 1 ], 10 ) - 1 || 0; - const prevNode = node.previousElementSibling; // Add new list if no previous. @@ -53,13 +46,11 @@ export default function msListConverter( node, doc ) { let receivingNode = listNode; - // Remove the first span with list info. - node.removeChild( node.firstChild ); - // Add content. - while ( node.firstChild ) { - listItem.appendChild( node.firstChild ); - } + listItem.innerHTML = deepFilterHTML( node.innerHTML, [ msListIgnore ] ); + + const matches = /mso-list\s*:[^;]+level([0-9]+)/i.exec( style ); + let level = matches ? parseInt( matches[ 1 ], 10 ) - 1 || 0 : 0; // Change pointer depending on indentation level. while ( level-- ) { diff --git a/packages/blocks/src/api/raw-handling/ms-list-ignore.js b/packages/blocks/src/api/raw-handling/ms-list-ignore.js new file mode 100644 index 00000000000000..d1ed421e3b76c5 --- /dev/null +++ b/packages/blocks/src/api/raw-handling/ms-list-ignore.js @@ -0,0 +1,27 @@ +/** + * Looks for comments, and removes them. + * + * @param {Node} node The node to be processed. + * @return {void} + */ +export default function msListIgnore( node ) { + if ( node.nodeType !== node.ELEMENT_NODE ) { + return; + } + + const style = node.getAttribute( 'style' ); + + if ( ! style || ! style.includes( 'mso-list' ) ) { + return; + } + + const rules = style.split( ';' ).reduce( ( acc, rule ) => { + const [ key, value ] = rule.split( ':' ); + acc[ key.trim().toLowerCase() ] = value.trim().toLowerCase(); + return acc; + }, {} ); + + if ( rules[ 'mso-list' ] === 'ignore' ) { + node.remove(); + } +} diff --git a/packages/blocks/src/api/raw-handling/paste-handler.js b/packages/blocks/src/api/raw-handling/paste-handler.js index c63b207c1dbad6..9fa87462d8a1b2 100644 --- a/packages/blocks/src/api/raw-handling/paste-handler.js +++ b/packages/blocks/src/api/raw-handling/paste-handler.js @@ -17,6 +17,7 @@ import isInlineContent from './is-inline-content'; import phrasingContentReducer from './phrasing-content-reducer'; import headRemover from './head-remover'; import msListConverter from './ms-list-converter'; +import msListIgnore from './ms-list-ignore'; import listReducer from './list-reducer'; import imageCorrector from './image-corrector'; import blockquoteNormaliser from './blockquote-normaliser'; @@ -49,6 +50,7 @@ function filterInlineHTML( HTML, preserveWhiteSpace ) { HTML = deepFilterHTML( HTML, [ headRemover, googleDocsUIDRemover, + msListIgnore, phrasingContentReducer, commentRemover, ] ); diff --git a/packages/blocks/src/api/raw-handling/test/ms-list-converter.js b/packages/blocks/src/api/raw-handling/test/ms-list-converter.js index a7c58dfa03010a..5ae7da68f16a92 100644 --- a/packages/blocks/src/api/raw-handling/test/ms-list-converter.js +++ b/packages/blocks/src/api/raw-handling/test/ms-list-converter.js @@ -7,7 +7,7 @@ import { deepFilterHTML } from '../utils'; describe( 'msListConverter', () => { it( 'should convert unordered list', () => { const input = - '

* test

'; + '

* test

'; const output = '
  • test
'; expect( deepFilterHTML( input, [ msListConverter ] ) ).toEqual( output @@ -16,7 +16,7 @@ describe( 'msListConverter', () => { it( 'should convert ordered list', () => { const input = - '

1 test

'; + '

1 test

'; const output = '
  1. test
'; expect( deepFilterHTML( input, [ msListConverter ] ) ).toEqual( output @@ -25,11 +25,11 @@ describe( 'msListConverter', () => { it( 'should convert indented list', () => { const input1 = - '

* test

'; + '

* test

'; const input2 = - '

* test

'; + '

* test

'; const input3 = - '

* test

'; + '

* test

'; const output = '
  • test
    • test
  • test
'; expect( @@ -39,13 +39,13 @@ describe( 'msListConverter', () => { it( 'should convert deep indented list', () => { const input1 = - '

* test

'; + '

* test

'; const input2 = - '

* test

'; + '

* test

'; const input3 = - '

* test

'; + '

* test

'; const input4 = - '

* test

'; + '

* test

'; const output = '
  • test
    • test
      • test
  • test
'; expect( diff --git a/test/integration/__snapshots__/blocks-raw-handling.test.js.snap b/test/integration/__snapshots__/blocks-raw-handling.test.js.snap index 121382d942f268..32f2dab7137666 100644 --- a/test/integration/__snapshots__/blocks-raw-handling.test.js.snap +++ b/test/integration/__snapshots__/blocks-raw-handling.test.js.snap @@ -26,7 +26,9 @@ exports[`Blocks raw handling pasteHandler iframe-embed 1`] = `""`; exports[`Blocks raw handling pasteHandler markdown 1`] = `"This is a heading with italic
This is a paragraph with a link, bold, and strikethrough.
Preserve
line breaks please.
Lists
A
Bulleted Indented
List
One
Two
Three
Table
First Header
Second Header
Content from cell 1
Content from cell 2
Content in the first column
Content in the second column



Table with empty cells.
Quote
First
Second
Code
Inline code tags should work.
This is a code block."`; -exports[`Blocks raw handling pasteHandler ms-word 1`] = `"This is a title
 
This is a subtitle
 
This is a heading level 1
 
This is a heading level 2
 
This is a paragraph with a link.
 
·      A
·      Bulleted
o   Indented
·      List
 
1      One
2      Two
3      Three
 
One
Two
Three
1
2
3
I
II
III
 
An image:
 

This is an anchor link that leads to the next paragraph.
This is the paragraph with the anchor.
This is an anchor link that leads nowhere.
This is a paragraph with an anchor with no link pointing to it.
This is a reference to a footnote[1].
This is a reference to an endnote[i].


[1] This is a footnote.


[i] This is an endnote."`; +exports[`Blocks raw handling pasteHandler ms-word 1`] = `"This is a title
 
This is a subtitle
 
This is a heading level 1
 
This is a heading level 2
 
This is a paragraph with a link.
 
A
Bulleted
Indented
List
 
One
Two
Three
 
One
Two
Three
1
2
3
I
II
III
 
An image:
 

This is an anchor link that leads to the next paragraph.
This is the paragraph with the anchor.
This is an anchor link that leads nowhere.
This is a paragraph with an anchor with no link pointing to it.
This is a reference to a footnote[1].
This is a reference to an endnote[i].


[1] This is a footnote.


[i] This is an endnote."`; + +exports[`Blocks raw handling pasteHandler ms-word-list 1`] = `"This is a headline?
This is a text:
One
Two
Three
Lorem Ipsum.
 "`; exports[`Blocks raw handling pasteHandler ms-word-online 1`] = `"This is a heading 
This is a paragraph with a link

Bulleted 
Indented 
List 
 
One 
Two 
Three 

One 
Two 
Three 




II 
III 
 
An image: 
 "`; diff --git a/test/integration/blocks-raw-handling.test.js b/test/integration/blocks-raw-handling.test.js index 2a31d0b0ceaa28..229fa0ba7761c8 100644 --- a/test/integration/blocks-raw-handling.test.js +++ b/test/integration/blocks-raw-handling.test.js @@ -383,6 +383,7 @@ describe( 'Blocks raw handling', () => { 'google-docs-table-with-comments', 'google-docs-with-comments', 'ms-word', + 'ms-word-list', 'ms-word-styled', 'ms-word-online', 'evernote', diff --git a/test/integration/fixtures/documents/ms-word-list-in.html b/test/integration/fixtures/documents/ms-word-list-in.html new file mode 100644 index 00000000000000..8cf79b8f7e5db7 --- /dev/null +++ b/test/integration/fixtures/documents/ms-word-list-in.html @@ -0,0 +1,28 @@ +

This is a headline?

+ +

This is a text:

+ +

·       +One

+ +

·       +Two

+ +

·       +Three

+ + + +

Lorem Ipsum.

+ +

 

\ No newline at end of file diff --git a/test/integration/fixtures/documents/ms-word-list-out.html b/test/integration/fixtures/documents/ms-word-list-out.html new file mode 100644 index 00000000000000..f57946f64bc985 --- /dev/null +++ b/test/integration/fixtures/documents/ms-word-list-out.html @@ -0,0 +1,25 @@ + +

This is a headline?

+ + + +

This is a text:

+ + + +
    +
  • One
  • + + + +
  • Two
  • + + + +
  • Three
  • +
+ + + +

Lorem Ipsum.

+ \ No newline at end of file From d55093a139f239c11b9c0e867ce27247b7b44d98 Mon Sep 17 00:00:00 2001 From: Daniel Richards Date: Tue, 10 Oct 2023 13:42:12 +0800 Subject: [PATCH 026/239] Fix scrollbars on pattern transforms (#55069) * Fix scrollbars on pattern transforms * Fix single pattern previews * Improve classname semantics * Remove modal title --- .../block-switcher/pattern-transformations-menu.js | 5 +---- .../block-editor/src/components/block-switcher/style.scss | 6 ++++++ 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/block-editor/src/components/block-switcher/pattern-transformations-menu.js b/packages/block-editor/src/components/block-switcher/pattern-transformations-menu.js index 83eecd329d8c4c..f9a4b7190a6dd8 100644 --- a/packages/block-editor/src/components/block-switcher/pattern-transformations-menu.js +++ b/packages/block-editor/src/components/block-switcher/pattern-transformations-menu.js @@ -60,10 +60,7 @@ function PreviewPatternsPopover( { patterns, onSelect } ) { className="block-editor-block-switcher__preview__popover" position="bottom right" > -
-
- { __( 'Preview' ) } -
+
Date: Tue, 10 Oct 2023 09:11:10 +0200 Subject: [PATCH 027/239] Reset styles on window resize (#55077) Co-authored-by: Ricardo Artemio Morales --- packages/block-library/src/image/index.php | 1 + packages/block-library/src/image/view.js | 21 ++++++++++++++++----- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/packages/block-library/src/image/index.php b/packages/block-library/src/image/index.php index bfc3af8754bc1d..e1f71964622c0c 100644 --- a/packages/block-library/src/image/index.php +++ b/packages/block-library/src/image/index.php @@ -200,6 +200,7 @@ function block_core_image_render_lightbox( $block_content, $block ) { $w->set_attribute( 'data-wp-init', 'effects.core.image.setCurrentSrc' ); $w->set_attribute( 'data-wp-on--load', 'actions.core.image.handleLoad' ); $w->set_attribute( 'data-wp-effect', 'effects.core.image.setButtonStyles' ); + $w->set_attribute( 'data-wp-effect--setStylesOnResize', 'effects.core.image.setStylesOnResize' ); $body_content = $w->get_updated_html(); // Wrap the image in the body content with a button. diff --git a/packages/block-library/src/image/view.js b/packages/block-library/src/image/view.js index 3eb47dcc7cab4b..3f2242ad737f02 100644 --- a/packages/block-library/src/image/view.js +++ b/packages/block-library/src/image/view.js @@ -105,7 +105,10 @@ store( context.core.image.scrollDelta = 0; context.core.image.lightboxEnabled = true; - setStyles( context, event ); + setStyles( + context, + event.target.previousElementSibling + ); context.core.image.scrollTopReset = window.pageYOffset || @@ -338,6 +341,15 @@ store( context.core.image.imageButtonHeight = offsetHeight; } }, + setStylesOnResize: ( { state, context, ref } ) => { + if ( + context.core.image.lightboxEnabled && + ( state.core.image.windowWidth || + state.core.image.windowHeight ) + ) { + setStyles( context, ref ); + } + }, }, }, }, @@ -362,7 +374,7 @@ store( * @param {Object} context - An Interactivity API context * @param {Object} event - A triggering event */ -function setStyles( context, event ) { +function setStyles( context, ref ) { // The reference img element lies adjacent // to the event target button in the DOM. let { @@ -370,9 +382,8 @@ function setStyles( context, event ) { naturalHeight, offsetWidth: originalWidth, offsetHeight: originalHeight, - } = event.target.previousElementSibling; - let { x: screenPosX, y: screenPosY } = - event.target.previousElementSibling.getBoundingClientRect(); + } = ref; + let { x: screenPosX, y: screenPosY } = ref.getBoundingClientRect(); // Natural ratio of the image clicked to open the lightbox. const naturalRatio = naturalWidth / naturalHeight; From 5034bf678bf2f65f67394d8b0761f4cfbcf2649d Mon Sep 17 00:00:00 2001 From: Ella <4710635+ellatrix@users.noreply.github.com> Date: Tue, 10 Oct 2023 11:54:07 +0300 Subject: [PATCH 028/239] List: fix forward merging of nested list (#55121) --- .../src/list-item/hooks/use-merge.js | 43 ++++++------- test/e2e/specs/editor/blocks/list.spec.js | 64 ++++++++++++------- 2 files changed, 62 insertions(+), 45 deletions(-) diff --git a/packages/block-library/src/list-item/hooks/use-merge.js b/packages/block-library/src/list-item/hooks/use-merge.js index 6b456a2a742bdb..cda1f0c02d3a88 100644 --- a/packages/block-library/src/list-item/hooks/use-merge.js +++ b/packages/block-library/src/list-item/hooks/use-merge.js @@ -76,6 +76,24 @@ export default function useMerge( clientId, onMerge ) { } return ( forward ) => { + function mergeWithNested( clientIdA, clientIdB ) { + registry.batch( () => { + // When merging a sub list item with a higher next list item, we + // also need to move any nested list items. Check if there's a + // listed list, and append its nested list items to the current + // list. + const [ nestedListClientId ] = getBlockOrder( clientIdB ); + if ( nestedListClientId ) { + moveBlocksToPosition( + getBlockOrder( nestedListClientId ), + nestedListClientId, + getBlockRootClientId( clientIdA ) + ); + } + mergeBlocks( clientIdA, clientIdB ); + } ); + } + if ( forward ) { const nextBlockClientId = getNextId( clientId ); @@ -87,14 +105,7 @@ export default function useMerge( clientId, onMerge ) { if ( getParentListItemId( nextBlockClientId ) ) { outdentListItem( nextBlockClientId ); } else { - registry.batch( () => { - moveBlocksToPosition( - getBlockOrder( nextBlockClientId ), - nextBlockClientId, - getPreviousBlockClientId( nextBlockClientId ) - ); - mergeBlocks( clientId, nextBlockClientId ); - } ); + mergeWithNested( clientId, nextBlockClientId ); } } else { // Merging is only done from the top level. For lowel levels, the @@ -104,21 +115,7 @@ export default function useMerge( clientId, onMerge ) { outdentListItem( clientId ); } else if ( previousBlockClientId ) { const trailingId = getTrailingId( previousBlockClientId ); - registry.batch( () => { - // When merging a list item with a previous trailing list - // item, we also need to move any nested list items. First, - // check if there's a listed list. If there's a nested list, - // append its nested list items to the trailing list. - const [ nestedListClientId ] = getBlockOrder( clientId ); - if ( nestedListClientId ) { - moveBlocksToPosition( - getBlockOrder( nestedListClientId ), - nestedListClientId, - getBlockRootClientId( trailingId ) - ); - } - mergeBlocks( trailingId, clientId ); - } ); + mergeWithNested( trailingId, clientId ); } else { onMerge( forward ); } diff --git a/test/e2e/specs/editor/blocks/list.spec.js b/test/e2e/specs/editor/blocks/list.spec.js index 6716a8fb5eac41..f10a266a41e659 100644 --- a/test/e2e/specs/editor/blocks/list.spec.js +++ b/test/e2e/specs/editor/blocks/list.spec.js @@ -1419,11 +1419,8 @@ test.describe( 'List (@firefox)', () => { ` ); } ); - test( 'should merge two list items with nested lists', async ( { - editor, - page, - } ) => { - await editor.insertBlock( { + test.describe( 'should merge two list items with nested lists', () => { + const start = { name: 'core/list', innerBlocks: [ { @@ -1457,22 +1454,8 @@ test.describe( 'List (@firefox)', () => { ], }, ], - } ); - - // Navigate to the third item. - await page.keyboard.press( 'ArrowDown' ); - await page.keyboard.press( 'ArrowDown' ); - await page.keyboard.press( 'ArrowDown' ); - await page.keyboard.press( 'ArrowDown' ); - await page.keyboard.press( 'ArrowDown' ); - await page.keyboard.press( 'ArrowDown' ); - - await page.keyboard.press( 'Backspace' ); - - // Test caret position. - await page.keyboard.type( '‸' ); - - await expect.poll( editor.getBlocks ).toMatchObject( [ + }; + const end = [ { name: 'core/list', innerBlocks: [ @@ -1497,6 +1480,43 @@ test.describe( 'List (@firefox)', () => { }, ], }, - ] ); + ]; + + test( 'Backspace', async ( { editor, page } ) => { + await editor.insertBlock( start ); + + // Navigate to the start of the third item. + await page.keyboard.press( 'ArrowDown' ); + await page.keyboard.press( 'ArrowDown' ); + await page.keyboard.press( 'ArrowDown' ); + await page.keyboard.press( 'ArrowDown' ); + await page.keyboard.press( 'ArrowDown' ); + await page.keyboard.press( 'ArrowDown' ); + + await page.keyboard.press( 'Backspace' ); + + // Test caret position. + await page.keyboard.type( '‸' ); + + await expect.poll( editor.getBlocks ).toMatchObject( end ); + } ); + + test( 'Delete (forward)', async ( { editor, page } ) => { + await editor.insertBlock( start ); + + // Navigate to the end of the second item. + await page.keyboard.press( 'ArrowDown' ); + await page.keyboard.press( 'ArrowDown' ); + await page.keyboard.press( 'ArrowDown' ); + await page.keyboard.press( 'ArrowDown' ); + await page.keyboard.press( 'ArrowRight' ); + + await page.keyboard.press( 'Delete' ); + + // Test caret position. + await page.keyboard.type( '‸' ); + + await expect.poll( editor.getBlocks ).toMatchObject( end ); + } ); } ); } ); From 3192b60daa3c39f34c8f35b9b5da962bbc8edc0c Mon Sep 17 00:00:00 2001 From: Ella <4710635+ellatrix@users.noreply.github.com> Date: Tue, 10 Oct 2023 11:54:29 +0300 Subject: [PATCH 029/239] Paste: only link selection if URL protocol is http(s) (#53000) --- .../components/rich-text/use-paste-handler.js | 6 ++- packages/format-library/src/link/index.js | 3 +- .../editor/various/copy-cut-paste.spec.js | 49 +++++++++++++++++++ 3 files changed, 56 insertions(+), 2 deletions(-) diff --git a/packages/block-editor/src/components/rich-text/use-paste-handler.js b/packages/block-editor/src/components/rich-text/use-paste-handler.js index 5f3b93a0ecc0e9..75a58bffd9aee1 100644 --- a/packages/block-editor/src/components/rich-text/use-paste-handler.js +++ b/packages/block-editor/src/components/rich-text/use-paste-handler.js @@ -139,10 +139,14 @@ export function usePasteHandler( props ) { let mode = onReplace && onSplit ? 'AUTO' : 'INLINE'; + const trimmedPlainText = plainText.trim(); + if ( __unstableEmbedURLOnPaste && isEmpty( value ) && - isURL( plainText.trim() ) + isURL( trimmedPlainText ) && + // For the link pasting feature, allow only http(s) protocols. + /^https?:/.test( trimmedPlainText ) ) { mode = 'BLOCKS'; } diff --git a/packages/format-library/src/link/index.js b/packages/format-library/src/link/index.js index 1596da055cc402..9faa90628e3c3b 100644 --- a/packages/format-library/src/link/index.js +++ b/packages/format-library/src/link/index.js @@ -144,7 +144,8 @@ export const link = { .trim(); // A URL was pasted, turn the selection into a link. - if ( ! isURL( pastedText ) ) { + // For the link pasting feature, allow only http(s) protocols. + if ( ! isURL( pastedText ) || ! /^https?:/.test( pastedText ) ) { return value; } diff --git a/test/e2e/specs/editor/various/copy-cut-paste.spec.js b/test/e2e/specs/editor/various/copy-cut-paste.spec.js index 4c249349243669..2a2fd95636e7fa 100644 --- a/test/e2e/specs/editor/various/copy-cut-paste.spec.js +++ b/test/e2e/specs/editor/various/copy-cut-paste.spec.js @@ -496,4 +496,53 @@ test.describe( 'Copy/cut/paste', () => { await page.keyboard.type( 'y' ); expect( await editor.getEditedPostContent() ).toMatchSnapshot(); } ); + + test( 'should link selection', async ( { pageUtils, editor } ) => { + await editor.insertBlock( { + name: 'core/paragraph', + attributes: { + content: 'a', + }, + } ); + await pageUtils.pressKeys( 'primary+a' ); + pageUtils.setClipboardData( { + plainText: 'https://wordpress.org/gutenberg', + html: 'https://wordpress.org/gutenberg', + } ); + await pageUtils.pressKeys( 'primary+v' ); + expect( await editor.getBlocks() ).toMatchObject( [ + { + name: 'core/paragraph', + attributes: { + content: 'a', + }, + }, + ] ); + } ); + + test( 'should not link selection for non http(s) protocol', async ( { + pageUtils, + editor, + } ) => { + await editor.insertBlock( { + name: 'core/paragraph', + attributes: { + content: 'a', + }, + } ); + await pageUtils.pressKeys( 'primary+a' ); + pageUtils.setClipboardData( { + plainText: 'movie: b', + html: 'movie: b', + } ); + await pageUtils.pressKeys( 'primary+v' ); + expect( await editor.getBlocks() ).toMatchObject( [ + { + name: 'core/paragraph', + attributes: { + content: 'movie: b', + }, + }, + ] ); + } ); } ); From 763e385a05c7c2a8ce767d7febf46b577d4ff6ef Mon Sep 17 00:00:00 2001 From: Ari Stathopoulos Date: Tue, 10 Oct 2023 12:03:29 +0300 Subject: [PATCH 030/239] Update the apiVersion in experimental blocks block.json files (#55186) --- packages/block-library/src/form-input/block.json | 2 +- .../block-library/src/form-submission-notification/block.json | 2 +- packages/block-library/src/form-submit-button/block.json | 2 +- packages/block-library/src/form/block.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/block-library/src/form-input/block.json b/packages/block-library/src/form-input/block.json index dbe182f03b4992..f0978302b34e20 100644 --- a/packages/block-library/src/form-input/block.json +++ b/packages/block-library/src/form-input/block.json @@ -1,6 +1,6 @@ { "$schema": "https://schemas.wp.org/trunk/block.json", - "apiVersion": 2, + "apiVersion": 3, "name": "core/form-input", "title": "Input field", "category": "common", diff --git a/packages/block-library/src/form-submission-notification/block.json b/packages/block-library/src/form-submission-notification/block.json index 62284d35ab4ddd..16d96928bf99e9 100644 --- a/packages/block-library/src/form-submission-notification/block.json +++ b/packages/block-library/src/form-submission-notification/block.json @@ -1,6 +1,6 @@ { "$schema": "https://schemas.wp.org/trunk/block.json", - "apiVersion": 2, + "apiVersion": 3, "name": "core/form-submission-notification", "title": "Form Submission Notification", "category": "common", diff --git a/packages/block-library/src/form-submit-button/block.json b/packages/block-library/src/form-submit-button/block.json index faa938e9bbc244..d7d516094bd899 100644 --- a/packages/block-library/src/form-submit-button/block.json +++ b/packages/block-library/src/form-submit-button/block.json @@ -1,6 +1,6 @@ { "$schema": "https://schemas.wp.org/trunk/block.json", - "apiVersion": 2, + "apiVersion": 3, "name": "core/form-submit-button", "title": "Form submit button", "category": "common", diff --git a/packages/block-library/src/form/block.json b/packages/block-library/src/form/block.json index 951d1dce4224eb..84e43396bddcc8 100644 --- a/packages/block-library/src/form/block.json +++ b/packages/block-library/src/form/block.json @@ -1,6 +1,6 @@ { "$schema": "https://schemas.wp.org/trunk/block.json", - "apiVersion": 2, + "apiVersion": 3, "name": "core/form", "title": "Form", "category": "common", From 6d42053c6105e6167a49693a7a90efed45d1f3be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9?= <583546+oandregal@users.noreply.github.com> Date: Tue, 10 Oct 2023 11:07:45 +0200 Subject: [PATCH 031/239] Document `kind`, `name`, `plural` for entity config (#55158) Co-authored-by: Marin Atanasov <8436925+tyxla@users.noreply.github.com> --- packages/core-data/README.md | 63 ++++++++++++++++++++++++++++++++++-- 1 file changed, 60 insertions(+), 3 deletions(-) diff --git a/packages/core-data/README.md b/packages/core-data/README.md index 4c4237bbc1e0ef..1493aee5bf22bb 100644 --- a/packages/core-data/README.md +++ b/packages/core-data/README.md @@ -48,21 +48,23 @@ As of right now, the default entities defined by this package map to the [REST A What follows is a description of some of the properties of `rootEntitiesConfig`. -## baseURL +### Connecting the entity with the data source + +#### baseURL - Type: string. - Example: `'/wp/v2/users'`. This property maps the entity to a given endpoint, taking its relative URL as value. -## baseURLParams +#### baseURLParams - Type: `object`. - Example: `{ context: 'edit' }`. Additional parameters to the request, added as a query string. Each property will be converted into a field/value pair. For example, given the `baseURL: '/wp/v2/users'` and the `baseURLParams: { context: 'edit' }` the URL would be `/wp/v2/users?context=edit`. -## key +#### key - Type: `string`. - Example: `'slug'`. @@ -99,6 +101,61 @@ There are also cases in which a response represents a collection shaped as an ob } ``` +### Interacting with entity records + +Entity records are unique. For entities that are collections, it's assumed that each record has an `id` property which serves as an identifier to manage it. If the entity defines a `key`, that property would be used as its identifier instead of the assumed `id`. + +#### name + +- Type: `string`. +- Example: `user`. + +The name of the entity. To be used in the utilities that interact with it (selectors, actions, hooks). + +#### kind + +- Type: `string`. +- Example: `root`. + +Entities can be grouped by `kind`. To be used in the utilities that interact with them (selectors, actions, hooks). + +The package provides general methods to interact with the entities (`getEntityRecords`, `getEntityRecord`, etc.) by leveraging the `kind` and `name` properties: + +```js +// Get the record collection for the user entity. +wp.data.select( 'core' ).getEntityRecords( 'root' 'user' ); + +// Get a single record for the user entity. +wp.data.select( 'core' ).getEntityRecord( 'root', 'user', recordId ); +``` + +#### plural + +- Type: `string`. +- Example: `statuses`. + +In addition to the general utilities (`getEntityRecords`, `getEntityRecord`, etc.), the package dynamically creates nicer-looking methods to interact with the entity records of the `root` kind, both the collection and single records. Compare the general and nicer-looking methods as follows: + +```js +// Collection +wp.data.select( 'core' ).getEntityRecords( 'root' 'user' ); +wp.data.select( 'core' ).getUsers(); + +// Single record +wp.data.select( 'core' ).getEntityRecord( 'root', 'user', recordId ); +wp.data.select( 'core' ).getUser( recordId ); +``` + +Sometimes, the pluralized form of an entity is not regular (it is not formed by adding a `-s` suffix). The `plural` property of the entity config allows to declare an alternative pluralized form for the dynamic methods created for the entity. For example, given the `status` entity that declares the `statuses` plural, there are the following methods created for it: + +```js +// Collection +wp.data.select( 'core' ).getStatuses(); + +// Single record +wp.data.select( 'core' ).getStatus( recordId ); +``` + ## Actions The following set of dispatching action creators are available on the object returned by `wp.data.dispatch( 'core' )`: From 7aa5a913473c223402549a9060aafac652153965 Mon Sep 17 00:00:00 2001 From: Dave Smith Date: Tue, 10 Oct 2023 10:31:47 +0100 Subject: [PATCH 032/239] Enable Block Renaming support for (almost) all blocks (#54426) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Enable Rename support for various blocks * Register metadata attribute for all blocks * Use new block supports and default to true for all blocks * Selectively disable for certain blocks * Remove unused supports * Consider accessibility mode when computing paragraph label * Rename hook to supports feature * Docs * Remove redundant metadata support from paragraph * Add test for block’s that don’t support rename * Move registration of metadata attribute out of experimental * Lint * Make block support non-experimental See https://github.com/WordPress/gutenberg/pull/54426/files#r1334343353 * Revert unintended formatting changes * Rename support to just “renaming” * Remove unneeded filter This also caused unit test failures in packages/editor/src/store/test/selectors.js --- docs/reference-guides/core-blocks.md | 8 +-- lib/blocks.php | 22 +++++++ lib/experimental/blocks.php | 21 ------- .../block-editor/src/hooks/block-rename-ui.js | 12 +--- .../{metadata-name.js => block-renaming.js} | 11 ++-- packages/block-editor/src/hooks/index.js | 2 +- packages/block-editor/src/hooks/metadata.js | 44 ++------------ packages/block-library/src/block/block.json | 3 +- packages/block-library/src/group/block.json | 1 - packages/block-library/src/heading/index.js | 6 +- .../block-library/src/navigation/block.json | 3 +- packages/block-library/src/paragraph/index.js | 10 ++++ packages/block-library/src/pattern/block.json | 3 +- .../src/template-part/block.json | 3 +- .../editor/various/block-renaming.spec.js | 57 +++++++++++++++++++ 15 files changed, 118 insertions(+), 88 deletions(-) rename packages/block-editor/src/hooks/{metadata-name.js => block-renaming.js} (84%) diff --git a/docs/reference-guides/core-blocks.md b/docs/reference-guides/core-blocks.md index ffd45a7cb8c75d..241c10141ae155 100644 --- a/docs/reference-guides/core-blocks.md +++ b/docs/reference-guides/core-blocks.md @@ -41,7 +41,7 @@ Create and save content to reuse across your site. Update the pattern, and the c - **Name:** core/block - **Category:** reusable -- **Supports:** ~~customClassName~~, ~~html~~, ~~inserter~~ +- **Supports:** ~~customClassName~~, ~~html~~, ~~inserter~~, ~~renaming~~ - **Attributes:** ref ## Button @@ -459,7 +459,7 @@ A collection of blocks that allow visitors to get around your site. ([Source](ht - **Name:** core/navigation - **Category:** theme -- **Supports:** align (full, wide), ariaLabel, inserter, interactivity, layout (allowSizingOnChildren, default, ~~allowInheriting~~, ~~allowSwitching~~, ~~allowVerticalAlignment~~), spacing (blockGap, units), typography (fontSize, lineHeight), ~~html~~ +- **Supports:** align (full, wide), ariaLabel, inserter, interactivity, layout (allowSizingOnChildren, default, ~~allowInheriting~~, ~~allowSwitching~~, ~~allowVerticalAlignment~~), spacing (blockGap, units), typography (fontSize, lineHeight), ~~html~~, ~~renaming~~ - **Attributes:** __unstableLocation, backgroundColor, customBackgroundColor, customOverlayBackgroundColor, customOverlayTextColor, customTextColor, hasIcon, icon, maxNestingLevel, openSubmenusOnClick, overlayBackgroundColor, overlayMenu, overlayTextColor, ref, rgbBackgroundColor, rgbTextColor, showSubmenuIcon, templateLock, textColor ## Custom Link @@ -526,7 +526,7 @@ Show a block pattern. ([Source](https://github.com/WordPress/gutenberg/tree/trun - **Name:** core/pattern - **Category:** theme -- **Supports:** ~~html~~, ~~inserter~~ +- **Supports:** ~~html~~, ~~inserter~~, ~~renaming~~ - **Attributes:** slug ## Author @@ -907,7 +907,7 @@ Edit the different global regions of your site, like the header, footer, sidebar - **Name:** core/template-part - **Category:** theme -- **Supports:** align, ~~html~~, ~~reusable~~ +- **Supports:** align, ~~html~~, ~~renaming~~, ~~reusable~~ - **Attributes:** area, slug, tagName, theme ## Term Description diff --git a/lib/blocks.php b/lib/blocks.php index 1794762b010dbd..39ac57d8b6d096 100644 --- a/lib/blocks.php +++ b/lib/blocks.php @@ -466,3 +466,25 @@ function gutenberg_should_render_lightbox( $block ) { } add_filter( 'render_block_data', 'gutenberg_should_render_lightbox', 15, 1 ); + +/** + * Registers the metadata block attribute for all block types. + * + * @param array $args Array of arguments for registering a block type. + * @return array $args + */ +function gutenberg_register_metadata_attribute( $args ) { + // Setup attributes if needed. + if ( ! isset( $args['attributes'] ) || ! is_array( $args['attributes'] ) ) { + $args['attributes'] = array(); + } + + if ( ! array_key_exists( 'metadata', $args['attributes'] ) ) { + $args['attributes']['metadata'] = array( + 'type' => 'object', + ); + } + + return $args; +} +add_filter( 'register_block_type_args', 'gutenberg_register_metadata_attribute' ); diff --git a/lib/experimental/blocks.php b/lib/experimental/blocks.php index 2102834dc6a15a..f9f2412ae51205 100644 --- a/lib/experimental/blocks.php +++ b/lib/experimental/blocks.php @@ -79,27 +79,6 @@ function wp_enqueue_block_view_script( $block_name, $args ) { } -/** - * Registers the metadata block attribute for block types. - * - * @param array $args Array of arguments for registering a block type. - * @return array $args - */ -function gutenberg_register_metadata_attribute( $args ) { - // Setup attributes if needed. - if ( ! isset( $args['attributes'] ) || ! is_array( $args['attributes'] ) ) { - $args['attributes'] = array(); - } - - if ( ! array_key_exists( 'metadata', $args['attributes'] ) ) { - $args['attributes']['metadata'] = array( - 'type' => 'object', - ); - } - - return $args; -} -add_filter( 'register_block_type_args', 'gutenberg_register_metadata_attribute' ); $gutenberg_experiments = get_option( 'gutenberg-experiments' ); diff --git a/packages/block-editor/src/hooks/block-rename-ui.js b/packages/block-editor/src/hooks/block-rename-ui.js index 9025bfee619839..6a98dcf2e2fad7 100644 --- a/packages/block-editor/src/hooks/block-rename-ui.js +++ b/packages/block-editor/src/hooks/block-rename-ui.js @@ -4,7 +4,7 @@ import { createHigherOrderComponent, useInstanceId } from '@wordpress/compose'; import { addFilter } from '@wordpress/hooks'; import { __, sprintf } from '@wordpress/i18n'; -import { getBlockSupport } from '@wordpress/blocks'; +import { hasBlockSupport } from '@wordpress/blocks'; import { MenuItem, __experimentalHStack as HStack, @@ -191,15 +191,7 @@ export const withBlockRenameControl = createHigherOrderComponent( ( BlockEdit ) => ( props ) => { const { clientId, name, attributes, setAttributes } = props; - const metaDataSupport = getBlockSupport( - name, - '__experimentalMetadata', - false - ); - - const supportsBlockNaming = !! ( - true === metaDataSupport || metaDataSupport?.name - ); + const supportsBlockNaming = hasBlockSupport( name, 'renaming', true ); return ( <> diff --git a/packages/block-editor/src/hooks/metadata-name.js b/packages/block-editor/src/hooks/block-renaming.js similarity index 84% rename from packages/block-editor/src/hooks/metadata-name.js rename to packages/block-editor/src/hooks/block-renaming.js index 6eecb0ce3667c4..5db06d1a652d41 100644 --- a/packages/block-editor/src/hooks/metadata-name.js +++ b/packages/block-editor/src/hooks/block-renaming.js @@ -2,10 +2,7 @@ * WordPress dependencies */ import { addFilter } from '@wordpress/hooks'; -/** - * Internal dependencies - */ -import { hasBlockMetadataSupport } from './metadata'; +import { hasBlockSupport } from '@wordpress/blocks'; /** * Filters registered block settings, adding an `__experimentalLabel` callback if one does not already exist. @@ -20,10 +17,10 @@ export function addLabelCallback( settings ) { return settings; } - const supportsBlockNaming = hasBlockMetadataSupport( + const supportsBlockNaming = hasBlockSupport( settings, - 'name', - false // default value + 'renaming', + true // default value ); // Check whether block metadata is supported before using it. diff --git a/packages/block-editor/src/hooks/index.js b/packages/block-editor/src/hooks/index.js index 8ae5c1dbe3a7e7..730f0defe0a635 100644 --- a/packages/block-editor/src/hooks/index.js +++ b/packages/block-editor/src/hooks/index.js @@ -19,9 +19,9 @@ import './position'; import './layout'; import './content-lock-ui'; import './metadata'; -import './metadata-name'; import './custom-fields'; import './block-hooks'; +import './block-renaming'; import './block-rename-ui'; export { useCustomSides } from './dimensions'; diff --git a/packages/block-editor/src/hooks/metadata.js b/packages/block-editor/src/hooks/metadata.js index 918f4f80ee9c27..8b938f1348f1c1 100644 --- a/packages/block-editor/src/hooks/metadata.js +++ b/packages/block-editor/src/hooks/metadata.js @@ -2,19 +2,8 @@ * WordPress dependencies */ import { addFilter } from '@wordpress/hooks'; -import { getBlockSupport } from '@wordpress/blocks'; - const META_ATTRIBUTE_NAME = 'metadata'; -export function hasBlockMetadataSupport( blockType, feature = '' ) { - // Only core blocks are allowed to use __experimentalMetadata until the fetaure is stablised. - if ( ! blockType.name.startsWith( 'core/' ) ) { - return false; - } - const support = getBlockSupport( blockType, '__experimentalMetadata' ); - return !! ( true === support || support?.[ feature ] ); -} - /** * Filters registered block settings, extending attributes to include `metadata`. * @@ -29,39 +18,18 @@ export function addMetaAttribute( blockTypeSettings ) { return blockTypeSettings; } - const supportsBlockNaming = hasBlockMetadataSupport( - blockTypeSettings, - 'name' - ); - - if ( supportsBlockNaming ) { - blockTypeSettings.attributes = { - ...blockTypeSettings.attributes, - [ META_ATTRIBUTE_NAME ]: { - type: 'object', - }, - }; - } + blockTypeSettings.attributes = { + ...blockTypeSettings.attributes, + [ META_ATTRIBUTE_NAME ]: { + type: 'object', + }, + }; return blockTypeSettings; } -export function addSaveProps( extraProps, blockType, attributes ) { - if ( hasBlockMetadataSupport( blockType ) ) { - extraProps[ META_ATTRIBUTE_NAME ] = attributes[ META_ATTRIBUTE_NAME ]; - } - - return extraProps; -} - addFilter( 'blocks.registerBlockType', 'core/metadata/addMetaAttribute', addMetaAttribute ); - -addFilter( - 'blocks.getSaveContent.extraProps', - 'core/metadata/save-props', - addSaveProps -); diff --git a/packages/block-library/src/block/block.json b/packages/block-library/src/block/block.json index 4cb53960725d21..aeccdbfc1051db 100644 --- a/packages/block-library/src/block/block.json +++ b/packages/block-library/src/block/block.json @@ -15,6 +15,7 @@ "supports": { "customClassName": false, "html": false, - "inserter": false + "inserter": false, + "renaming": false } } diff --git a/packages/block-library/src/group/block.json b/packages/block-library/src/group/block.json index 4b89d865391172..92bbc1b0d11352 100644 --- a/packages/block-library/src/group/block.json +++ b/packages/block-library/src/group/block.json @@ -24,7 +24,6 @@ "__experimentalOnEnter": true, "__experimentalOnMerge": true, "__experimentalSettings": true, - "__experimentalMetadata": true, "align": [ "wide", "full" ], "anchor": true, "ariaLabel": true, diff --git a/packages/block-library/src/heading/index.js b/packages/block-library/src/heading/index.js index 4ff1203df33fcc..3752ca70bc7142 100644 --- a/packages/block-library/src/heading/index.js +++ b/packages/block-library/src/heading/index.js @@ -29,10 +29,12 @@ export const settings = { __experimentalLabel( attributes, { context } ) { const { content, level } = attributes; + const customName = attributes?.metadata?.name; + // In the list view, use the block's content as the label. // If the content is empty, fall back to the default label. - if ( context === 'list-view' && content ) { - return content; + if ( context === 'list-view' && ( customName || content ) ) { + return attributes?.metadata?.name || content; } if ( context === 'accessibility' ) { diff --git a/packages/block-library/src/navigation/block.json b/packages/block-library/src/navigation/block.json index cb5ca4fec1b90f..9ec919ae38d1fa 100644 --- a/packages/block-library/src/navigation/block.json +++ b/packages/block-library/src/navigation/block.json @@ -133,7 +133,8 @@ } } }, - "interactivity": true + "interactivity": true, + "renaming": false }, "viewScript": "file:./view.min.js", "editorStyle": "wp-block-navigation-editor", diff --git a/packages/block-library/src/paragraph/index.js b/packages/block-library/src/paragraph/index.js index bceff881367074..715fb35ec05ab1 100644 --- a/packages/block-library/src/paragraph/index.js +++ b/packages/block-library/src/paragraph/index.js @@ -28,7 +28,17 @@ export const settings = { }, }, __experimentalLabel( attributes, { context } ) { + const customName = attributes?.metadata?.name; + + if ( context === 'list-view' && customName ) { + return customName; + } + if ( context === 'accessibility' ) { + if ( customName ) { + return customName; + } + const { content } = attributes; return ! content || content.length === 0 ? __( 'Empty' ) : content; } diff --git a/packages/block-library/src/pattern/block.json b/packages/block-library/src/pattern/block.json index e9a85a9b2f84f1..da02f7b72747e4 100644 --- a/packages/block-library/src/pattern/block.json +++ b/packages/block-library/src/pattern/block.json @@ -7,7 +7,8 @@ "description": "Show a block pattern.", "supports": { "html": false, - "inserter": false + "inserter": false, + "renaming": false }, "textdomain": "default", "attributes": { diff --git a/packages/block-library/src/template-part/block.json b/packages/block-library/src/template-part/block.json index 9fe431150ae392..3b0946718bcb9c 100644 --- a/packages/block-library/src/template-part/block.json +++ b/packages/block-library/src/template-part/block.json @@ -23,7 +23,8 @@ "supports": { "align": true, "html": false, - "reusable": false + "reusable": false, + "renaming": false }, "editorStyle": "wp-block-template-part-editor" } diff --git a/test/e2e/specs/editor/various/block-renaming.spec.js b/test/e2e/specs/editor/various/block-renaming.spec.js index 1c8a958b23fd41..4150be64bd33d6 100644 --- a/test/e2e/specs/editor/various/block-renaming.spec.js +++ b/test/e2e/specs/editor/various/block-renaming.spec.js @@ -145,6 +145,27 @@ test.describe( 'Block Renaming', () => { }, ] ); } ); + + test( 'does not allow renaming of blocks that do not support renaming', async ( { + // use `core/template-part` as the block + editor, + page, + } ) => { + await editor.insertBlock( { + name: 'core/navigation', + } ); + + // Opens the block options menu and check there is not a `Rename` option + await editor.clickBlockToolbarButton( 'Options' ); + // + + const renameMenuItem = page.getByRole( 'menuitem', { + name: 'Rename', + } ); + + // TODO: assert that the locator didn't find a DOM node at all. + await expect( renameMenuItem ).toBeHidden(); + } ); } ); test.describe( 'Block inspector renaming', () => { @@ -219,5 +240,41 @@ test.describe( 'Block Renaming', () => { }, ] ); } ); + + test( 'does not allow renaming of blocks that do not support renaming', async ( { + editor, + page, + } ) => { + await editor.insertBlock( { + name: 'core/navigation', + } ); + + await editor.openDocumentSettingsSidebar(); + + const settingsTab = page + .getByRole( 'region', { + name: 'Editor settings', + } ) + .getByRole( 'tab', { name: 'Settings' } ); + + await settingsTab.click(); + + const advancedPanelToggle = page + .getByRole( 'region', { + name: 'Editor settings', + } ) + .getByRole( 'button', { + name: 'Advanced', + expanded: false, + } ); + + await advancedPanelToggle.click(); + + const nameInput = page.getByRole( 'textbox', { + name: 'Block name', + } ); + + await expect( nameInput ).toBeHidden(); + } ); } ); } ); From 9d15719d3d1b7d43058398001d542712d9b42739 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dami=C3=A1n=20Su=C3=A1rez?= Date: Tue, 10 Oct 2023 10:53:00 +0100 Subject: [PATCH 033/239] Editor: use hooks instead of HoCs in PostExcerpt (#55189) * Editor: use hooks instead of HoCs in PostExcerpt * remove unneeded isRemoved handling --- .../components/sidebar/post-excerpt/index.js | 37 ++++++++----------- 1 file changed, 16 insertions(+), 21 deletions(-) diff --git a/packages/edit-post/src/components/sidebar/post-excerpt/index.js b/packages/edit-post/src/components/sidebar/post-excerpt/index.js index b2d56808d64c79..bee78681b2b246 100644 --- a/packages/edit-post/src/components/sidebar/post-excerpt/index.js +++ b/packages/edit-post/src/components/sidebar/post-excerpt/index.js @@ -7,8 +7,7 @@ import { PostExcerpt as PostExcerptForm, PostExcerptCheck, } from '@wordpress/editor'; -import { compose } from '@wordpress/compose'; -import { withSelect, withDispatch } from '@wordpress/data'; +import { useDispatch, useSelect } from '@wordpress/data'; /** * Internal dependencies @@ -20,7 +19,20 @@ import { store as editPostStore } from '../../../store'; */ const PANEL_NAME = 'post-excerpt'; -function PostExcerpt( { isEnabled, isOpened, onTogglePanel } ) { +export default function PostExcerpt() { + const { isOpened, isEnabled } = useSelect( ( select ) => { + const { isEditorPanelOpened, isEditorPanelEnabled } = + select( editPostStore ); + + return { + isOpened: isEditorPanelOpened( PANEL_NAME ), + isEnabled: isEditorPanelEnabled( PANEL_NAME ), + }; + }, [] ); + + const { toggleEditorPanelOpened } = useDispatch( editPostStore ); + const toggleExcerptPanel = () => toggleEditorPanelOpened( PANEL_NAME ); + if ( ! isEnabled ) { return null; } @@ -30,27 +42,10 @@ function PostExcerpt( { isEnabled, isOpened, onTogglePanel } ) { ); } - -export default compose( [ - withSelect( ( select ) => { - return { - isEnabled: - select( editPostStore ).isEditorPanelEnabled( PANEL_NAME ), - isOpened: select( editPostStore ).isEditorPanelOpened( PANEL_NAME ), - }; - } ), - withDispatch( ( dispatch ) => ( { - onTogglePanel() { - return dispatch( editPostStore ).toggleEditorPanelOpened( - PANEL_NAME - ); - }, - } ) ), -] )( PostExcerpt ); From da1290d5820a2ef24176d1b87ade0a602f00e141 Mon Sep 17 00:00:00 2001 From: Riad Benguella Date: Tue, 10 Oct 2023 11:25:59 +0100 Subject: [PATCH 034/239] Core Data: Retrieve the pagination totals in the getEntityRecords calls (#55164) --- docs/reference-guides/data/data-core.md | 31 ++++++++++ packages/core-data/CHANGELOG.md | 4 ++ packages/core-data/README.md | 31 ++++++++++ packages/core-data/src/actions.js | 8 ++- packages/core-data/src/entities.js | 2 + .../src/hooks/test/use-entity-records.js | 4 ++ .../core-data/src/hooks/use-entity-records.ts | 37 ++++++++++++ .../core-data/src/queried-data/actions.js | 9 ++- .../core-data/src/queried-data/reducer.js | 27 +++++---- .../core-data/src/queried-data/selectors.js | 14 ++++- .../src/queried-data/test/reducer.js | 18 +++--- .../src/queried-data/test/selectors.js | 16 ++--- packages/core-data/src/resolvers.js | 27 ++++++++- packages/core-data/src/selectors.ts | 60 ++++++++++++++++++- packages/core-data/src/test/resolvers.js | 5 +- packages/core-data/src/test/selectors.js | 8 ++- .../src/components/page-pages/index.js | 46 +++++--------- 17 files changed, 275 insertions(+), 72 deletions(-) diff --git a/docs/reference-guides/data/data-core.md b/docs/reference-guides/data/data-core.md index acaaa5f23f1b98..8a190869f99e78 100644 --- a/docs/reference-guides/data/data-core.md +++ b/docs/reference-guides/data/data-core.md @@ -299,6 +299,36 @@ _Returns_ - `EntityRecord[] | null`: Records. +### getEntityRecordsTotalItems + +Returns the Entity's total available records for a given query (ignoring pagination). + +_Parameters_ + +- _state_ `State`: State tree +- _kind_ `string`: Entity kind. +- _name_ `string`: Entity name. +- _query_ `GetRecordsHttpQuery`: Optional terms query. If requesting specific fields, fields must always include the ID. For valid query parameters see the [Reference](https://developer.wordpress.org/rest-api/reference/) in the REST API Handbook and select the entity kind. Then see the arguments available for "List [Entity kind]s". + +_Returns_ + +- `number | null`: number | null. + +### getEntityRecordsTotalPages + +Returns the number of available pages for the given query. + +_Parameters_ + +- _state_ `State`: State tree +- _kind_ `string`: Entity kind. +- _name_ `string`: Entity name. +- _query_ `GetRecordsHttpQuery`: Optional terms query. If requesting specific fields, fields must always include the ID. For valid query parameters see the [Reference](https://developer.wordpress.org/rest-api/reference/) in the REST API Handbook and select the entity kind. Then see the arguments available for "List [Entity kind]s". + +_Returns_ + +- `number | null`: number | null. + ### getLastEntityDeleteError Returns the specified entity record's last delete error. @@ -630,6 +660,7 @@ _Parameters_ - _query_ `?Object`: Query Object. - _invalidateCache_ `?boolean`: Should invalidate query caches. - _edits_ `?Object`: Edits to reset. +- _meta_ `?Object`: Meta information about pagination. _Returns_ diff --git a/packages/core-data/CHANGELOG.md b/packages/core-data/CHANGELOG.md index 99ad93050f6478..817a6cc924bb74 100644 --- a/packages/core-data/CHANGELOG.md +++ b/packages/core-data/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +## Enhancements + +- Add `getEntityRecordsTotalItems` and `getEntityRecordsTotalPages` selectors. [#55164](https://github.com/WordPress/gutenberg/pull/55164). + ## 6.20.0 (2023-10-05) ## 6.19.0 (2023-09-20) diff --git a/packages/core-data/README.md b/packages/core-data/README.md index 1493aee5bf22bb..a20e86e9695a26 100644 --- a/packages/core-data/README.md +++ b/packages/core-data/README.md @@ -217,6 +217,7 @@ _Parameters_ - _query_ `?Object`: Query Object. - _invalidateCache_ `?boolean`: Should invalidate query caches. - _edits_ `?Object`: Edits to reset. +- _meta_ `?Object`: Meta information about pagination. _Returns_ @@ -592,6 +593,36 @@ _Returns_ - `EntityRecord[] | null`: Records. +### getEntityRecordsTotalItems + +Returns the Entity's total available records for a given query (ignoring pagination). + +_Parameters_ + +- _state_ `State`: State tree +- _kind_ `string`: Entity kind. +- _name_ `string`: Entity name. +- _query_ `GetRecordsHttpQuery`: Optional terms query. If requesting specific fields, fields must always include the ID. For valid query parameters see the [Reference](https://developer.wordpress.org/rest-api/reference/) in the REST API Handbook and select the entity kind. Then see the arguments available for "List [Entity kind]s". + +_Returns_ + +- `number | null`: number | null. + +### getEntityRecordsTotalPages + +Returns the number of available pages for the given query. + +_Parameters_ + +- _state_ `State`: State tree +- _kind_ `string`: Entity kind. +- _name_ `string`: Entity name. +- _query_ `GetRecordsHttpQuery`: Optional terms query. If requesting specific fields, fields must always include the ID. For valid query parameters see the [Reference](https://developer.wordpress.org/rest-api/reference/) in the REST API Handbook and select the entity kind. Then see the arguments available for "List [Entity kind]s". + +_Returns_ + +- `number | null`: number | null. + ### getLastEntityDeleteError Returns the specified entity record's last delete error. diff --git a/packages/core-data/src/actions.js b/packages/core-data/src/actions.js index 3f6c3c12432f58..c4b19819ed7a41 100644 --- a/packages/core-data/src/actions.js +++ b/packages/core-data/src/actions.js @@ -80,6 +80,7 @@ export function addEntities( entities ) { * @param {?Object} query Query Object. * @param {?boolean} invalidateCache Should invalidate query caches. * @param {?Object} edits Edits to reset. + * @param {?Object} meta Meta information about pagination. * @return {Object} Action object. */ export function receiveEntityRecords( @@ -88,7 +89,8 @@ export function receiveEntityRecords( records, query, invalidateCache = false, - edits + edits, + meta ) { // Auto drafts should not have titles, but some plugins rely on them so we can't filter this // on the server. @@ -102,9 +104,9 @@ export function receiveEntityRecords( } let action; if ( query ) { - action = receiveQueriedItems( records, query, edits ); + action = receiveQueriedItems( records, query, edits, meta ); } else { - action = receiveItems( records, edits ); + action = receiveItems( records, edits, meta ); } return { diff --git a/packages/core-data/src/entities.js b/packages/core-data/src/entities.js index 1c952af4a05a86..af8829d0bc852c 100644 --- a/packages/core-data/src/entities.js +++ b/packages/core-data/src/entities.js @@ -120,6 +120,7 @@ export const rootEntitiesConfig = [ plural: 'mediaItems', label: __( 'Media' ), rawAttributes: [ 'caption', 'title', 'description' ], + supportsPagination: true, }, { name: 'taxonomy', @@ -326,6 +327,7 @@ async function loadPostTypeEntities() { }, syncObjectType: 'postType/' + postType.name, getSyncObjectId: ( id ) => id, + supportsPagination: true, }; } ); } diff --git a/packages/core-data/src/hooks/test/use-entity-records.js b/packages/core-data/src/hooks/test/use-entity-records.js index d75d2e8af849c9..af490b65096290 100644 --- a/packages/core-data/src/hooks/test/use-entity-records.js +++ b/packages/core-data/src/hooks/test/use-entity-records.js @@ -51,6 +51,8 @@ describe( 'useEntityRecords', () => { hasResolved: false, isResolving: false, status: 'IDLE', + totalItems: null, + totalPages: null, } ); // Fetch request should have been issued @@ -65,6 +67,8 @@ describe( 'useEntityRecords', () => { hasResolved: true, isResolving: false, status: 'SUCCESS', + totalItems: null, + totalPages: null, } ); } ); } ); diff --git a/packages/core-data/src/hooks/use-entity-records.ts b/packages/core-data/src/hooks/use-entity-records.ts index ff72ea4078c0e4..5d643ab8896925 100644 --- a/packages/core-data/src/hooks/use-entity-records.ts +++ b/packages/core-data/src/hooks/use-entity-records.ts @@ -3,6 +3,7 @@ */ import { addQueryArgs } from '@wordpress/url'; import deprecated from '@wordpress/deprecated'; +import { useSelect } from '@wordpress/data'; /** * Internal dependencies @@ -28,6 +29,16 @@ interface EntityRecordsResolution< RecordType > { /** Resolution status */ status: Status; + + /** + * The total number of available items (if not paginated). + */ + totalItems: number | null; + + /** + * The total number of pages. + */ + totalPages: number | null; } const EMPTY_ARRAY = []; @@ -97,8 +108,34 @@ export default function useEntityRecords< RecordType >( [ kind, name, queryAsString, options.enabled ] ); + const { totalItems, totalPages } = useSelect( + ( select ) => { + if ( ! options.enabled ) { + return { + totalItems: null, + totalPages: null, + }; + } + return { + totalItems: select( coreStore ).getEntityRecordsTotalItems( + kind, + name, + queryArgs + ), + totalPages: select( coreStore ).getEntityRecordsTotalPages( + kind, + name, + queryArgs + ), + }; + }, + [ kind, name, queryAsString, options.enabled ] + ); + return { records, + totalItems, + totalPages, ...rest, }; } diff --git a/packages/core-data/src/queried-data/actions.js b/packages/core-data/src/queried-data/actions.js index 58416713aebdb4..5a1a1acb48c64c 100644 --- a/packages/core-data/src/queried-data/actions.js +++ b/packages/core-data/src/queried-data/actions.js @@ -3,14 +3,16 @@ * * @param {Array} items Items received. * @param {?Object} edits Optional edits to reset. + * @param {?Object} meta Meta information about pagination. * * @return {Object} Action object. */ -export function receiveItems( items, edits ) { +export function receiveItems( items, edits, meta ) { return { type: 'RECEIVE_ITEMS', items: Array.isArray( items ) ? items : [ items ], persistedEdits: edits, + meta, }; } @@ -41,12 +43,13 @@ export function removeItems( kind, name, records, invalidateCache = false ) { * @param {Array} items Queried items received. * @param {?Object} query Optional query object. * @param {?Object} edits Optional edits to reset. + * @param {?Object} meta Meta information about pagination. * * @return {Object} Action object. */ -export function receiveQueriedItems( items, query = {}, edits ) { +export function receiveQueriedItems( items, query = {}, edits, meta ) { return { - ...receiveItems( items, edits ), + ...receiveItems( items, edits, meta ), query, }; } diff --git a/packages/core-data/src/queried-data/reducer.js b/packages/core-data/src/queried-data/reducer.js index 8dfdca404e1a8c..40b0a9ba1f080b 100644 --- a/packages/core-data/src/queried-data/reducer.js +++ b/packages/core-data/src/queried-data/reducer.js @@ -222,19 +222,22 @@ const receiveQueries = compose( [ // Queries shape is shared, but keyed by query `stableKey` part. Original // reducer tracks only a single query object. onSubKey( 'stableKey' ), -] )( ( state = null, action ) => { +] )( ( state = {}, action ) => { const { type, page, perPage, key = DEFAULT_ENTITY_KEY } = action; if ( type !== 'RECEIVE_ITEMS' ) { return state; } - return getMergedItemIds( - state || [], - action.items.map( ( item ) => item[ key ] ), - page, - perPage - ); + return { + itemIds: getMergedItemIds( + state?.itemIds || [], + action.items.map( ( item ) => item[ key ] ), + page, + perPage + ), + meta: action.meta, + }; } ); /** @@ -263,9 +266,13 @@ const queries = ( state = {}, action ) => { Object.entries( contextQueries ).map( ( [ query, queryItems ] ) => [ query, - queryItems.filter( - ( queryId ) => ! removedItems[ queryId ] - ), + { + ...queryItems, + itemIds: queryItems.itemIds.filter( + ( queryId ) => + ! removedItems[ queryId ] + ), + }, ] ) ), diff --git a/packages/core-data/src/queried-data/selectors.js b/packages/core-data/src/queried-data/selectors.js index 562aab7ce6b79b..ec2ef9beb23ae8 100644 --- a/packages/core-data/src/queried-data/selectors.js +++ b/packages/core-data/src/queried-data/selectors.js @@ -33,7 +33,7 @@ function getQueriedItemsUncached( state, query ) { let itemIds; if ( state.queries?.[ context ]?.[ stableKey ] ) { - itemIds = state.queries[ context ][ stableKey ]; + itemIds = state.queries[ context ][ stableKey ].itemIds; } if ( ! itemIds ) { @@ -118,3 +118,15 @@ export const getQueriedItems = createSelector( ( state, query = {} ) => { queriedItemsCache.set( query, items ); return items; } ); + +export function getQueriedTotalItems( state, query = {} ) { + const { stableKey, context } = getQueryParts( query ); + + return state.queries?.[ context ]?.[ stableKey ]?.meta?.totalItems ?? null; +} + +export function getQueriedTotalPages( state, query = {} ) { + const { stableKey, context } = getQueryParts( query ); + + return state.queries?.[ context ]?.[ stableKey ]?.meta?.totalPages ?? null; +} diff --git a/packages/core-data/src/queried-data/test/reducer.js b/packages/core-data/src/queried-data/test/reducer.js index 76657fca61da1b..4271f8d80a4a3e 100644 --- a/packages/core-data/src/queried-data/test/reducer.js +++ b/packages/core-data/src/queried-data/test/reducer.js @@ -159,7 +159,7 @@ describe( 'reducer', () => { default: { 1: true }, }, queries: { - default: { 's=a': [ 1 ] }, + default: { 's=a': { itemIds: [ 1 ] } }, }, } ); } ); @@ -200,8 +200,8 @@ describe( 'reducer', () => { }, queries: { default: { - '': [ 1, 2, 3, 4 ], - 's=a': [ 1, 3 ], + '': { itemIds: [ 1, 2, 3, 4 ] }, + 's=a': { itemIds: [ 1, 3 ] }, }, }, } ); @@ -218,8 +218,8 @@ describe( 'reducer', () => { }, queries: { default: { - '': [ 1, 2, 4 ], - 's=a': [ 1 ], + '': { itemIds: [ 1, 2, 4 ] }, + 's=a': { itemIds: [ 1 ] }, }, }, } ); @@ -238,8 +238,8 @@ describe( 'reducer', () => { }, queries: { default: { - '': [ 'foo//bar1', 'foo//bar2', 'foo//bar3' ], - 's=2': [ 'foo//bar2' ], + '': { itemIds: [ 'foo//bar1', 'foo//bar2', 'foo//bar3' ] }, + 's=2': { itemIds: [ 'foo//bar2' ] }, }, }, } ); @@ -258,8 +258,8 @@ describe( 'reducer', () => { }, queries: { default: { - '': [ 'foo//bar1', 'foo//bar3' ], - 's=2': [], + '': { itemIds: [ 'foo//bar1', 'foo//bar3' ] }, + 's=2': { itemIds: [] }, }, }, } ); diff --git a/packages/core-data/src/queried-data/test/selectors.js b/packages/core-data/src/queried-data/test/selectors.js index f0a38aab2887e1..3ec1e085c47bb2 100644 --- a/packages/core-data/src/queried-data/test/selectors.js +++ b/packages/core-data/src/queried-data/test/selectors.js @@ -32,7 +32,7 @@ describe( 'getQueriedItems', () => { }, queries: { default: { - '': [ 1, 2 ], + '': { itemIds: [ 1, 2 ] }, }, }, }; @@ -56,7 +56,7 @@ describe( 'getQueriedItems', () => { 2: true, }, }, - queries: [ 1, 2 ], + queries: { itemIds: [ 1, 2 ] }, }; const resultA = getQueriedItems( state, {} ); @@ -81,8 +81,8 @@ describe( 'getQueriedItems', () => { }, queries: { default: { - '': [ 1, 2 ], - 'include=1': [ 1 ], + '': { itemIds: [ 1, 2 ] }, + 'include=1': { itemIds: [ 1 ] }, }, }, }; @@ -116,7 +116,7 @@ describe( 'getQueriedItems', () => { }, queries: { default: { - '_fields=content': [ 1, 2 ], + '_fields=content': { itemIds: [ 1, 2 ] }, }, }, }; @@ -161,7 +161,7 @@ describe( 'getQueriedItems', () => { }, queries: { default: { - '_fields=content%2Cmeta.template': [ 1, 2 ], + '_fields=content%2Cmeta.template': { itemIds: [ 1, 2 ] }, }, }, }; @@ -198,7 +198,7 @@ describe( 'getQueriedItems', () => { }, queries: { default: { - '': [ 1, 2 ], + '': { itemIds: [ 1, 2 ] }, }, }, }; @@ -230,7 +230,7 @@ describe( 'getQueriedItems', () => { }, queries: { default: { - '': [ 1, 2 ], + '': { itemIds: [ 1, 2 ] }, }, }, }; diff --git a/packages/core-data/src/resolvers.js b/packages/core-data/src/resolvers.js index e8cf4e34a120ff..01b1db8d87d21d 100644 --- a/packages/core-data/src/resolvers.js +++ b/packages/core-data/src/resolvers.js @@ -228,7 +228,22 @@ export const getEntityRecords = ...query, } ); - let records = Object.values( await apiFetch( { path } ) ); + let records, meta; + if ( entityConfig.supportsPagination && query.per_page !== -1 ) { + const response = await apiFetch( { path, parse: false } ); + records = Object.values( await response.json() ); + meta = { + totalPages: parseInt( + response.headers.get( 'X-WP-TotalPages' ) + ), + totalItems: parseInt( + response.headers.get( 'X-WP-Total' ) + ), + }; + } else { + records = Object.values( await apiFetch( { path } ) ); + } + // If we request fields but the result doesn't contain the fields, // explicitly set these fields as "undefined" // that way we consider the query "fullfilled". @@ -244,7 +259,15 @@ export const getEntityRecords = } ); } - dispatch.receiveEntityRecords( kind, name, records, query ); + dispatch.receiveEntityRecords( + kind, + name, + records, + query, + false, + undefined, + meta + ); // When requesting all fields, the list of results can be used to // resolve the `getEntityRecord` selector in addition to `getEntityRecords`. diff --git a/packages/core-data/src/selectors.ts b/packages/core-data/src/selectors.ts index 4e582bcf8a34ba..5d6fda85e557b6 100644 --- a/packages/core-data/src/selectors.ts +++ b/packages/core-data/src/selectors.ts @@ -14,7 +14,11 @@ import deprecated from '@wordpress/deprecated'; * Internal dependencies */ import { STORE_NAME } from './name'; -import { getQueriedItems } from './queried-data'; +import { + getQueriedItems, + getQueriedTotalItems, + getQueriedTotalPages, +} from './queried-data'; import { DEFAULT_ENTITY_KEY } from './entities'; import { getNormalizedCommaSeparable, @@ -523,6 +527,60 @@ export const getEntityRecords = ( < return getQueriedItems( queriedState, query ); } ) as GetEntityRecords; +/** + * Returns the Entity's total available records for a given query (ignoring pagination). + * + * @param state State tree + * @param kind Entity kind. + * @param name Entity name. + * @param query Optional terms query. If requesting specific + * fields, fields must always include the ID. For valid query parameters see the [Reference](https://developer.wordpress.org/rest-api/reference/) in the REST API Handbook and select the entity kind. Then see the arguments available for "List [Entity kind]s". + * + * @return number | null. + */ +export const getEntityRecordsTotalItems = ( + state: State, + kind: string, + name: string, + query: GetRecordsHttpQuery +): number | null => { + // Queried data state is prepopulated for all known entities. If this is not + // assigned for the given parameters, then it is known to not exist. + const queriedState = + state.entities.records?.[ kind ]?.[ name ]?.queriedData; + if ( ! queriedState ) { + return null; + } + return getQueriedTotalItems( queriedState, query ); +}; + +/** + * Returns the number of available pages for the given query. + * + * @param state State tree + * @param kind Entity kind. + * @param name Entity name. + * @param query Optional terms query. If requesting specific + * fields, fields must always include the ID. For valid query parameters see the [Reference](https://developer.wordpress.org/rest-api/reference/) in the REST API Handbook and select the entity kind. Then see the arguments available for "List [Entity kind]s". + * + * @return number | null. + */ +export const getEntityRecordsTotalPages = ( + state: State, + kind: string, + name: string, + query: GetRecordsHttpQuery +): number | null => { + // Queried data state is prepopulated for all known entities. If this is not + // assigned for the given parameters, then it is known to not exist. + const queriedState = + state.entities.records?.[ kind ]?.[ name ]?.queriedData; + if ( ! queriedState ) { + return null; + } + return getQueriedTotalPages( queriedState, query ); +}; + type DirtyEntityRecord = { title: string; key: EntityRecordKey; diff --git a/packages/core-data/src/test/resolvers.js b/packages/core-data/src/test/resolvers.js index 77487071d3f139..544caad0c2dbc4 100644 --- a/packages/core-data/src/test/resolvers.js +++ b/packages/core-data/src/test/resolvers.js @@ -172,7 +172,10 @@ describe( 'getEntityRecords', () => { 'root', 'postType', Object.values( POST_TYPES ), - {} + {}, + false, + undefined, + undefined ); } ); diff --git a/packages/core-data/src/test/selectors.js b/packages/core-data/src/test/selectors.js index 5ae7a81fa144e9..61c0621b6e5e49 100644 --- a/packages/core-data/src/test/selectors.js +++ b/packages/core-data/src/test/selectors.js @@ -226,7 +226,7 @@ describe( 'hasEntityRecords', () => { }, queries: { default: { - '': [ 'post', 'page' ], + '': { itemIds: [ 'post', 'page' ] }, }, }, }, @@ -361,7 +361,7 @@ describe( 'getEntityRecords', () => { }, queries: { default: { - '': [ 'post', 'page' ], + '': { itemIds: [ 'post', 'page' ] }, }, }, }, @@ -399,7 +399,9 @@ describe( 'getEntityRecords', () => { }, queries: { default: { - '_fields=id%2Ccontent': [ 1 ], + '_fields=id%2Ccontent': { + itemIds: [ 1 ], + }, }, }, }, diff --git a/packages/edit-site/src/components/page-pages/index.js b/packages/edit-site/src/components/page-pages/index.js index eea646c1c7326c..ef3c85ba92cff9 100644 --- a/packages/edit-site/src/components/page-pages/index.js +++ b/packages/edit-site/src/components/page-pages/index.js @@ -1,8 +1,6 @@ /** * WordPress dependencies */ -import apiFetch from '@wordpress/api-fetch'; -import { addQueryArgs } from '@wordpress/url'; import { VisuallyHidden, __experimentalHeading as Heading, @@ -11,7 +9,7 @@ import { import { __ } from '@wordpress/i18n'; import { useEntityRecords } from '@wordpress/core-data'; import { decodeEntities } from '@wordpress/html-entities'; -import { useState, useEffect, useMemo } from '@wordpress/element'; +import { useState, useMemo } from '@wordpress/element'; /** * Internal dependencies @@ -35,7 +33,6 @@ export default function PagePages() { direction: 'desc', }, } ); - const [ paginationInfo, setPaginationInfo ] = useState(); // Request post statuses to get the proper labels. const { records: statuses } = useEntityRecords( 'root', 'status' ); const postStatuses = @@ -58,33 +55,20 @@ export default function PagePages() { } ), [ view ] ); - const { records: pages, isResolving: isLoadingPages } = useEntityRecords( - 'postType', - 'page', - queryArgs + const { + records: pages, + isResolving: isLoadingPages, + totalItems, + totalPages, + } = useEntityRecords( 'postType', 'page', queryArgs ); + + const paginationInfo = useMemo( + () => ( { + totalItems, + totalPages, + } ), + [ totalItems, totalPages ] ); - useEffect( () => { - // Make extra request to handle controlled pagination. - apiFetch( { - path: addQueryArgs( '/wp/v2/pages', { - ...queryArgs, - _fields: 'id', - } ), - method: 'HEAD', - parse: false, - } ).then( ( res ) => { - // TODO: store this in core-data reducer and - // make sure it's returned as part of useEntityRecords - // (to avoid double requests). - const totalPages = parseInt( res.headers.get( 'X-WP-TotalPages' ) ); - const totalItems = parseInt( res.headers.get( 'X-WP-Total' ) ); - setPaginationInfo( { - totalPages, - totalItems, - } ); - } ); - // Status should not make extra request if already did.. - }, [ queryArgs ] ); const fields = useMemo( () => [ @@ -158,7 +142,7 @@ export default function PagePages() { view={ view } onChangeView={ setView } options={ { - pageCount: paginationInfo?.totalPages, + pageCount: totalPages, } } /> From cf50d9f4c286e06966d74998a7b07aa5c4ad0187 Mon Sep 17 00:00:00 2001 From: Ari Stathopoulos Date: Tue, 10 Oct 2023 13:27:39 +0300 Subject: [PATCH 035/239] Mark forms blocks as experimental in block.json (#55187) --- docs/reference-guides/core-blocks.md | 4 ++++ packages/block-library/src/form-input/block.json | 1 + .../block-library/src/form-submission-notification/block.json | 1 + packages/block-library/src/form-submit-button/block.json | 1 + packages/block-library/src/form/block.json | 1 + 5 files changed, 8 insertions(+) diff --git a/docs/reference-guides/core-blocks.md b/docs/reference-guides/core-blocks.md index 241c10141ae155..68b4349197a8b7 100644 --- a/docs/reference-guides/core-blocks.md +++ b/docs/reference-guides/core-blocks.md @@ -282,6 +282,7 @@ Display footnotes added to the page. ([Source](https://github.com/WordPress/gute A form. ([Source](https://github.com/WordPress/gutenberg/tree/trunk/packages/block-library/src/form)) - **Name:** core/form +- **Experimental:** true - **Category:** common - **Supports:** anchor, color (background, gradients, link, text), spacing (margin, padding), typography (fontSize, lineHeight), ~~className~~ - **Attributes:** action, email, method, submissionMethod @@ -291,6 +292,7 @@ A form. ([Source](https://github.com/WordPress/gutenberg/tree/trunk/packages/blo The basic building block for forms. ([Source](https://github.com/WordPress/gutenberg/tree/trunk/packages/block-library/src/form-input)) - **Name:** core/form-input +- **Experimental:** true - **Category:** common - **Parent:** core/form - **Supports:** anchor, spacing (margin), ~~reusable~~ @@ -301,6 +303,7 @@ The basic building block for forms. ([Source](https://github.com/WordPress/guten Provide a notification message after the form has been submitted. ([Source](https://github.com/WordPress/gutenberg/tree/trunk/packages/block-library/src/form-submission-notification)) - **Name:** core/form-submission-notification +- **Experimental:** true - **Category:** common - **Parent:** core/form - **Supports:** @@ -311,6 +314,7 @@ Provide a notification message after the form has been submitted. ([Source](http A submission button for forms. ([Source](https://github.com/WordPress/gutenberg/tree/trunk/packages/block-library/src/form-submit-button)) - **Name:** core/form-submit-button +- **Experimental:** true - **Category:** common - **Parent:** core/form - **Supports:** diff --git a/packages/block-library/src/form-input/block.json b/packages/block-library/src/form-input/block.json index f0978302b34e20..f166d2f6754f64 100644 --- a/packages/block-library/src/form-input/block.json +++ b/packages/block-library/src/form-input/block.json @@ -1,6 +1,7 @@ { "$schema": "https://schemas.wp.org/trunk/block.json", "apiVersion": 3, + "__experimental": true, "name": "core/form-input", "title": "Input field", "category": "common", diff --git a/packages/block-library/src/form-submission-notification/block.json b/packages/block-library/src/form-submission-notification/block.json index 16d96928bf99e9..e8c3059043c59f 100644 --- a/packages/block-library/src/form-submission-notification/block.json +++ b/packages/block-library/src/form-submission-notification/block.json @@ -1,6 +1,7 @@ { "$schema": "https://schemas.wp.org/trunk/block.json", "apiVersion": 3, + "__experimental": true, "name": "core/form-submission-notification", "title": "Form Submission Notification", "category": "common", diff --git a/packages/block-library/src/form-submit-button/block.json b/packages/block-library/src/form-submit-button/block.json index d7d516094bd899..165742e0e0d44c 100644 --- a/packages/block-library/src/form-submit-button/block.json +++ b/packages/block-library/src/form-submit-button/block.json @@ -1,6 +1,7 @@ { "$schema": "https://schemas.wp.org/trunk/block.json", "apiVersion": 3, + "__experimental": true, "name": "core/form-submit-button", "title": "Form submit button", "category": "common", diff --git a/packages/block-library/src/form/block.json b/packages/block-library/src/form/block.json index 84e43396bddcc8..0c6451f4959a48 100644 --- a/packages/block-library/src/form/block.json +++ b/packages/block-library/src/form/block.json @@ -1,6 +1,7 @@ { "$schema": "https://schemas.wp.org/trunk/block.json", "apiVersion": 3, + "__experimental": true, "name": "core/form", "title": "Form", "category": "common", From f0b4192b33fc69fe42929ccceda161979435ebc5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9?= <583546+oandregal@users.noreply.github.com> Date: Tue, 10 Oct 2023 12:45:14 +0200 Subject: [PATCH 036/239] Use `accessorFn` instead of `cell` to render `status` value (#55196) --- packages/edit-site/src/components/page-pages/index.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/edit-site/src/components/page-pages/index.js b/packages/edit-site/src/components/page-pages/index.js index ef3c85ba92cff9..6ee6c8bc1bacee 100644 --- a/packages/edit-site/src/components/page-pages/index.js +++ b/packages/edit-site/src/components/page-pages/index.js @@ -114,9 +114,8 @@ export default function PagePages() { { header: 'Status', id: 'status', - cell: ( props ) => - postStatuses[ props.row.original.status ] ?? - props.row.original.status, + accessorFn: ( page ) => + postStatuses[ page.status ] ?? page.status, }, { header: { __( 'Actions' ) }, From d564bc9bb9450f447444a2678af3c469d8412367 Mon Sep 17 00:00:00 2001 From: Jarda Snajdr Date: Tue, 10 Oct 2023 14:36:23 +0200 Subject: [PATCH 037/239] useBlockSync: fix typo and simplify test (#55203) --- .../src/components/provider/test/use-block-sync.js | 2 +- .../block-editor/src/components/provider/use-block-sync.js | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/block-editor/src/components/provider/test/use-block-sync.js b/packages/block-editor/src/components/provider/test/use-block-sync.js index b1e6a97d137c91..09529c197516a3 100644 --- a/packages/block-editor/src/components/provider/test/use-block-sync.js +++ b/packages/block-editor/src/components/provider/test/use-block-sync.js @@ -31,7 +31,7 @@ const TestWrapper = withRegistryProvider( ( props ) => { props.setRegistry( props.registry ); } useBlockSync( props ); - return

Test.

; + return null; } ); describe( 'useBlockSync hook', () => { diff --git a/packages/block-editor/src/components/provider/use-block-sync.js b/packages/block-editor/src/components/provider/use-block-sync.js index 32dff45d8be679..58aca847d80de0 100644 --- a/packages/block-editor/src/components/provider/use-block-sync.js +++ b/packages/block-editor/src/components/provider/use-block-sync.js @@ -180,7 +180,7 @@ export default function useBlockSync( { // bound sync, unset the outbound value to avoid considering it in // subsequent renders. pendingChanges.current.outgoing = []; - const hadSelecton = hasSelectedBlock(); + const hadSelection = hasSelectedBlock(); const selectionAnchor = getSelectionStart(); const selectionFocus = getSelectionEnd(); setControlledBlocks(); @@ -195,7 +195,7 @@ export default function useBlockSync( { const selectionStillExists = getBlock( selectionAnchor.clientId ); - if ( hadSelecton && ! selectionStillExists ) { + if ( hadSelection && ! selectionStillExists ) { selectBlock( clientId ); } else { resetSelection( selectionAnchor, selectionFocus ); From 8860495f43dfc48679c9c885bc57797906ba88eb Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 10 Oct 2023 09:49:24 -0400 Subject: [PATCH 038/239] test: Ignore local environment setup (#55172) Local environment setup configuration is intended for the development workflow. Applying it to the testing environment produced unexpected outcomes where one's local configuration can cause tests to fail unexpectedly. --- packages/react-native-editor/src/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-native-editor/src/index.js b/packages/react-native-editor/src/index.js index 32ba1c6f3441d0..e4c73676651fa0 100644 --- a/packages/react-native-editor/src/index.js +++ b/packages/react-native-editor/src/index.js @@ -52,7 +52,7 @@ const registerGutenberg = ( { this.editorComponent = setup(); // Apply optional setup configuration, enabling modification via hooks. - if ( typeof require.context === 'function' ) { + if ( __DEV__ && typeof require.context === 'function' ) { const req = require.context( './', false, /setup-local\.js$/ ); req.keys().forEach( ( key ) => req( key ).default() ); } From 6a04d57b280490ec1a33c168c3f3f47603b08161 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9?= <583546+oandregal@users.noreply.github.com> Date: Tue, 10 Oct 2023 19:28:20 +0200 Subject: [PATCH 039/239] Set status as no sortable (#55210) --- packages/edit-site/src/components/page-pages/index.js | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/edit-site/src/components/page-pages/index.js b/packages/edit-site/src/components/page-pages/index.js index 6ee6c8bc1bacee..56575248453f0a 100644 --- a/packages/edit-site/src/components/page-pages/index.js +++ b/packages/edit-site/src/components/page-pages/index.js @@ -116,6 +116,7 @@ export default function PagePages() { id: 'status', accessorFn: ( page ) => postStatuses[ page.status ] ?? page.status, + enableSorting: false, }, { header: { __( 'Actions' ) }, From da3d90b6e5a3c9b3bd7a6b0b37e332638662896e Mon Sep 17 00:00:00 2001 From: Matias Benedetto Date: Tue, 10 Oct 2023 16:45:34 -0300 Subject: [PATCH 040/239] Use all the settings origins for a block that consumes paths with merge. (#55219) Co-authored-by: Jason Crist <146530+pbking@users.noreply.github.com> --- packages/block-editor/src/components/use-setting/index.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/block-editor/src/components/use-setting/index.js b/packages/block-editor/src/components/use-setting/index.js index c1222c9116ae67..1ae672103015ce 100644 --- a/packages/block-editor/src/components/use-setting/index.js +++ b/packages/block-editor/src/components/use-setting/index.js @@ -189,7 +189,12 @@ export default function useSetting( path ) { // Return if the setting was found in either the block instance or the store. if ( result !== undefined ) { if ( PATHS_WITH_MERGE[ normalizedPath ] ) { - return result.custom ?? result.theme ?? result.default; + return [ 'default', 'theme', 'custom' ].reduce( + ( acc, key ) => { + return acc.concat( result[ key ] ?? [] ); + }, + [] + ); } return result; } From 8d26e5d60c6f509c5a5944161084dfe618398abd Mon Sep 17 00:00:00 2001 From: Jeff Ong Date: Tue, 10 Oct 2023 17:23:00 -0400 Subject: [PATCH 041/239] Font Library: show error if fetching collection fails (#54919) * Throw and render error if fetching font collections fail. * use the existing notice state to dispay the error --------- Co-authored-by: Matias Benedetto --- .../font-library-modal/context.js | 28 +++++++++++-------- .../font-library-modal/font-collection.js | 28 ++++++++++++------- 2 files changed, 35 insertions(+), 21 deletions(-) diff --git a/packages/edit-site/src/components/global-styles/font-library-modal/context.js b/packages/edit-site/src/components/global-styles/font-library-modal/context.js index 8ab067495fcc08..33a5b0910f0526 100644 --- a/packages/edit-site/src/components/global-styles/font-library-modal/context.js +++ b/packages/edit-site/src/components/global-styles/font-library-modal/context.js @@ -323,17 +323,23 @@ function FontLibraryProvider( { children } ) { setFontCollections( response ); }; const getFontCollection = async ( id ) => { - const hasData = !! collections.find( - ( collection ) => collection.id === id - )?.data; - if ( hasData ) return; - const response = await fetchFontCollection( id ); - const updatedCollections = collections.map( ( collection ) => - collection.id === id - ? { ...collection, data: { ...response?.data } } - : collection - ); - setFontCollections( updatedCollections ); + try { + const hasData = !! collections.find( + ( collection ) => collection.id === id + )?.data; + if ( hasData ) return; + const response = await fetchFontCollection( id ); + const updatedCollections = collections.map( ( collection ) => + collection.id === id + ? { ...collection, data: { ...response?.data } } + : collection + ); + setFontCollections( updatedCollections ); + } catch ( e ) { + // eslint-disable-next-line no-console + console.error( e ); + throw e; + } }; useEffect( () => { diff --git a/packages/edit-site/src/components/global-styles/font-library-modal/font-collection.js b/packages/edit-site/src/components/global-styles/font-library-modal/font-collection.js index a3b697efcfb8b9..61de80838f6372 100644 --- a/packages/edit-site/src/components/global-styles/font-library-modal/font-collection.js +++ b/packages/edit-site/src/components/global-styles/font-library-modal/font-collection.js @@ -72,20 +72,32 @@ function FontCollection( { id } ) { }, [ id, requiresPermission ] ); useEffect( () => { - getFontCollection( id ); - resetFilters(); + const fetchFontCollection = async () => { + try { + await getFontCollection( id ); + resetFilters(); + } catch ( e ) { + setNotice( { + type: 'error', + message: e?.message, + duration: 0, // Don't auto-hide. + } ); + } + }; + fetchFontCollection(); }, [ id, getFontCollection ] ); useEffect( () => { setSelectedFont( null ); + setNotice( null ); }, [ id ] ); // Reset notice after 5 seconds useEffect( () => { - if ( notice ) { + if ( notice && notice?.duration !== 0 ) { const timeout = setTimeout( () => { setNotice( null ); - }, 5000 ); + }, notice.duration ?? 5000 ); return () => clearTimeout( timeout ); } }, [ notice ] ); @@ -167,10 +179,6 @@ function FontCollection( { id } ) { ) } - { ! renderConfirmDialog && ! selectedCollection.data && ( - - ) } - { notice && ( <> @@ -227,9 +235,9 @@ function FontCollection( { id } ) { ) } - { ! renderConfirmDialog && - ! selectedCollection?.data?.fontFamilies && } + ! selectedCollection?.data?.fontFamilies && + ! notice && } { ! renderConfirmDialog && !! selectedCollection?.data?.fontFamilies?.length && From cf3b27f199512e08c17f07b093957a656f9b081b Mon Sep 17 00:00:00 2001 From: Dennis Snell Date: Tue, 10 Oct 2023 13:42:24 -0700 Subject: [PATCH 042/239] HTML API: Backup updates from Core - add `has_class()` and `class_list()` methods [#59209-trac](https://core.trac.wordpress.org/ticket/59209) - add `matches_breadcrumbs()` method [#59400-trac](https://core.trac.wordpress.org/ticket/59400) - rename `createFramgent()` to `create_fragment()` [#59547-trac](https://core.trac.wordpress.org/ticket/59547) --- ...class-gutenberg-html-tag-processor-6-4.php | 147 +++++++++++------- .../html-api/class-wp-html-processor.php | 88 ++++++++--- 2 files changed, 154 insertions(+), 81 deletions(-) diff --git a/lib/compat/wordpress-6.4/html-api/class-gutenberg-html-tag-processor-6-4.php b/lib/compat/wordpress-6.4/html-api/class-gutenberg-html-tag-processor-6-4.php index 7a87bddd7f1f7b..13f0ec5fad4796 100644 --- a/lib/compat/wordpress-6.4/html-api/class-gutenberg-html-tag-processor-6-4.php +++ b/lib/compat/wordpress-6.4/html-api/class-gutenberg-html-tag-processor-6-4.php @@ -626,6 +626,94 @@ public function next_tag( $query = null ) { } + /** + * Generator for a foreach loop to step through each class name for the matched tag. + * + * This generator function is designed to be used inside a "foreach" loop. + * + * Example: + * + * $p = new WP_HTML_Tag_Processor( "
" ); + * $p->next_tag(); + * foreach ( $p->class_list() as $class_name ) { + * echo "{$class_name} "; + * } + * // Outputs: "free lang-en " + * + * @since 6.4.0 + */ + public function class_list() { + /** @var string $class contains the string value of the class attribute, with character references decoded. */ + $class = $this->get_attribute( 'class' ); + + if ( ! is_string( $class ) ) { + return; + } + + $seen = array(); + + $at = 0; + while ( $at < strlen( $class ) ) { + // Skip past any initial boundary characters. + $at += strspn( $class, " \t\f\r\n", $at ); + if ( $at >= strlen( $class ) ) { + return; + } + + // Find the byte length until the next boundary. + $length = strcspn( $class, " \t\f\r\n", $at ); + if ( 0 === $length ) { + return; + } + + /* + * CSS class names are case-insensitive in the ASCII range. + * + * @see https://www.w3.org/TR/CSS2/syndata.html#x1 + */ + $name = strtolower( substr( $class, $at, $length ) ); + $at += $length; + + /* + * It's expected that the number of class names for a given tag is relatively small. + * Given this, it is probably faster overall to scan an array for a value rather + * than to use the class name as a key and check if it's a key of $seen. + */ + if ( in_array( $name, $seen, true ) ) { + continue; + } + + $seen[] = $name; + yield $name; + } + } + + + /** + * Returns if a matched tag contains the given ASCII case-insensitive class name. + * + * @since 6.4.0 + * + * @param string $wanted_class Look for this CSS class name, ASCII case-insensitive. + * @return bool|null Whether the matched tag contains the given class name, or null if not matched. + */ + public function has_class( $wanted_class ) { + if ( ! $this->tag_name_starts_at ) { + return null; + } + + $wanted_class = strtolower( $wanted_class ); + + foreach ( $this->class_list() as $class_name ) { + if ( $class_name === $wanted_class ) { + return true; + } + } + + return false; + } + + /** * Sets a bookmark in the HTML document. * @@ -2347,64 +2435,7 @@ private function matches() { } } - $needs_class_name = null !== $this->sought_class_name; - - if ( $needs_class_name && ! isset( $this->attributes['class'] ) ) { - return false; - } - - /* - * Match byte-for-byte (case-sensitive and encoding-form-sensitive) on the class name. - * - * This will overlook certain classes that exist in other lexical variations - * than was supplied to the search query, but requires more complicated searching. - */ - if ( $needs_class_name ) { - $class_start = $this->attributes['class']->value_starts_at; - $class_end = $class_start + $this->attributes['class']->value_length; - $class_at = $class_start; - - /* - * Ensure that boundaries surround the class name to avoid matching on - * substrings of a longer name. For example, the sequence "not-odd" - * should not match for the class "odd" even though "odd" is found - * within the class attribute text. - * - * See https://html.spec.whatwg.org/#attributes-3 - * See https://html.spec.whatwg.org/#space-separated-tokens - */ - while ( - // phpcs:ignore WordPress.CodeAnalysis.AssignmentInCondition.FoundInWhileCondition - false !== ( $class_at = strpos( $this->html, $this->sought_class_name, $class_at ) ) && - $class_at < $class_end - ) { - /* - * Verify this class starts at a boundary. - */ - if ( $class_at > $class_start ) { - $character = $this->html[ $class_at - 1 ]; - - if ( ' ' !== $character && "\t" !== $character && "\f" !== $character && "\r" !== $character && "\n" !== $character ) { - $class_at += strlen( $this->sought_class_name ); - continue; - } - } - - /* - * Verify this class ends at a boundary as well. - */ - if ( $class_at + strlen( $this->sought_class_name ) < $class_end ) { - $character = $this->html[ $class_at + strlen( $this->sought_class_name ) ]; - - if ( ' ' !== $character && "\t" !== $character && "\f" !== $character && "\r" !== $character && "\n" !== $character ) { - $class_at += strlen( $this->sought_class_name ); - continue; - } - } - - return true; - } - + if ( null !== $this->sought_class_name && ! $this->has_class( $this->sought_class_name ) ) { return false; } diff --git a/lib/compat/wordpress-6.4/html-api/class-wp-html-processor.php b/lib/compat/wordpress-6.4/html-api/class-wp-html-processor.php index 2e4b5a47589e81..e53e64c80e2e02 100644 --- a/lib/compat/wordpress-6.4/html-api/class-wp-html-processor.php +++ b/lib/compat/wordpress-6.4/html-api/class-wp-html-processor.php @@ -43,7 +43,7 @@ * * Example: * - * $processor = WP_HTML_Processor::createFragment( $html ); + * $processor = WP_HTML_Processor::create_fragment( $html ); * if ( $processor->next_tag( array( 'breadcrumbs' => array( 'DIV', 'FIGURE', 'IMG' ) ) ) ) { * $processor->add_class( 'responsive-image' ); * } @@ -62,7 +62,7 @@ * Since all elements find themselves inside a full HTML document * when parsed, the return value from `get_breadcrumbs()` will always * contain any implicit outermost elements. For example, when parsing - * with `createFragment()` in the `BODY` context (the default), any + * with `create_fragment()` in the `BODY` context (the default), any * tag in the given HTML document will contain `array( 'HTML', 'BODY', … )` * in its breadcrumbs. * @@ -243,7 +243,7 @@ class WP_HTML_Processor extends Gutenberg_HTML_Tag_Processor_6_4 { * @param string $encoding Text encoding of the document; must be default of 'UTF-8'. * @return WP_HTML_Processor|null The created processor if successful, otherwise null. */ - public static function createFragment( $html, $context = '', $encoding = 'UTF-8' ) { + public static function create_fragment( $html, $context = '', $encoding = 'UTF-8' ) { if ( '' !== $context || 'UTF-8' !== $encoding ) { return null; } @@ -284,7 +284,7 @@ public static function createFragment( $html, $context = '', $encoding = ' * * @since 6.4.0 * - * @see WP_HTML_Processor::createFragment() + * @see WP_HTML_Processor::create_fragment() * * @param string $html HTML to process. * @param string|null $use_the_static_create_methods_instead This constructor should not be called manually. @@ -296,9 +296,9 @@ public function __construct( $html, $use_the_static_create_methods_instead = nul _doing_it_wrong( __METHOD__, sprintf( - /* translators: %s: WP_HTML_Processor::createFragment. */ + /* translators: %s: WP_HTML_Processor::create_fragment(). */ __( 'Call %s to create an HTML Processor instead of calling the constructor directly.' ), - 'WP_HTML_Processor::createFragment' + 'WP_HTML_Processor::create_fragment()' ), '6.4.0' ); @@ -328,7 +328,7 @@ public function __construct( $html, $use_the_static_create_methods_instead = nul * * Example * - * $processor = WP_HTML_Processor::createFragment( '