@@ -56,7 +62,7 @@ export function FormFileUpload( {
ref={ ref }
multiple={ multiple }
style={ { display: 'none' } }
- accept={ accept }
+ accept={ compatAccept }
onChange={ onChange }
onClick={ onClick }
data-testid="form-file-upload-input"
diff --git a/packages/components/src/guide/index.tsx b/packages/components/src/guide/index.tsx
index 0ca5957fd3a656..121c9f22330e88 100644
--- a/packages/components/src/guide/index.tsx
+++ b/packages/components/src/guide/index.tsx
@@ -164,6 +164,7 @@ function Guide( {
className="components-guide__finish-button"
variant="primary"
onClick={ onFinish }
+ __next40pxDefaultSize
>
{ finishButtonText }
diff --git a/packages/components/src/mobile/bottom-sheet-select-control/index.native.js b/packages/components/src/mobile/bottom-sheet-select-control/index.native.js
index 89c157d85b9b2d..0e0a19c12aed44 100644
--- a/packages/components/src/mobile/bottom-sheet-select-control/index.native.js
+++ b/packages/components/src/mobile/bottom-sheet-select-control/index.native.js
@@ -64,7 +64,7 @@ const BottomSheetSelectControl = ( {
onPress={ openSubSheet }
accessibilityRole="button"
accessibilityLabel={ sprintf(
- // translators: %1$s: Select control button label e.g. "Button width". %2$s: Select control option value e.g: "Auto, 25%".
+ // translators: 1: Select control button label e.g. "Button width". 2: Select control option value e.g: "Auto, 25%".
__( '%1$s. Currently selected: %2$s' ),
label,
selectedOption.label
@@ -102,7 +102,7 @@ const BottomSheetSelectControl = ( {
accessibilityLabel={
item.value === selectedValue
? sprintf(
- // translators: %s: Select control option value e.g: "Auto, 25%".
+ // translators: %s: The selected option.
__( 'Selected: %s' ),
item.label
)
diff --git a/packages/components/src/mobile/bottom-sheet/range-cell.native.js b/packages/components/src/mobile/bottom-sheet/range-cell.native.js
index f0d82cb96908ac..afa25683e4e3e8 100644
--- a/packages/components/src/mobile/bottom-sheet/range-cell.native.js
+++ b/packages/components/src/mobile/bottom-sheet/range-cell.native.js
@@ -165,7 +165,7 @@ class BottomSheetRangeCell extends Component {
const getAccessibilityLabel = () => {
return sprintf(
- /* translators: accessibility text. Inform about current value. %1$s: Control label %2$s: setting label (example: width), %3$s: Current value. %4$s: value measurement unit (example: pixels) */
+ /* translators: accessibility text. Inform about current value. 1: Control label. 2: setting label (example: width). 3: Current value. 4: value measurement unit (example: pixels) */
__( '%1$s. %2$s is %3$s %4$s.' ),
cellProps.label,
settingLabel,
diff --git a/packages/components/src/mobile/bottom-sheet/stepper-cell/index.native.js b/packages/components/src/mobile/bottom-sheet/stepper-cell/index.native.js
index aad78cd12665b6..6e5239687c5aa4 100644
--- a/packages/components/src/mobile/bottom-sheet/stepper-cell/index.native.js
+++ b/packages/components/src/mobile/bottom-sheet/stepper-cell/index.native.js
@@ -159,7 +159,7 @@ class BottomSheetStepperCell extends Component {
};
const accessibilityLabel = sprintf(
- /* translators: accessibility text. Inform about current value. %1$s: Control label %2$s: setting label (example: width), %3$s: Current value. %4$s: value measurement unit (example: pixels) */
+ /* translators: accessibility text. Inform about current value. 1: Control label. 2: setting label (example: width). 3: Current value. 4: value measurement unit (example: pixels) */
__( '%1$s. %2$s is %3$s %4$s.' ),
label,
settingLabel,
diff --git a/packages/components/src/navigator/navigator-provider/component.tsx b/packages/components/src/navigator/navigator-provider/component.tsx
index ebcb247c574830..01254b743f87d0 100644
--- a/packages/components/src/navigator/navigator-provider/component.tsx
+++ b/packages/components/src/navigator/navigator-provider/component.tsx
@@ -66,7 +66,7 @@ function goTo(
options: NavigateOptions = {}
) {
const { focusSelectors } = state;
- const currentLocation = { ...state.currentLocation, isInitial: false };
+ const currentLocation = { ...state.currentLocation };
const {
// Default assignments
@@ -114,6 +114,7 @@ function goTo(
return {
currentLocation: {
...restOptions,
+ isInitial: false,
path,
isBack,
hasRestoredFocus: false,
@@ -129,7 +130,7 @@ function goToParent(
options: NavigateToParentOptions = {}
) {
const { screens, focusSelectors } = state;
- const currentLocation = { ...state.currentLocation, isInitial: false };
+ const currentLocation = { ...state.currentLocation };
const currentPath = currentLocation.path;
if ( currentPath === undefined ) {
return { currentLocation, focusSelectors };
diff --git a/packages/components/src/palette-edit/index.tsx b/packages/components/src/palette-edit/index.tsx
index 601ea758b75bba..8bfcb7240b9ea4 100644
--- a/packages/components/src/palette-edit/index.tsx
+++ b/packages/components/src/palette-edit/index.tsx
@@ -49,7 +49,6 @@ import { kebabCase } from '../utils/strings';
import type {
Color,
ColorPickerPopoverProps,
- Gradient,
NameInputProps,
OptionProps,
PaletteEditListViewProps,
@@ -70,6 +69,28 @@ function NameInput( { value, onChange, label }: NameInputProps ) {
);
}
+/*
+ * Deduplicates the slugs of the provided elements.
+ */
+export function deduplicateElementSlugs< T extends PaletteElement >(
+ elements: T[]
+) {
+ const slugCounts: { [ slug: string ]: number } = {};
+
+ return elements.map( ( element ) => {
+ let newSlug: string | undefined;
+
+ const { slug } = element;
+ slugCounts[ slug ] = ( slugCounts[ slug ] || 0 ) + 1;
+
+ if ( slugCounts[ slug ] > 1 ) {
+ newSlug = `${ slug }-${ slugCounts[ slug ] - 1 }`;
+ }
+
+ return { ...element, slug: newSlug ?? slug };
+ } );
+}
+
/**
* Returns a name and slug for a palette item. The name takes the format "Color + id".
* To ensure there are no duplicate ids, this function checks all slugs.
@@ -109,7 +130,7 @@ export function getNameAndSlugForPosition(
};
}
-function ColorPickerPopover< T extends Color | Gradient >( {
+function ColorPickerPopover< T extends PaletteElement >( {
isGradient,
element,
onChange,
@@ -167,7 +188,7 @@ function ColorPickerPopover< T extends Color | Gradient >( {
);
}
-function Option< T extends Color | Gradient >( {
+function Option< T extends PaletteElement >( {
canOnlyChangeValues,
element,
onChange,
@@ -265,7 +286,7 @@ function Option< T extends Color | Gradient >( {
);
}
-function PaletteEditListView< T extends Color | Gradient >( {
+function PaletteEditListView< T extends PaletteElement >( {
elements,
onChange,
canOnlyChangeValues,
@@ -280,7 +301,11 @@ function PaletteEditListView< T extends Color | Gradient >( {
elementsReferenceRef.current = elements;
}, [ elements ] );
- const debounceOnChange = useDebounce( onChange, 100 );
+ const debounceOnChange = useDebounce(
+ ( updatedElements: T[] ) =>
+ onChange( deduplicateElementSlugs( updatedElements ) ),
+ 100
+ );
return (
diff --git a/packages/components/src/palette-edit/test/index.tsx b/packages/components/src/palette-edit/test/index.tsx
index 980630633b97f9..7dc00dbba22042 100644
--- a/packages/components/src/palette-edit/test/index.tsx
+++ b/packages/components/src/palette-edit/test/index.tsx
@@ -7,7 +7,10 @@ import { click, type, press } from '@ariakit/test';
/**
* Internal dependencies
*/
-import PaletteEdit, { getNameAndSlugForPosition } from '..';
+import PaletteEdit, {
+ getNameAndSlugForPosition,
+ deduplicateElementSlugs,
+} from '..';
import type { PaletteElement } from '../types';
const noop = () => {};
@@ -97,6 +100,52 @@ describe( 'getNameAndSlugForPosition', () => {
} );
} );
+describe( 'deduplicateElementSlugs', () => {
+ it( 'should not change the slugs if they are unique', () => {
+ const elements: PaletteElement[] = [
+ {
+ slug: 'test-color-1',
+ color: '#ffffff',
+ name: 'Test Color 1',
+ },
+ {
+ slug: 'test-color-2',
+ color: '#1a4548',
+ name: 'Test Color 2',
+ },
+ ];
+
+ expect( deduplicateElementSlugs( elements ) ).toEqual( elements );
+ } );
+ it( 'should change the slugs if they are not unique', () => {
+ const elements: PaletteElement[] = [
+ {
+ slug: 'test-color-1',
+ color: '#ffffff',
+ name: 'Test Color 1',
+ },
+ {
+ slug: 'test-color-1',
+ color: '#1a4548',
+ name: 'Test Color 2',
+ },
+ ];
+
+ expect( deduplicateElementSlugs( elements ) ).toEqual( [
+ {
+ slug: 'test-color-1',
+ color: '#ffffff',
+ name: 'Test Color 1',
+ },
+ {
+ slug: 'test-color-1-1',
+ color: '#1a4548',
+ name: 'Test Color 2',
+ },
+ ] );
+ } );
+} );
+
describe( 'PaletteEdit', () => {
const defaultProps = {
paletteLabel: 'Test label',
diff --git a/packages/components/src/tabs/styles.ts b/packages/components/src/tabs/styles.ts
index c00943b180f637..07f3cb9600bb2e 100644
--- a/packages/components/src/tabs/styles.ts
+++ b/packages/components/src/tabs/styles.ts
@@ -100,7 +100,7 @@ export const Tab = styled( Ariakit.Tab )`
box-shadow: none;
cursor: pointer;
line-height: 1.2; // Some languages characters e.g. Japanese may have a native higher line-height.
- padding: ${ space( 4 ) };
+ padding: ${ space( 3 ) } ${ space( 4 ) };
margin-left: 0;
font-weight: 500;
text-align: inherit;
diff --git a/packages/components/src/toggle-group-control/test/__snapshots__/index.tsx.snap b/packages/components/src/toggle-group-control/test/__snapshots__/index.tsx.snap
index e9b4f4ca22ab85..34b7fa9575a546 100644
--- a/packages/components/src/toggle-group-control/test/__snapshots__/index.tsx.snap
+++ b/packages/components/src/toggle-group-control/test/__snapshots__/index.tsx.snap
@@ -244,7 +244,7 @@ exports[`ToggleGroupControl controlled should render correctly with icons 1`] =
class="components-toggle-group-control emotion-8 emotion-9"
data-wp-c16t="true"
data-wp-component="ToggleGroupControl"
- id="toggle-group-control-as-radio-group-11"
+ id="toggle-group-control-as-radio-group-12"
role="radiogroup"
>
{
+ render(
+
{ options }
+ );
+
+ const radio = screen.getByRole( 'radio', { name: 'R' } );
+ expect( radio ).not.toBeChecked();
+
+ await press.Tab();
+ expect( radio ).toHaveFocus();
+ expect( radio ).not.toBeChecked();
+ } );
+
+ if ( mode === 'controlled' ) {
+ it( 'should not set a value on focus, after the value is reset', async () => {
+ render(
+
+ { options }
+
+ );
+
+ expect( screen.getByRole( 'radio', { name: 'J' } ) ).toBeChecked();
+
+ await click( screen.getByRole( 'button', { name: 'Reset' } ) );
+
+ expect(
+ screen.getByRole( 'radio', { name: 'J' } )
+ ).not.toBeChecked();
+
+ await press.ShiftTab();
+ expect(
+ screen.getByRole( 'radio', { name: 'R' } )
+ ).not.toBeChecked();
+ expect(
+ screen.getByRole( 'radio', { name: 'J' } )
+ ).not.toBeChecked();
+ } );
+ }
+
it( 'should render tooltip where `showTooltip` === `true`', async () => {
render(
diff --git a/packages/components/src/toggle-group-control/toggle-group-control-option-base/component.tsx b/packages/components/src/toggle-group-control/toggle-group-control-option-base/component.tsx
index 30d74e68913f22..8b401423742e14 100644
--- a/packages/components/src/toggle-group-control/toggle-group-control-option-base/component.tsx
+++ b/packages/components/src/toggle-group-control/toggle-group-control-option-base/component.tsx
@@ -83,7 +83,6 @@ function ToggleGroupControlOptionBase(
value,
children,
showTooltip = false,
- onFocus: onFocusProp,
disabled,
...otherButtonProps
} = buttonProps;
@@ -134,7 +133,6 @@ function ToggleGroupControlOptionBase(
-
+
- Toggle context falseValue
+ Toggle context value
@@ -83,4 +83,16 @@ class="foo"
data-testid="can use classes with several dashes"
>
+
+
+
+ Toggle context val
+
+
diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-class/view.js b/packages/e2e-tests/plugins/interactive-blocks/directive-class/view.js
index f008ae910673fd..9ae5207c0e05c5 100644
--- a/packages/e2e-tests/plugins/interactive-blocks/directive-class/view.js
+++ b/packages/e2e-tests/plugins/interactive-blocks/directive-class/view.js
@@ -15,9 +15,9 @@ const { state } = store( 'directive-class', {
toggleFalseValue: () => {
state.falseValue = ! state.falseValue;
},
- toggleContextFalseValue: () => {
+ toggleContextValue: () => {
const context = getContext();
- context.falseValue = ! context.falseValue;
+ context.value = ! context.value;
},
},
} );
diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-init/view.js b/packages/e2e-tests/plugins/interactive-blocks/directive-init/view.js
index 9990e2743c8799..a8c70a4a907207 100644
--- a/packages/e2e-tests/plugins/interactive-blocks/directive-init/view.js
+++ b/packages/e2e-tests/plugins/interactive-blocks/directive-init/view.js
@@ -12,7 +12,7 @@ const { directive } = privateApis(
directive(
'show-mock',
( { directives: { 'show-mock': showMock }, element, evaluate } ) => {
- const entry = showMock.find( ( { suffix } ) => suffix === 'default' );
+ const entry = showMock.find( ( { suffix } ) => suffix === null );
if ( ! evaluate( entry ) ) {
return null;
}
diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-on-document/view.js b/packages/e2e-tests/plugins/interactive-blocks/directive-on-document/view.js
index 19ffc2a530193b..b9689ac978f85f 100644
--- a/packages/e2e-tests/plugins/interactive-blocks/directive-on-document/view.js
+++ b/packages/e2e-tests/plugins/interactive-blocks/directive-on-document/view.js
@@ -12,7 +12,7 @@ const { directive } = privateApis(
directive(
'show-mock',
( { directives: { 'show-mock': showMock }, element, evaluate } ) => {
- const entry = showMock.find( ( { suffix } ) => suffix === 'default' );
+ const entry = showMock.find( ( { suffix } ) => suffix === null );
if ( ! evaluate( entry ) ) {
return null;
}
diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-on-window/view.js b/packages/e2e-tests/plugins/interactive-blocks/directive-on-window/view.js
index e22379ad4d0775..ef72e266e10759 100644
--- a/packages/e2e-tests/plugins/interactive-blocks/directive-on-window/view.js
+++ b/packages/e2e-tests/plugins/interactive-blocks/directive-on-window/view.js
@@ -12,7 +12,7 @@ const { directive } = privateApis(
directive(
'show-mock',
( { directives: { 'show-mock': showMock }, element, evaluate } ) => {
- const entry = showMock.find( ( { suffix } ) => suffix === 'default' );
+ const entry = showMock.find( ( { suffix } ) => suffix === null );
if ( ! evaluate( entry ) ) {
return null;
}
diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-priorities/view.js b/packages/e2e-tests/plugins/interactive-blocks/directive-priorities/view.js
index 5a46908f77d87b..77f2f25c5f9a41 100644
--- a/packages/e2e-tests/plugins/interactive-blocks/directive-priorities/view.js
+++ b/packages/e2e-tests/plugins/interactive-blocks/directive-priorities/view.js
@@ -41,13 +41,13 @@ directive(
'test-context',
( { context: { Provider }, props: { children } } ) => {
executionProof( 'context' );
- const value = {
+ const client = {
[ namespace ]: proxifyState( namespace, {
attribute: 'from context',
text: 'from context',
} ),
};
- return h( Provider, { value }, children );
+ return h( Provider, { value: { client } }, children );
},
{ priority: 8 }
);
diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-run/view.js b/packages/e2e-tests/plugins/interactive-blocks/directive-run/view.js
index cb9be34b2460a8..125ac392042306 100644
--- a/packages/e2e-tests/plugins/interactive-blocks/directive-run/view.js
+++ b/packages/e2e-tests/plugins/interactive-blocks/directive-run/view.js
@@ -21,9 +21,7 @@ directive(
element,
evaluate,
} ) => {
- const entry = showChildren.find(
- ( { suffix } ) => suffix === 'default'
- );
+ const entry = showChildren.find( ( { suffix } ) => suffix === null );
return evaluate( entry )
? element
: cloneElement( element, { children: null } );
diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-watch/view.js b/packages/e2e-tests/plugins/interactive-blocks/directive-watch/view.js
index af2d452a104319..ad035811a0bcd7 100644
--- a/packages/e2e-tests/plugins/interactive-blocks/directive-watch/view.js
+++ b/packages/e2e-tests/plugins/interactive-blocks/directive-watch/view.js
@@ -12,7 +12,7 @@ const { directive } = privateApis(
directive(
'show-mock',
( { directives: { 'show-mock': showMock }, element, evaluate } ) => {
- const entry = showMock.find( ( { suffix } ) => suffix === 'default' );
+ const entry = showMock.find( ( { suffix } ) => suffix === null );
if ( ! evaluate( entry ) ) {
return null;
}
diff --git a/packages/e2e-tests/plugins/interactive-blocks/get-server-context/block.json b/packages/e2e-tests/plugins/interactive-blocks/get-server-context/block.json
new file mode 100644
index 00000000000000..c635846328b9e4
--- /dev/null
+++ b/packages/e2e-tests/plugins/interactive-blocks/get-server-context/block.json
@@ -0,0 +1,15 @@
+{
+ "$schema": "https://schemas.wp.org/trunk/block.json",
+ "apiVersion": 2,
+ "name": "test/get-server-context",
+ "title": "E2E Interactivity tests - getServerContext",
+ "category": "text",
+ "icon": "heart",
+ "description": "",
+ "supports": {
+ "interactivity": true
+ },
+ "textdomain": "e2e-interactivity",
+ "viewScriptModule": "file:./view.js",
+ "render": "file:./render.php"
+}
diff --git a/packages/e2e-tests/plugins/interactive-blocks/get-server-context/render.php b/packages/e2e-tests/plugins/interactive-blocks/get-server-context/render.php
new file mode 100644
index 00000000000000..a71ced20dc46a1
--- /dev/null
+++ b/packages/e2e-tests/plugins/interactive-blocks/get-server-context/render.php
@@ -0,0 +1,51 @@
+
+
+
+
+
+>
+
+ >
+
+
+
+
+
+
+
+
'modify' ) ); ?>
+ data-wp-on--click="actions.attemptModification"
+ data-wp-text="context.result">
+ >
+ modify
+
+
+
diff --git a/packages/e2e-tests/plugins/interactive-blocks/get-server-context/view.asset.php b/packages/e2e-tests/plugins/interactive-blocks/get-server-context/view.asset.php
new file mode 100644
index 00000000000000..bdaec8d1b67a9d
--- /dev/null
+++ b/packages/e2e-tests/plugins/interactive-blocks/get-server-context/view.asset.php
@@ -0,0 +1,9 @@
+ array(
+ '@wordpress/interactivity',
+ array(
+ 'id' => '@wordpress/interactivity-router',
+ 'import' => 'dynamic',
+ ),
+ ),
+);
diff --git a/packages/e2e-tests/plugins/interactive-blocks/get-server-context/view.js b/packages/e2e-tests/plugins/interactive-blocks/get-server-context/view.js
new file mode 100644
index 00000000000000..83f016e2eac16a
--- /dev/null
+++ b/packages/e2e-tests/plugins/interactive-blocks/get-server-context/view.js
@@ -0,0 +1,46 @@
+/**
+ * WordPress dependencies
+ */
+import { store, getContext, getServerContext } from '@wordpress/interactivity';
+
+store( 'test/get-server-context', {
+ actions: {
+ *navigate( e ) {
+ e.preventDefault();
+ const { actions } = yield import(
+ '@wordpress/interactivity-router'
+ );
+ yield actions.navigate( e.target.href );
+ },
+ attemptModification() {
+ try {
+ getServerContext().prop = 'updated from client';
+ getContext().result = 'unexpectedly modified ❌';
+ } catch ( e ) {
+ getContext().result = 'not modified ✅';
+ }
+ },
+ },
+ callbacks: {
+ updateServerContextParent() {
+ const ctx = getContext();
+ const { prop, newProp, nested, inherited } = getServerContext();
+ ctx.prop = prop;
+ ctx.newProp = newProp;
+ ctx.nested.prop = nested.prop;
+ ctx.nested.newProp = nested.newProp;
+ ctx.inherited.prop = inherited.prop;
+ ctx.inherited.newProp = inherited.newProp;
+ },
+ updateServerContextChild() {
+ const ctx = getContext();
+ const { prop, newProp, nested, inherited } = getServerContext();
+ ctx.prop = prop;
+ ctx.newProp = newProp;
+ ctx.nested.prop = nested.prop;
+ ctx.nested.newProp = nested.newProp;
+ ctx.inherited.prop = inherited.prop;
+ ctx.inherited.newProp = inherited.newProp;
+ },
+ },
+} );
diff --git a/packages/e2e-tests/plugins/interactive-blocks/get-server-state/block.json b/packages/e2e-tests/plugins/interactive-blocks/get-server-state/block.json
new file mode 100644
index 00000000000000..abf76eb9beddcc
--- /dev/null
+++ b/packages/e2e-tests/plugins/interactive-blocks/get-server-state/block.json
@@ -0,0 +1,15 @@
+{
+ "$schema": "https://schemas.wp.org/trunk/block.json",
+ "apiVersion": 2,
+ "name": "test/get-server-state",
+ "title": "E2E Interactivity tests - getServerState",
+ "category": "text",
+ "icon": "heart",
+ "description": "",
+ "supports": {
+ "interactivity": true
+ },
+ "textdomain": "e2e-interactivity",
+ "viewScriptModule": "file:./view.js",
+ "render": "file:./render.php"
+}
diff --git a/packages/e2e-tests/plugins/interactive-blocks/get-server-state/render.php b/packages/e2e-tests/plugins/interactive-blocks/get-server-state/render.php
new file mode 100644
index 00000000000000..abc4efd8272d5b
--- /dev/null
+++ b/packages/e2e-tests/plugins/interactive-blocks/get-server-state/render.php
@@ -0,0 +1,50 @@
+
+
+
+
+
+
+
+
+
'modify' ) ); ?>
+ data-wp-on--click="actions.attemptModification"
+ data-wp-text="context.result">
+ >
+ modify
+
+
+
+
+
diff --git a/packages/e2e-tests/plugins/interactive-blocks/get-server-state/view.asset.php b/packages/e2e-tests/plugins/interactive-blocks/get-server-state/view.asset.php
new file mode 100644
index 00000000000000..bdaec8d1b67a9d
--- /dev/null
+++ b/packages/e2e-tests/plugins/interactive-blocks/get-server-state/view.asset.php
@@ -0,0 +1,9 @@
+ array(
+ '@wordpress/interactivity',
+ array(
+ 'id' => '@wordpress/interactivity-router',
+ 'import' => 'dynamic',
+ ),
+ ),
+);
diff --git a/packages/e2e-tests/plugins/interactive-blocks/get-server-state/view.js b/packages/e2e-tests/plugins/interactive-blocks/get-server-state/view.js
new file mode 100644
index 00000000000000..db2992ec4a5863
--- /dev/null
+++ b/packages/e2e-tests/plugins/interactive-blocks/get-server-state/view.js
@@ -0,0 +1,33 @@
+/**
+ * WordPress dependencies
+ */
+import { store, getServerState, getContext } from '@wordpress/interactivity';
+
+const { state } = store( 'test/get-server-state', {
+ actions: {
+ *navigate( e ) {
+ e.preventDefault();
+ const { actions } = yield import(
+ '@wordpress/interactivity-router'
+ );
+ yield actions.navigate( e.target.href );
+ },
+ attemptModification() {
+ try {
+ getServerState().prop = 'updated from client';
+ getContext().result = 'unexpectedly modified ❌';
+ } catch ( e ) {
+ getContext().result = 'not modified ✅';
+ }
+ },
+ },
+ callbacks: {
+ updateState() {
+ const { prop, newProp, nested } = getServerState();
+ state.prop = prop;
+ state.newProp = newProp;
+ state.nested.prop = nested.prop;
+ state.nested.newProp = nested.newProp;
+ },
+ },
+} );
diff --git a/packages/e2e-tests/plugins/interactive-blocks/tovdom-islands/view.js b/packages/e2e-tests/plugins/interactive-blocks/tovdom-islands/view.js
index 2993a273486c2b..8016e931624a16 100644
--- a/packages/e2e-tests/plugins/interactive-blocks/tovdom-islands/view.js
+++ b/packages/e2e-tests/plugins/interactive-blocks/tovdom-islands/view.js
@@ -12,7 +12,7 @@ const { directive, h } = privateApis(
directive(
'show-mock',
( { directives: { 'show-mock': showMock }, element, evaluate } ) => {
- const entry = showMock.find( ( { suffix } ) => suffix === 'default' );
+ const entry = showMock.find( ( { suffix } ) => suffix === null );
if ( ! evaluate( entry ) ) {
element.props.children = h(
diff --git a/packages/edit-post/CHANGELOG.md b/packages/edit-post/CHANGELOG.md
index cd4b4a36295f07..3eb52f26e65012 100644
--- a/packages/edit-post/CHANGELOG.md
+++ b/packages/edit-post/CHANGELOG.md
@@ -2,6 +2,8 @@
## Unreleased
+## 8.8.0 (2024-09-19)
+
## 8.7.0 (2024-09-05)
## 8.6.0 (2024-08-21)
diff --git a/packages/edit-post/package.json b/packages/edit-post/package.json
index 0d8e0b28f7c7c5..fea3d8ef832469 100644
--- a/packages/edit-post/package.json
+++ b/packages/edit-post/package.json
@@ -1,6 +1,6 @@
{
"name": "@wordpress/edit-post",
- "version": "8.7.0",
+ "version": "8.8.17",
"description": "Edit Post module for WordPress.",
"author": "The WordPress Contributors",
"license": "GPL-2.0-or-later",
diff --git a/packages/edit-post/src/components/layout/index.js b/packages/edit-post/src/components/layout/index.js
index e6451c6a4a4082..2f6127f89b9702 100644
--- a/packages/edit-post/src/components/layout/index.js
+++ b/packages/edit-post/src/components/layout/index.js
@@ -31,6 +31,7 @@ import {
useRef,
useState,
} from '@wordpress/element';
+import { chevronDown, chevronUp } from '@wordpress/icons';
import { store as noticesStore } from '@wordpress/notices';
import { store as preferencesStore } from '@wordpress/preferences';
import {
@@ -43,10 +44,12 @@ import { addQueryArgs } from '@wordpress/url';
import { decodeEntities } from '@wordpress/html-entities';
import { store as coreStore } from '@wordpress/core-data';
import {
+ Icon,
ResizableBox,
SlotFillProvider,
Tooltip,
VisuallyHidden,
+ __unstableUseNavigateRegions as useNavigateRegions,
} from '@wordpress/components';
import {
useMediaQuery,
@@ -75,7 +78,7 @@ import useNavigateToEntityRecord from '../../hooks/use-navigate-to-entity-record
const { getLayoutStyles } = unlock( blockEditorPrivateApis );
const { useCommands } = unlock( coreCommandsPrivateApis );
const { useCommandContext } = unlock( commandsPrivateApis );
-const { Editor, FullscreenMode } = unlock( editorPrivateApis );
+const { Editor, FullscreenMode, NavigableRegion } = unlock( editorPrivateApis );
const { BlockKeyboardShortcuts } = unlock( blockLibraryPrivateApis );
const DESIGN_POST_TYPES = [
'wp_template',
@@ -183,7 +186,7 @@ function MetaBoxesMain( { isLegacy } ) {
];
}, [] );
const { set: setPreference } = useDispatch( preferencesStore );
- const resizableBoxRef = useRef();
+ const metaBoxesMainRef = useRef();
const isShort = useMediaQuery( '(max-height: 549px)' );
const [ { min, max }, setHeightConstraints ] = useState( () => ( {} ) );
@@ -198,9 +201,9 @@ function MetaBoxesMain( { isLegacy } ) {
':scope > .components-notice-list'
);
const resizeHandle = container.querySelector(
- '.edit-post-meta-boxes-main__resize-handle'
+ '.edit-post-meta-boxes-main__presenter'
);
- const actualize = () => {
+ const deriveConstraints = () => {
const fullHeight = container.offsetHeight;
let nextMax = fullHeight;
for ( const element of noticeLists ) {
@@ -209,7 +212,7 @@ function MetaBoxesMain( { isLegacy } ) {
const nextMin = resizeHandle.offsetHeight;
setHeightConstraints( { min: nextMin, max: nextMax } );
};
- const observer = new window.ResizeObserver( actualize );
+ const observer = new window.ResizeObserver( deriveConstraints );
observer.observe( container );
for ( const element of noticeLists ) {
observer.observe( element );
@@ -221,12 +224,33 @@ function MetaBoxesMain( { isLegacy } ) {
const separatorHelpId = useId();
const [ isUntouched, setIsUntouched ] = useState( true );
+ const applyHeight = ( candidateHeight, isPersistent, isInstant ) => {
+ const nextHeight = Math.min( max, Math.max( min, candidateHeight ) );
+ if ( isPersistent ) {
+ setPreference(
+ 'core/edit-post',
+ 'metaBoxesMainOpenHeight',
+ nextHeight
+ );
+ } else {
+ separatorRef.current.ariaValueNow = getAriaValueNow( nextHeight );
+ }
+ if ( isInstant ) {
+ metaBoxesMainRef.current.updateSize( {
+ height: nextHeight,
+ // Oddly, when the event that triggered this was not from the mouse (e.g. keydown),
+ // if `width` is left unspecified a subsequent drag gesture applies a fixed
+ // width and the pane fails to widen/narrow with parent width changes from
+ // sidebars opening/closing or window resizes.
+ width: 'auto',
+ } );
+ }
+ };
if ( ! hasAnyVisible ) {
return;
}
- const className = 'edit-post-meta-boxes-main';
const contents = (
@@ -256,59 +281,39 @@ function MetaBoxesMain( { isLegacy } ) {
const usedAriaValueNow =
max === undefined || isAutoHeight ? 50 : getAriaValueNow( openHeight );
- if ( isShort ) {
- return (
-
{
- setPreference(
- 'core/edit-post',
- 'metaBoxesMainIsOpen',
- target.open
- );
- } }
- >
- { __( 'Meta Boxes' ) }
- { contents }
-
- );
- }
+ const toggle = () =>
+ setPreference( 'core/edit-post', 'metaBoxesMainIsOpen', ! isOpen );
// TODO: Support more/all keyboard interactions from the window splitter pattern:
// https://www.w3.org/WAI/ARIA/apg/patterns/windowsplitter/
const onSeparatorKeyDown = ( event ) => {
const delta = { ArrowUp: 20, ArrowDown: -20 }[ event.key ];
if ( delta ) {
- const { resizable } = resizableBoxRef.current;
- const fromHeight = isAutoHeight
- ? resizable.offsetHeight
- : openHeight;
- const nextHeight = Math.min(
- max,
- Math.max( min, delta + fromHeight )
- );
- resizableBoxRef.current.updateSize( {
- height: nextHeight,
- // Oddly, if left unspecified a subsequent drag gesture applies a fixed
- // width and the pane fails to shrink/grow with parent width changes from
- // sidebars opening/closing or window resizes.
- width: 'auto',
- } );
- setPreference(
- 'core/edit-post',
- 'metaBoxesMainOpenHeight',
- nextHeight
- );
+ const pane = metaBoxesMainRef.current.resizable;
+ const fromHeight = isAutoHeight ? pane.offsetHeight : openHeight;
+ const nextHeight = delta + fromHeight;
+ applyHeight( nextHeight, true, true );
+ event.preventDefault();
}
};
-
- return (
-
[0]} */ ( {
+ as: NavigableRegion,
+ ref: metaBoxesMainRef,
+ className: clsx( className, 'is-resizable' ),
+ defaultSize: { height: openHeight },
+ minHeight: min,
+ maxHeight: usedMax,
+ enable: {
top: true,
right: false,
bottom: false,
@@ -317,72 +322,66 @@ function MetaBoxesMain( { isLegacy } ) {
topRight: false,
bottomRight: false,
bottomLeft: false,
- } }
- minHeight={ min }
- maxHeight={ usedMax }
- bounds="parent"
- boundsByDirection
- // Avoids hiccups while dragging over objects like iframes and ensures that
- // the event to end the drag is captured by the target (resize handle)
- // whether or not it’s under the pointer.
- onPointerDown={ ( { pointerId, target } ) => {
- target.setPointerCapture( pointerId );
- } }
- onResizeStart={ ( event, direction, elementRef ) => {
- if ( isAutoHeight ) {
- const heightNow = elementRef.offsetHeight;
- // Sets the starting height to avoid visual jumps in height and
- // aria-valuenow being `NaN` for the first (few) resize events.
- resizableBoxRef.current.updateSize( { height: heightNow } );
- // Causes `maxHeight` to update to full `max` value instead of half.
- setIsUntouched( false );
- }
- } }
- onResize={ () => {
- const { height } = resizableBoxRef.current.state;
- const separator = separatorRef.current;
- separator.ariaValueNow = getAriaValueNow( height );
- } }
- onResizeStop={ () => {
- const nextHeight = resizableBoxRef.current.state.height;
- setPreference(
- 'core/edit-post',
- 'metaBoxesMainOpenHeight',
- nextHeight
- );
- } }
- handleClasses={ {
- top: 'edit-post-meta-boxes-main__resize-handle',
- } }
- handleComponent={ {
+ },
+ handleClasses: { top: 'edit-post-meta-boxes-main__presenter' },
+ handleComponent: {
top: (
<>
- { /* Disable reason: aria-valuenow is supported by separator role. */ }
- { /* eslint-disable-next-line jsx-a11y/role-supports-aria-props */ }
-
{ __(
- 'Use up and down arrow keys to resize the metabox panel.'
+ 'Use up and down arrow keys to resize the meta box panel.'
) }
>
),
- } }
- >
-
+ },
+ // Avoids hiccups while dragging over objects like iframes and ensures that
+ // the event to end the drag is captured by the target (resize handle)
+ // whether or not it’s under the pointer.
+ onPointerDown: ( { pointerId, target } ) => {
+ target.setPointerCapture( pointerId );
+ },
+ onResizeStart: ( event, direction, elementRef ) => {
+ if ( isAutoHeight ) {
+ // Sets the starting height to avoid visual jumps in height and
+ // aria-valuenow being `NaN` for the first (few) resize events.
+ applyHeight( elementRef.offsetHeight, false, true );
+ setIsUntouched( false );
+ }
+ },
+ onResize: () =>
+ applyHeight( metaBoxesMainRef.current.state.height ),
+ onResizeStop: () =>
+ applyHeight( metaBoxesMainRef.current.state.height, true ),
+ } );
+ }
+
+ return (
+
+ { isShort ? (
+
+ { paneLabel }
+
+
+ ) : (
+
+ ) }
{ contents }
-
+
);
}
@@ -398,7 +397,7 @@ function Layout( {
const shouldIframe = useShouldIframe();
const { createErrorNotice } = useDispatch( noticesStore );
const {
- currentPost,
+ currentPost: { postId: currentPostId, postType: currentPostType },
onNavigateToEntityRecord,
onNavigateToPreviousEntityRecord,
} = useNavigateToEntityRecord(
@@ -406,6 +405,7 @@ function Layout( {
initialPostType,
'post-only'
);
+ const isEditingTemplate = currentPostType === 'wp_template';
const {
mode,
isFullscreenActive,
@@ -415,7 +415,6 @@ function Layout( {
isDistractionFree,
showMetaBoxes,
hasHistory,
- isEditingTemplate,
isWelcomeGuideVisible,
templateId,
} = useSelect(
@@ -425,15 +424,20 @@ function Layout( {
select( editPostStore )
);
const { canUser, getPostType } = select( coreStore );
+ const { __unstableGetEditorMode } = unlock(
+ select( blockEditorStore )
+ );
const supportsTemplateMode = settings.supportsTemplateMode;
const isViewable =
- getPostType( currentPost.postType )?.viewable ?? false;
+ getPostType( currentPostType )?.viewable ?? false;
const canViewTemplate = canUser( 'read', {
kind: 'postType',
name: 'wp_template',
} );
+ const isZoomOut = __unstableGetEditorMode() === 'zoom-out';
+
return {
mode: select( editorStore ).getEditorMode(),
isFullscreenActive:
@@ -444,21 +448,20 @@ function Layout( {
showIconLabels: get( 'core', 'showIconLabels' ),
isDistractionFree: get( 'core', 'distractionFree' ),
showMetaBoxes:
- select( editorStore ).getRenderingMode() === 'post-only',
- isEditingTemplate:
- select( editorStore ).getCurrentPostType() ===
- 'wp_template',
+ ! DESIGN_POST_TYPES.includes( currentPostType ) &&
+ select( editorStore ).getRenderingMode() === 'post-only' &&
+ ! isZoomOut,
isWelcomeGuideVisible: isFeatureActive( 'welcomeGuide' ),
templateId:
supportsTemplateMode &&
isViewable &&
canViewTemplate &&
- currentPost.postType !== 'wp_template'
+ ! isEditingTemplate
? getEditedPostTemplateId()
: null,
};
},
- [ settings.supportsTemplateMode, currentPost.postType ]
+ [ currentPostType, isEditingTemplate, settings.supportsTemplateMode ]
);
// Set the right context for the command palette
@@ -484,6 +487,8 @@ function Layout( {
document.body.classList.remove( 'show-icon-labels' );
}
+ const navigateRegionsProps = useNavigateRegions();
+
const className = clsx( 'edit-post-layout', 'is-mode-' + mode, {
'has-metaboxes': hasActiveMetaboxes,
} );
@@ -523,7 +528,7 @@ function Layout( {
: newItem.title?.rendered;
createSuccessNotice(
sprintf(
- // translators: %s: Title of the created post e.g: "Post 1".
+ // translators: %s: Title of the created post or template, e.g: "Hello world".
__( '"%s" successfully created.' ),
decodeEntities( title )
),
@@ -568,48 +573,54 @@ function Layout( {
-
-
- }
- extraContent={
- ! isDistractionFree &&
- showMetaBoxes && (
-
- )
- }
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
- { backButton }
-
-
+
+ }
+ extraContent={
+ ! isDistractionFree &&
+ showMetaBoxes && (
+
+ )
+ }
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+ { backButton }
+
+
+
);
diff --git a/packages/edit-post/src/components/layout/style.scss b/packages/edit-post/src/components/layout/style.scss
index 5d0a9a29c329ca..18f12c1dbfbb92 100644
--- a/packages/edit-post/src/components/layout/style.scss
+++ b/packages/edit-post/src/components/layout/style.scss
@@ -1,71 +1,111 @@
-$resize-handle-height: $grid-unit-30;
-
.edit-post-meta-boxes-main {
filter: drop-shadow(0 -1px rgba($color: #000, $alpha: 0.133)); // 0.133 = $gray-200 but with alpha.
+ // Windows High Contrast mode will show this outline, but not the shadow.
+ outline: 1px solid transparent;
background-color: $white;
- clear: both; // This is seemingly only needed in case the canvas is not iframe’d.
-
- &:not(details) {
- padding-top: $resize-handle-height;
- }
-
- // The component renders as a details element in short viewports.
- &:is(details) {
- & > summary {
- cursor: pointer;
- color: $gray-900;
- background-color: $white;
- height: $button-size-compact;
- line-height: $button-size-compact;
- font-size: 13px;
- padding-left: $grid-unit-30;
- box-shadow: 0 $border-width $gray-300;
- }
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
- &[open] > summary {
- position: sticky;
- top: 0;
- z-index: 1;
- }
+ &.is-resizable {
+ padding-block-start: $grid-unit-30;
}
}
-.edit-post-meta-boxes-main__resize-handle {
+.edit-post-meta-boxes-main__presenter {
display: flex;
- // The position is absolute by default inline style of ResizableBox.
- inset: 0 0 auto 0;
- height: $resize-handle-height;
box-shadow: 0 $border-width $gray-300;
+ // Windows High Contrast mode will show this outline, but not the shadow.
+ outline: 1px solid transparent;
+ position: relative;
+ z-index: 1;
- & > button {
+ // Button style reset for both toggle or resizable.
+ .is-toggle-only > &,
+ .is-resizable.edit-post-meta-boxes-main & > button {
appearance: none;
- cursor: inherit;
- margin: auto;
padding: 0;
border: none;
outline: none;
- background-color: $gray-300;
- width: $grid-unit-80;
- height: $grid-unit-05;
- border-radius: $radius-small;
- transition: width 0.3s ease-out;
- @include reduce-motion("transition");
+ background-color: transparent;
}
- &:hover > button,
- > button:focus {
- background-color: var(--wp-admin-theme-color);
- width: $grid-unit-80 + $grid-unit-20;
+ .is-toggle-only > & {
+ flex-shrink: 0;
+ cursor: pointer;
+ height: $button-size-compact;
+ justify-content: space-between;
+ align-items: center;
+ padding-inline: $grid-unit-30 $grid-unit-15;
+
+ &:is(:hover, :focus-visible) {
+ color: var(--wp-admin-theme-color);
+ }
+ &:focus-visible::after {
+ content: "";
+ position: absolute;
+ inset: var(--wp-admin-border-width-focus);
+ @include button-style__focus();
+ }
+ > svg {
+ fill: currentColor;
+ }
+ }
+
+ .is-resizable.edit-post-meta-boxes-main & {
+ inset: 0 0 auto;
+
+ > button {
+ cursor: inherit;
+ width: $grid-unit-80;
+ height: $grid-unit-30;
+ margin: auto;
+
+ &::before {
+ content: "";
+ background-color: $gray-300;
+ // Windows High Contrast mode will show this outline, but not the background-color.
+ outline: 2px solid transparent;
+ outline-offset: -2px;
+ position: absolute;
+ inset-block: calc(50% - #{$grid-unit-05} / 2) auto;
+ transform: translateX(-50%);
+ width: inherit;
+ height: $grid-unit-05;
+ border-radius: $radius-small;
+ transition: width 0.3s ease-out;
+ @include reduce-motion("transition");
+ }
+ }
+
+ &:is(:hover, :focus-within) > button::before {
+ background-color: var(--wp-admin-theme-color);
+ width: $grid-unit-80 + $grid-unit-20;
+ }
+ }
+}
+
+@media (pointer: coarse) {
+ .is-resizable.edit-post-meta-boxes-main {
+ padding-block-start: $button-size-compact;
+
+ .edit-post-meta-boxes-main__presenter > button {
+ height: $button-size-compact;
+ }
}
}
.edit-post-meta-boxes-main__liner {
overflow: auto;
- max-height: 100%;
// Keep the contents behind the resize handle or details summary.
isolation: isolate;
}
+// In case the canvas is not iframe’d.
+.edit-post-layout__metaboxes {
+ clear: both;
+}
+
.has-metaboxes .editor-visual-editor {
flex: 1;
diff --git a/packages/edit-post/src/components/layout/use-padding-appender.js b/packages/edit-post/src/components/layout/use-padding-appender.js
index efd46a485058ca..8bd245e278aec4 100644
--- a/packages/edit-post/src/components/layout/use-padding-appender.js
+++ b/packages/edit-post/src/components/layout/use-padding-appender.js
@@ -11,7 +11,13 @@ export function usePaddingAppender() {
return useRefEffect(
( node ) => {
function onMouseDown( event ) {
- if ( event.target !== node ) {
+ if (
+ event.target !== node &&
+ // Tests for the parent element because in the iframed editor if the click is
+ // below the padding the target will be the parent element (html) and should
+ // still be treated as intent to append.
+ event.target !== node.parentElement
+ ) {
return;
}
@@ -38,7 +44,7 @@ export function usePaddingAppender() {
return;
}
- event.stopPropagation();
+ event.preventDefault();
const blockOrder = registry
.select( blockEditorStore )
@@ -57,9 +63,12 @@ export function usePaddingAppender() {
insertDefaultBlock();
}
}
- node.addEventListener( 'mousedown', onMouseDown );
+ const { ownerDocument } = node;
+ // Adds the listener on the document so that in the iframed editor clicks below the
+ // padding can be handled as they too should be treated as intent to append.
+ ownerDocument.addEventListener( 'mousedown', onMouseDown );
return () => {
- node.removeEventListener( 'mousedown', onMouseDown );
+ ownerDocument.removeEventListener( 'mousedown', onMouseDown );
};
},
[ registry ]
diff --git a/packages/edit-post/src/index.js b/packages/edit-post/src/index.js
index daf789cb0a2ec9..685ffc56f63a80 100644
--- a/packages/edit-post/src/index.js
+++ b/packages/edit-post/src/index.js
@@ -28,7 +28,6 @@ import { unlock } from './lock-unlock';
const {
BackButton: __experimentalMainDashboardButton,
registerCoreBlockBindingsSources,
- bootstrapBlockBindingsSourcesFromServer,
} = unlock( editorPrivateApis );
/**
@@ -95,7 +94,6 @@ export function initializeEditor(
}
registerCoreBlocks();
- bootstrapBlockBindingsSourcesFromServer( settings?.blockBindingsSources );
registerCoreBlockBindingsSources();
registerLegacyWidgetBlock( { inserter: false } );
registerWidgetGroupBlock( { inserter: false } );
diff --git a/packages/edit-post/src/store/actions.js b/packages/edit-post/src/store/actions.js
index d00f7472382f80..7ab0a965379be2 100644
--- a/packages/edit-post/src/store/actions.js
+++ b/packages/edit-post/src/store/actions.js
@@ -8,7 +8,7 @@ import {
privateApis as editorPrivateApis,
} from '@wordpress/editor';
import deprecated from '@wordpress/deprecated';
-import { addFilter } from '@wordpress/hooks';
+import { addAction } from '@wordpress/hooks';
import { store as coreStore } from '@wordpress/core-data';
/**
@@ -478,21 +478,14 @@ export const initializeMetaBoxes =
metaBoxesInitialized = true;
// Save metaboxes on save completion, except for autosaves.
- addFilter(
- 'editor.__unstableSavePost',
+ addAction(
+ 'editor.savePost',
'core/edit-post/save-metaboxes',
- ( previous, options ) =>
- previous.then( () => {
- if ( options.isAutosave ) {
- return;
- }
-
- if ( ! select.hasMetaBoxes() ) {
- return;
- }
-
- return dispatch.requestMetaBoxUpdates();
- } )
+ async ( post, options ) => {
+ if ( ! options.isAutosave && select.hasMetaBoxes() ) {
+ await dispatch.requestMetaBoxUpdates();
+ }
+ }
);
dispatch( {
diff --git a/packages/edit-site/CHANGELOG.md b/packages/edit-site/CHANGELOG.md
index 025d2389e4b01d..b14fb6dcd6d3e8 100644
--- a/packages/edit-site/CHANGELOG.md
+++ b/packages/edit-site/CHANGELOG.md
@@ -2,6 +2,8 @@
## Unreleased
+## 6.8.0 (2024-09-19)
+
## 6.7.0 (2024-09-05)
## 6.6.0 (2024-08-21)
diff --git a/packages/edit-site/package.json b/packages/edit-site/package.json
index 43bcf68aa68e68..022f74aec457c9 100644
--- a/packages/edit-site/package.json
+++ b/packages/edit-site/package.json
@@ -1,6 +1,6 @@
{
"name": "@wordpress/edit-site",
- "version": "6.7.0",
+ "version": "6.8.17",
"description": "Edit Site Page module for WordPress.",
"author": "The WordPress Contributors",
"license": "GPL-2.0-or-later",
diff --git a/packages/edit-site/src/components/add-new-post/index.js b/packages/edit-site/src/components/add-new-post/index.js
index 044e3c703b9948..04e286e3967a44 100644
--- a/packages/edit-site/src/components/add-new-post/index.js
+++ b/packages/edit-site/src/components/add-new-post/index.js
@@ -64,7 +64,7 @@ export default function AddNewPostModal( { postType, onSave, onClose } ) {
createSuccessNotice(
sprintf(
- // translators: %s: Title of the created post e.g: "Hello world".
+ // translators: %s: Title of the created post or template, e.g: "Hello world".
__( '"%s" successfully created.' ),
decodeEntities( newPage.title?.rendered || title )
),
diff --git a/packages/edit-site/src/components/add-new-template/add-custom-template-modal-content.js b/packages/edit-site/src/components/add-new-template/add-custom-template-modal-content.js
index 4993f12153b9e4..69f1925c7b0e44 100644
--- a/packages/edit-site/src/components/add-new-template/add-custom-template-modal-content.js
+++ b/packages/edit-site/src/components/add-new-template/add-custom-template-modal-content.js
@@ -36,8 +36,7 @@ function SuggestionListItem( {
diff --git a/packages/edit-site/src/components/add-new-template/index.js b/packages/edit-site/src/components/add-new-template/index.js
index 933ea6df1198bb..1a2d9ea727fa85 100644
--- a/packages/edit-site/src/components/add-new-template/index.js
+++ b/packages/edit-site/src/components/add-new-template/index.js
@@ -107,8 +107,7 @@ function TemplateListItem( {
} ) {
return (
{
if ( _needsUniqueIdentifier ) {
menuItemTitle = labels.template_name
? sprintf(
- // translators: %1s: Name of the template e.g: "Single Item: Post"; %2s: Slug of the post type e.g: "book".
- __( '%1$s (%2$s)' ),
+ // translators: 1: Name of the template e.g: "Single Item: Post". 2: Slug of the post type e.g: "book".
+ _x( '%1$s (%2$s)', 'post type menu label' ),
labels.template_name,
slug
)
: sprintf(
- // translators: %1s: Name of the post type e.g: "Post"; %2s: Slug of the post type e.g: "book".
- __( 'Single item: %1$s (%2$s)' ),
+ // translators: 1: Name of the post type e.g: "Post". 2: Slug of the post type e.g: "book".
+ _x(
+ 'Single item: %1$s (%2$s)',
+ 'post type menu label'
+ ),
labels.singular_name,
slug
);
@@ -410,14 +413,14 @@ export const useTaxonomiesMenuItems = ( onClickMenuItem ) => {
if ( _needsUniqueIdentifier ) {
menuItemTitle = labels.template_name
? sprintf(
- // translators: %1s: Name of the template e.g: "Products by Category"; %2s: Slug of the taxonomy e.g: "product_cat".
- __( '%1$s (%2$s)' ),
+ // translators: 1: Name of the template e.g: "Products by Category". 2s: Slug of the taxonomy e.g: "product_cat".
+ _x( '%1$s (%2$s)', 'taxonomy template menu label' ),
labels.template_name,
slug
)
: sprintf(
- // translators: %1s: Name of the taxonomy e.g: "Category"; %2s: Slug of the taxonomy e.g: "product_cat".
- __( '%1$s (%2$s)' ),
+ // translators: 1: Name of the taxonomy e.g: "Category". 2: Slug of the taxonomy e.g: "product_cat".
+ _x( '%1$s (%2$s)', 'taxonomy menu label' ),
labels.singular_name,
slug
);
diff --git a/packages/edit-site/src/components/editor-canvas-container/index.js b/packages/edit-site/src/components/editor-canvas-container/index.js
index 36a3a5b43e5cd2..c55d6b188e1a25 100644
--- a/packages/edit-site/src/components/editor-canvas-container/index.js
+++ b/packages/edit-site/src/components/editor-canvas-container/index.js
@@ -125,8 +125,7 @@ function EditorCanvasContainer( {
>
{ shouldShowCloseButton && (
}
@@ -256,13 +259,17 @@ export default function EditSiteEditor( { isPostsList = false } ) {
whileTap="tap"
>
{
setCanvasMode( 'view' );
+ __unstableSetEditorMode(
+ 'edit'
+ );
+ resetZoomLevel();
+
// TODO: this is a temporary solution to navigate to the posts list if we are
// come here through `posts list` and are in focus mode editing a template, template part etc..
if (
@@ -292,7 +299,7 @@ export default function EditSiteEditor( { isPostsList = false } ) {
) }
variants={ toggleHomeIconVariants }
>
-
+
)
diff --git a/packages/edit-site/src/components/editor/style.scss b/packages/edit-site/src/components/editor/style.scss
index 10efff92af6434..a6cc5084966947 100644
--- a/packages/edit-site/src/components/editor/style.scss
+++ b/packages/edit-site/src/components/editor/style.scss
@@ -69,6 +69,10 @@
background-color: hsla(0, 0%, 80%);
pointer-events: none;
+ svg {
+ fill: currentColor;
+ }
+
&.has-site-icon {
background-color: hsla(0, 0%, 100%, 0.6);
-webkit-backdrop-filter: saturate(180%) blur(15px);
diff --git a/packages/edit-site/src/components/editor/use-editor-title.js b/packages/edit-site/src/components/editor/use-editor-title.js
index 01e258a5db1073..0645c2031a3af0 100644
--- a/packages/edit-site/src/components/editor/use-editor-title.js
+++ b/packages/edit-site/src/components/editor/use-editor-title.js
@@ -1,7 +1,7 @@
/**
* WordPress dependencies
*/
-import { __, sprintf } from '@wordpress/i18n';
+import { _x, sprintf } from '@wordpress/i18n';
/**
* Internal dependencies
@@ -19,8 +19,8 @@ function useEditorTitle() {
let title;
if ( hasLoadedPost ) {
title = sprintf(
- // translators: A breadcrumb trail for the Admin document title. %1$s: title of template being edited, %2$s: type of template (Template or Template Part).
- __( '%1$s ‹ %2$s' ),
+ // translators: A breadcrumb trail for the Admin document title. 1: title of template being edited, 2: type of template (Template or Template Part).
+ _x( '%1$s ‹ %2$s', 'breadcrumb trail' ),
getTitle(),
POST_TYPE_LABELS[ editedPost.type ] ??
POST_TYPE_LABELS[ TEMPLATE_POST_TYPE ]
diff --git a/packages/edit-site/src/components/error-boundary/warning.js b/packages/edit-site/src/components/error-boundary/warning.js
index b03c99f46f03b1..c4090c7e6b1190 100644
--- a/packages/edit-site/src/components/error-boundary/warning.js
+++ b/packages/edit-site/src/components/error-boundary/warning.js
@@ -9,12 +9,7 @@ import { useCopyToClipboard } from '@wordpress/compose';
function CopyButton( { text, children } ) {
const ref = useCopyToClipboard( text );
return (
-
+
{ children }
);
diff --git a/packages/edit-site/src/components/global-styles/font-families.js b/packages/edit-site/src/components/global-styles/font-families.js
index 6a554b136317dd..f3e81efbe597b0 100644
--- a/packages/edit-site/src/components/global-styles/font-families.js
+++ b/packages/edit-site/src/components/global-styles/font-families.js
@@ -1,14 +1,16 @@
/**
* WordPress dependencies
*/
-import { __, _x } from '@wordpress/i18n';
+import { __ } from '@wordpress/i18n';
import {
__experimentalText as Text,
__experimentalItemGroup as ItemGroup,
__experimentalVStack as VStack,
+ __experimentalHStack as HStack,
Button,
} from '@wordpress/components';
import { privateApis as blockEditorPrivateApis } from '@wordpress/block-editor';
+import { settings } from '@wordpress/icons';
import { useContext } from '@wordpress/element';
/**
@@ -25,6 +27,19 @@ import { unlock } from '../../lock-unlock';
const { useGlobalSetting } = unlock( blockEditorPrivateApis );
+/**
+ * Maps the fonts with the source, if available.
+ *
+ * @param {Array} fonts The fonts to map.
+ * @param {string} source The source of the fonts.
+ * @return {Array} The mapped fonts.
+ */
+function mapFontsWithSource( fonts, source ) {
+ return fonts
+ ? fonts.map( ( f ) => setUIValuesNeeded( f, { source } ) )
+ : [];
+}
+
function FontFamilies() {
const { baseCustomFonts, modalTabOpen, setModalTabOpen } =
useContext( FontLibraryContext );
@@ -34,18 +49,12 @@ function FontFamilies() {
undefined,
'base'
);
- const themeFonts = fontFamilies?.theme
- ? fontFamilies.theme
- .map( ( f ) => setUIValuesNeeded( f, { source: 'theme' } ) )
- .sort( ( a, b ) => a.name.localeCompare( b.name ) )
- : [];
- const customFonts = fontFamilies?.custom
- ? fontFamilies.custom
- .map( ( f ) => setUIValuesNeeded( f, { source: 'custom' } ) )
- .sort( ( a, b ) => a.name.localeCompare( b.name ) )
- : [];
- const hasFonts = 0 < customFonts.length || 0 < themeFonts.length;
-
+ const themeFonts = mapFontsWithSource( fontFamilies?.theme, 'theme' );
+ const customFonts = mapFontsWithSource( fontFamilies?.custom, 'custom' );
+ const activeFonts = [ ...themeFonts, ...customFonts ].sort( ( a, b ) =>
+ a.name.localeCompare( b.name )
+ );
+ const hasFonts = 0 < activeFonts.length;
const hasInstalledFonts =
hasFonts ||
baseFontFamilies?.theme?.length > 0 ||
@@ -60,69 +69,53 @@ function FontFamilies() {
/>
) }
-
- { themeFonts.length > 0 && (
-
-
- {
- /* translators: Heading for a list of fonts provided by the theme. */
- _x( 'Theme', 'font source' )
- }
-
+
+
+ { __( 'Fonts' ) }
+ setModalTabOpen( 'installed-fonts' ) }
+ label={ __( 'Manage fonts' ) }
+ icon={ settings }
+ size="small"
+ />
+
+ { activeFonts.length > 0 && (
+ <>
- { themeFonts.map( ( font ) => (
+ { activeFonts.map( ( font ) => (
) ) }
-
- ) }
- { customFonts.length > 0 && (
-
-
- {
- /* translators: Heading for a list of fonts installed by the user. */
- _x( 'Custom', 'font source' )
- }
-
-
- { customFonts.map( ( font ) => (
-
- ) ) }
-
-
+ >
) }
{ ! hasFonts && (
-
- { __( 'Fonts' ) }
+ <>
{ hasInstalledFonts
? __( 'No fonts activated.' )
: __( 'No fonts installed.' ) }
-
+ {
+ setModalTabOpen(
+ hasInstalledFonts
+ ? 'installed-fonts'
+ : 'upload-fonts'
+ );
+ } }
+ >
+ { hasInstalledFonts
+ ? __( 'Manage fonts' )
+ : __( 'Add fonts' ) }
+
+ >
) }
- {
- setModalTabOpen(
- hasInstalledFonts
- ? 'installed-fonts'
- : 'upload-fonts'
- );
- } }
- >
- { hasInstalledFonts
- ? __( 'Manage fonts' )
- : __( 'Add fonts' ) }
-
>
);
diff --git a/packages/edit-site/src/components/global-styles/font-sizes/font-size.js b/packages/edit-site/src/components/global-styles/font-sizes/font-size.js
index 25ff6812d583c9..9970e7354fc382 100644
--- a/packages/edit-site/src/components/global-styles/font-sizes/font-size.js
+++ b/packages/edit-site/src/components/global-styles/font-sizes/font-size.js
@@ -15,7 +15,7 @@ import {
ToggleControl,
} from '@wordpress/components';
import { moreVertical } from '@wordpress/icons';
-import { useState } from '@wordpress/element';
+import { useState, useEffect } from '@wordpress/element';
/**
* Internal dependencies
@@ -36,7 +36,6 @@ function FontSize() {
const {
params: { origin, slug },
- goBack,
goTo,
} = useNavigator();
@@ -54,10 +53,10 @@ function FontSize() {
// Whether the font size is fluid. If not defined, use the global fluid value of the theme.
const isFluid =
- fontSize.fluid !== undefined ? !! fontSize.fluid : !! globalFluid;
+ fontSize?.fluid !== undefined ? !! fontSize.fluid : !! globalFluid;
// Whether custom fluid values are used.
- const isCustomFluid = typeof fontSize.fluid === 'object';
+ const isCustomFluid = typeof fontSize?.fluid === 'object';
const handleNameChange = ( value ) => {
updateFontSize( 'name', value );
@@ -107,9 +106,6 @@ function FontSize() {
};
const handleRemoveFontSize = () => {
- // Navigate to the font sizes list.
- goBack();
-
const newFontSizes = sizes.filter( ( size ) => size.slug !== slug );
setFontSizes( {
...fontSizes,
@@ -125,6 +121,18 @@ function FontSize() {
setIsRenameDialogOpen( ! isRenameDialogOpen );
};
+ // Navigate to the font sizes list if the font size is not available.
+ useEffect( () => {
+ if ( ! fontSize ) {
+ goTo( '/typography/font-sizes/', { isBack: true } );
+ }
+ }, [ fontSize, goTo ] );
+
+ // Avoid rendering if the font size is not available.
+ if ( ! fontSize ) {
+ return null;
+ }
+
return (
<>
diff --git a/packages/edit-site/src/components/global-styles/screen-typeset.js b/packages/edit-site/src/components/global-styles/screen-typeset.js
deleted file mode 100644
index ce754121dfe1b5..00000000000000
--- a/packages/edit-site/src/components/global-styles/screen-typeset.js
+++ /dev/null
@@ -1,42 +0,0 @@
-/**
- * WordPress dependencies
- */
-import { __ } from '@wordpress/i18n';
-import { useSelect } from '@wordpress/data';
-import { store as editorStore } from '@wordpress/editor';
-import { __experimentalVStack as VStack } from '@wordpress/components';
-
-/**
- * Internal dependencies
- */
-import TypographyVariations from './variations/variations-typography';
-import ScreenHeader from './header';
-import FontFamilies from './font-families';
-
-function ScreenTypeset() {
- const fontLibraryEnabled = useSelect(
- ( select ) =>
- select( editorStore ).getEditorSettings().fontLibraryEnabled,
- []
- );
-
- return (
- <>
-
-
-
-
-
- { fontLibraryEnabled && }
-
-
- >
- );
-}
-
-export default ScreenTypeset;
diff --git a/packages/edit-site/src/components/global-styles/screen-typography.js b/packages/edit-site/src/components/global-styles/screen-typography.js
index c23592c51a6a2a..3739e3234258bd 100644
--- a/packages/edit-site/src/components/global-styles/screen-typography.js
+++ b/packages/edit-site/src/components/global-styles/screen-typography.js
@@ -11,8 +11,8 @@ import { store as editorStore } from '@wordpress/editor';
*/
import TypographyElements from './typography-elements';
import ScreenHeader from './header';
+import TypographyVariations from './variations/variations-typography';
import FontSizesCount from './font-sizes/font-sizes-count';
-import TypesetButton from './typeset-button';
import FontFamilies from './font-families';
function ScreenTypography() {
@@ -27,12 +27,12 @@ function ScreenTypography() {
-
+
{ fontLibraryEnabled && }
diff --git a/packages/edit-site/src/components/global-styles/shadows-edit-panel.js b/packages/edit-site/src/components/global-styles/shadows-edit-panel.js
index ec1dd1a900c3bf..555107d155f15e 100644
--- a/packages/edit-site/src/components/global-styles/shadows-edit-panel.js
+++ b/packages/edit-site/src/components/global-styles/shadows-edit-panel.js
@@ -35,7 +35,7 @@ import {
reset,
moreVertical,
} from '@wordpress/icons';
-import { useState, useMemo } from '@wordpress/element';
+import { useState, useMemo, useEffect } from '@wordpress/element';
/**
* Internal dependencies
@@ -73,12 +73,30 @@ const presetShadowMenuItems = [
export default function ShadowsEditPanel() {
const {
+ goBack,
params: { category, slug },
- goTo,
} = useNavigator();
const [ shadows, setShadows ] = useGlobalSetting(
`shadow.presets.${ category }`
);
+
+ useEffect( () => {
+ const hasCurrentShadow = shadows?.some(
+ ( shadow ) => shadow.slug === slug
+ );
+ // If the shadow being edited doesn't exist anymore in the global styles setting, navigate back
+ // to prevent the user from editing a non-existent shadow entry.
+ // This can happen, for example:
+ // - when the user deletes the shadow
+ // - when the user resets the styles while editing a custom shadow
+ //
+ // The check on the slug is necessary to prevent a double back navigation when the user triggers
+ // a backward navigation by interacting with the screen's UI.
+ if ( !! slug && ! hasCurrentShadow ) {
+ goBack();
+ }
+ }, [ shadows, slug, goBack ] );
+
const [ baseShadows ] = useGlobalSetting(
`shadow.presets.${ category }`,
undefined,
@@ -119,9 +137,7 @@ export default function ShadowsEditPanel() {
};
const handleShadowDelete = () => {
- const updatedShadows = shadows.filter( ( s ) => s.slug !== slug );
- setShadows( updatedShadows );
- goTo( `/shadows` );
+ setShadows( shadows.filter( ( s ) => s.slug !== slug ) );
};
const handleShadowRename = ( newName ) => {
diff --git a/packages/edit-site/src/components/global-styles/typeset-button.js b/packages/edit-site/src/components/global-styles/typeset-button.js
deleted file mode 100644
index bcd450def06f8e..00000000000000
--- a/packages/edit-site/src/components/global-styles/typeset-button.js
+++ /dev/null
@@ -1,100 +0,0 @@
-/**
- * WordPress dependencies
- */
-import { isRTL, __ } from '@wordpress/i18n';
-import {
- __experimentalItemGroup as ItemGroup,
- __experimentalVStack as VStack,
- __experimentalHStack as HStack,
- FlexItem,
-} from '@wordpress/components';
-import { store as coreStore } from '@wordpress/core-data';
-import { useSelect } from '@wordpress/data';
-import { privateApis as blockEditorPrivateApis } from '@wordpress/block-editor';
-import { privateApis as editorPrivateApis } from '@wordpress/editor';
-import { useMemo, useContext } from '@wordpress/element';
-import { Icon, chevronLeft, chevronRight } from '@wordpress/icons';
-
-/**
- * Internal dependencies
- */
-import FontLibraryProvider from './font-library-modal/context';
-import { getFontFamilies } from './utils';
-import { NavigationButtonAsItem } from './navigation-button';
-import Subtitle from './subtitle';
-import { unlock } from '../../lock-unlock';
-import {
- filterObjectByProperties,
- useCurrentMergeThemeStyleVariationsWithUserConfig,
-} from '../../hooks/use-theme-style-variations/use-theme-style-variations-by-property';
-
-const { GlobalStylesContext } = unlock( blockEditorPrivateApis );
-const { mergeBaseAndUserConfigs } = unlock( editorPrivateApis );
-
-function TypesetButton() {
- const propertiesToFilter = [ 'typography' ];
- const typographyVariations =
- useCurrentMergeThemeStyleVariationsWithUserConfig( propertiesToFilter );
- const hasTypographyVariations = typographyVariations?.length > 1;
- const { base, user: userConfig } = useContext( GlobalStylesContext );
- const config = mergeBaseAndUserConfigs( base, userConfig );
- const allFontFamilies = getFontFamilies( config );
- const hasFonts =
- allFontFamilies.filter( ( font ) => font !== null ).length > 0;
- const variations = useSelect( ( select ) => {
- return select(
- coreStore
- ).__experimentalGetCurrentThemeGlobalStylesVariations();
- }, [] );
- const userTypographyConfig = filterObjectByProperties(
- userConfig,
- 'typography'
- );
-
- const title = useMemo( () => {
- if ( Object.keys( userTypographyConfig ).length === 0 ) {
- return __( 'Default' );
- }
- const activeVariation = variations?.find( ( variation ) => {
- return (
- JSON.stringify(
- filterObjectByProperties( variation, 'typography' )
- ) === JSON.stringify( userTypographyConfig )
- );
- } );
- if ( activeVariation ) {
- return activeVariation.title;
- }
- return allFontFamilies.map( ( font ) => font?.name ).join( ', ' );
- }, [ allFontFamilies, userTypographyConfig, variations ] );
-
- return (
- hasTypographyVariations &&
- hasFonts && (
-
-
- { __( 'Typeset' ) }
-
-
-
-
- { title }
-
-
-
-
-
- )
- );
-}
-
-export default ( { ...props } ) => (
-
-
-
-);
diff --git a/packages/edit-site/src/components/global-styles/ui.js b/packages/edit-site/src/components/global-styles/ui.js
index 60d7e314d7776a..b1550d2a245131 100644
--- a/packages/edit-site/src/components/global-styles/ui.js
+++ b/packages/edit-site/src/components/global-styles/ui.js
@@ -32,7 +32,6 @@ import {
} from './screen-block-list';
import ScreenBlock from './screen-block';
import ScreenTypography from './screen-typography';
-import ScreenTypeset from './screen-typeset';
import ScreenTypographyElement from './screen-typography-element';
import FontSize from './font-sizes/font-size';
import FontSizes from './font-sizes/font-sizes';
@@ -272,19 +271,6 @@ function GlobalStylesEditorCanvasContainerLink() {
goTo( '/' );
}
break;
- default:
- /*
- * Example: the user has navigated to "Browse styles" or elsewhere
- * and changes the editorCanvasContainerView, e.g., closes the style book.
- * The panel should not be affected.
- * Exclude revisions panel from this behavior,
- * as it should close when the editorCanvasContainerView doesn't correspond.
- */
- if ( path !== '/' && ! isRevisionsOpen ) {
- return;
- }
- goTo( '/' );
- break;
}
}, [ editorCanvasContainerView, isRevisionsOpen, goTo ] );
}
@@ -325,10 +311,6 @@ function GlobalStylesUI() {
-
-
-
-
diff --git a/packages/edit-site/src/components/global-styles/variations/variation.js b/packages/edit-site/src/components/global-styles/variations/variation.js
index 48cbe0f19e7ab0..aa936314652885 100644
--- a/packages/edit-site/src/components/global-styles/variations/variation.js
+++ b/packages/edit-site/src/components/global-styles/variations/variation.js
@@ -9,7 +9,7 @@ import clsx from 'clsx';
import { Tooltip } from '@wordpress/components';
import { useMemo, useContext, useState } from '@wordpress/element';
import { ENTER } from '@wordpress/keycodes';
-import { __, sprintf } from '@wordpress/i18n';
+import { _x, sprintf } from '@wordpress/i18n';
import { privateApis as blockEditorPrivateApis } from '@wordpress/block-editor';
import { privateApis as editorPrivateApis } from '@wordpress/editor';
@@ -64,8 +64,8 @@ export default function Variation( {
let label = variation?.title;
if ( variation?.description ) {
label = sprintf(
- /* translators: %1$s: variation title. %2$s variation description. */
- __( '%1$s (%2$s)' ),
+ /* translators: 1: variation title. 2: variation description. */
+ _x( '%1$s (%2$s)', 'variation label' ),
variation?.title,
variation?.description
);
diff --git a/packages/edit-site/src/components/layout/index.js b/packages/edit-site/src/components/layout/index.js
index c54b7efa382c66..2619f7c96dcf74 100644
--- a/packages/edit-site/src/components/layout/index.js
+++ b/packages/edit-site/src/components/layout/index.js
@@ -20,7 +20,6 @@ import {
} from '@wordpress/compose';
import { __ } from '@wordpress/i18n';
import { useState, useRef, useEffect } from '@wordpress/element';
-import { store as keyboardShortcutsStore } from '@wordpress/keyboard-shortcuts';
import { CommandMenu } from '@wordpress/commands';
import { privateApis as blockEditorPrivateApis } from '@wordpress/block-editor';
import {
@@ -57,28 +56,13 @@ export default function Layout( { route } ) {
useCommands();
const isMobileViewport = useViewportMatch( 'medium', '<' );
const toggleRef = useRef();
- const { canvasMode, previousShortcut, nextShortcut } = useSelect(
- ( select ) => {
- const { getAllShortcutKeyCombinations } = select(
- keyboardShortcutsStore
- );
- const { getCanvasMode } = unlock( select( editSiteStore ) );
- return {
- canvasMode: getCanvasMode(),
- previousShortcut: getAllShortcutKeyCombinations(
- 'core/editor/previous-region'
- ),
- nextShortcut: getAllShortcutKeyCombinations(
- 'core/editor/next-region'
- ),
- };
- },
- []
- );
- const navigateRegionsProps = useNavigateRegions( {
- previous: previousShortcut,
- next: nextShortcut,
- } );
+ const { canvasMode } = useSelect( ( select ) => {
+ const { getCanvasMode } = unlock( select( editSiteStore ) );
+ return {
+ canvasMode: getCanvasMode(),
+ };
+ }, [] );
+ const navigateRegionsProps = useNavigateRegions();
const disableMotion = useReducedMotion();
const [ canvasResizer, canvasSize ] = useResizeObserver();
const isEditorLoading = useIsSiteEditorLoading();
diff --git a/packages/edit-site/src/components/layout/style.scss b/packages/edit-site/src/components/layout/style.scss
index b2d929a7943dbf..9e738ecbcdb498 100644
--- a/packages/edit-site/src/components/layout/style.scss
+++ b/packages/edit-site/src/components/layout/style.scss
@@ -206,8 +206,8 @@ html.canvas-mode-edit-transition::view-transition-group(toggle) {
.edit-site-layout__view-mode-toggle-icon {
display: flex;
- height: $grid-unit-80;
- width: $grid-unit-80;
+ height: $header-height;
+ width: $header-height;
justify-content: center;
align-items: center;
}
diff --git a/packages/edit-site/src/components/page-patterns/delete-category-menu-item.js b/packages/edit-site/src/components/page-patterns/delete-category-menu-item.js
index d366ed6fb3a0e5..d87737c55326c6 100644
--- a/packages/edit-site/src/components/page-patterns/delete-category-menu-item.js
+++ b/packages/edit-site/src/components/page-patterns/delete-category-menu-item.js
@@ -9,7 +9,7 @@ import { store as coreStore } from '@wordpress/core-data';
import { useDispatch } from '@wordpress/data';
import { useState } from '@wordpress/element';
import { decodeEntities } from '@wordpress/html-entities';
-import { __, sprintf } from '@wordpress/i18n';
+import { __, _x, sprintf } from '@wordpress/i18n';
import { store as noticesStore } from '@wordpress/notices';
import { privateApis as routerPrivateApis } from '@wordpress/router';
@@ -51,8 +51,8 @@ export default function DeleteCategoryMenuItem( { category, onClose } ) {
createSuccessNotice(
sprintf(
- /* translators: The pattern category's name */
- __( '"%s" deleted.' ),
+ /* translators: %s: The pattern category's name */
+ _x( '"%s" deleted.', 'pattern category' ),
category.label
),
{ type: 'snackbar', id: 'pattern-category-delete' }
@@ -91,7 +91,7 @@ export default function DeleteCategoryMenuItem( { category, onClose } ) {
className="edit-site-patterns__delete-modal"
title={ sprintf(
// translators: %s: The pattern category's name.
- __( 'Delete "%s"?' ),
+ _x( 'Delete "%s"?', 'pattern category' ),
decodeEntities( category.label )
) }
size="medium"
diff --git a/packages/edit-site/src/components/page-patterns/fields.js b/packages/edit-site/src/components/page-patterns/fields.js
index ff9c0dbe81a047..88de0c1fa39b01 100644
--- a/packages/edit-site/src/components/page-patterns/fields.js
+++ b/packages/edit-site/src/components/page-patterns/fields.js
@@ -133,8 +133,7 @@ function TitleField( { item } ) {
title
) : (
field === 'sync-status'
)?.value;
@@ -121,10 +122,10 @@ export default function DataviewsPatterns() {
// Reset the page number when the category changes.
useEffect( () => {
- if ( previousCategoryId !== categoryId ) {
+ if ( previousCategoryId !== categoryId || previousPostType !== type ) {
setView( ( prevView ) => ( { ...prevView, page: 1 } ) );
}
- }, [ categoryId, previousCategoryId ] );
+ }, [ categoryId, previousCategoryId, previousPostType, type ] );
const { data, paginationInfo } = useMemo( () => {
// Search is managed server-side as well as filters for patterns.
// However, the author filter in template parts is done client-side.
diff --git a/packages/edit-site/src/components/pagination/index.js b/packages/edit-site/src/components/pagination/index.js
index 8ef45883eb4524..5d6ce852d5a4c4 100644
--- a/packages/edit-site/src/components/pagination/index.js
+++ b/packages/edit-site/src/components/pagination/index.js
@@ -65,7 +65,7 @@ export default function Pagination( {
{ sprintf(
- // translators: %1$s: Current page number, %2$s: Total number of pages.
+ // translators: 1: Current page number. 2: Total number of pages.
_x( '%1$s of %2$s', 'paging' ),
currentPage,
numPages
diff --git a/packages/edit-site/src/components/post-fields/index.js b/packages/edit-site/src/components/post-fields/index.js
index 9e59b23d61922d..030e6919dd920d 100644
--- a/packages/edit-site/src/components/post-fields/index.js
+++ b/packages/edit-site/src/components/post-fields/index.js
@@ -303,7 +303,7 @@ function usePostFields( viewType ) {
if ( isDraftOrPrivate ) {
return createInterpolateElement(
sprintf(
- /* translators: %s: page creation date */
+ /* translators: %s: page creation or modification date. */
__( 'Modified: ' ),
getFormattedDate( item.date )
),
@@ -354,7 +354,7 @@ function usePostFields( viewType ) {
if ( isPending ) {
return createInterpolateElement(
sprintf(
- /* translators: %s: the newest of created or modified date for the page */
+ /* translators: %s: page creation or modification date. */
__( 'Modified: ' ),
getFormattedDate( dateToDisplay )
),
diff --git a/packages/edit-site/src/components/save-panel/index.js b/packages/edit-site/src/components/save-panel/index.js
index babcf72181a1af..6d228cc7f84b96 100644
--- a/packages/edit-site/src/components/save-panel/index.js
+++ b/packages/edit-site/src/components/save-panel/index.js
@@ -47,7 +47,7 @@ const EntitiesSavedStatesForPreview = ( { onClose } ) => {
const additionalPrompt = (
{ sprintf(
- /* translators: %1$s: The name of active theme, %2$s: The name of theme to be activated. */
+ /* translators: 1: The name of active theme, 2: The name of theme to be activated. */
__(
'Saving your changes will change your active theme from %1$s to %2$s.'
),
@@ -150,8 +150,7 @@ export default function SavePanel() {
} ) }
>
setIsSaveViewOpened( true ) }
diff --git a/packages/edit-site/src/components/sidebar-button/index.js b/packages/edit-site/src/components/sidebar-button/index.js
index d7030597dac503..f4cea37f01078b 100644
--- a/packages/edit-site/src/components/sidebar-button/index.js
+++ b/packages/edit-site/src/components/sidebar-button/index.js
@@ -11,8 +11,7 @@ import { Button } from '@wordpress/components';
export default function SidebarButton( props ) {
return (
diff --git a/packages/edit-site/src/components/sidebar-button/style.scss b/packages/edit-site/src/components/sidebar-button/style.scss
index 6daa560a114ad0..4507accbd12123 100644
--- a/packages/edit-site/src/components/sidebar-button/style.scss
+++ b/packages/edit-site/src/components/sidebar-button/style.scss
@@ -14,10 +14,10 @@
outline: 3px solid transparent;
}
- &:hover,
+ &:hover:not(:disabled,[aria-disabled="true"]),
&:focus-visible,
&:focus,
- &:not([aria-disabled="true"]):active,
+ &:not(:disabled,[aria-disabled="true"]):active,
&[aria-expanded="true"] {
color: $gray-100;
}
diff --git a/packages/edit-site/src/components/sidebar-dataviews/add-new-view.js b/packages/edit-site/src/components/sidebar-dataviews/add-new-view.js
index 3e369db9b2a821..62956ccd18960d 100644
--- a/packages/edit-site/src/components/sidebar-dataviews/add-new-view.js
+++ b/packages/edit-site/src/components/sidebar-dataviews/add-new-view.js
@@ -88,8 +88,7 @@ function AddNewItemModalContent( { type, setIsAdding } ) {
/>
{
setIsAdding( false );
@@ -99,8 +98,7 @@ function AddNewItemModalContent( { type, setIsAdding } ) {
{ title }
diff --git a/packages/edit-site/src/components/sidebar-dataviews/default-views.js b/packages/edit-site/src/components/sidebar-dataviews/default-views.js
index 20f61e451b21fa..658fa319e9c667 100644
--- a/packages/edit-site/src/components/sidebar-dataviews/default-views.js
+++ b/packages/edit-site/src/components/sidebar-dataviews/default-views.js
@@ -13,7 +13,7 @@ import {
notAllowed,
} from '@wordpress/icons';
import { useSelect } from '@wordpress/data';
-import { store as coreStore, useEntityRecords } from '@wordpress/core-data';
+import { store as coreStore } from '@wordpress/core-data';
import { useMemo } from '@wordpress/element';
/**
@@ -68,50 +68,6 @@ const DEFAULT_POST_BASE = {
layout: defaultLayouts[ LAYOUT_LIST ].layout,
};
-export function useDefaultViewsWithItemCounts( { postType } ) {
- const defaultViews = useDefaultViews( { postType } );
- const { records, totalItems } = useEntityRecords( 'postType', postType, {
- per_page: -1,
- status: [ 'any', 'trash' ],
- } );
-
- return useMemo( () => {
- if ( ! defaultViews ) {
- return [];
- }
-
- // If there are no records, return the default views with no counts.
- if ( ! records ) {
- return defaultViews;
- }
-
- const counts = {
- drafts: records.filter( ( record ) => record.status === 'draft' )
- .length,
- future: records.filter( ( record ) => record.status === 'future' )
- .length,
- pending: records.filter( ( record ) => record.status === 'pending' )
- .length,
- private: records.filter( ( record ) => record.status === 'private' )
- .length,
- published: records.filter(
- ( record ) => record.status === 'publish'
- ).length,
- trash: records.filter( ( record ) => record.status === 'trash' )
- .length,
- };
-
- // All items excluding trashed items as per the default "all" status query.
- counts.all = totalItems ? totalItems - counts.trash : 0;
-
- // Filter out views with > 0 item counts.
- return defaultViews.map( ( _view ) => {
- _view.count = counts[ _view.slug ];
- return _view;
- } );
- }, [ defaultViews, records, totalItems ] );
-}
-
export function useDefaultViews( { postType } ) {
const labels = useSelect(
( select ) => {
diff --git a/packages/edit-site/src/components/sidebar-dataviews/index.js b/packages/edit-site/src/components/sidebar-dataviews/index.js
index 3f7f5b965fce71..86420c4eec1d1f 100644
--- a/packages/edit-site/src/components/sidebar-dataviews/index.js
+++ b/packages/edit-site/src/components/sidebar-dataviews/index.js
@@ -7,7 +7,7 @@ import { privateApis as routerPrivateApis } from '@wordpress/router';
/**
* Internal dependencies
*/
-import { useDefaultViewsWithItemCounts } from './default-views';
+import { useDefaultViews } from './default-views';
import { unlock } from '../../lock-unlock';
import DataViewItem from './dataview-item';
import CustomDataViewsList from './custom-dataviews-list';
@@ -18,9 +18,7 @@ export default function DataViewsSidebarContent() {
const {
params: { postType, activeView = 'all', isCustom = 'false' },
} = useLocation();
-
- const defaultViews = useDefaultViewsWithItemCounts( { postType } );
-
+ const defaultViews = useDefaultViews( { postType } );
if ( ! postType ) {
return null;
}
@@ -36,9 +34,6 @@ export default function DataViewsSidebarContent() {
slug={ dataview.slug }
title={ dataview.title }
icon={ dataview.icon }
- navigationItemSuffix={
- { dataview.count }
- }
type={ dataview.view.type }
isActive={
! isCustomBoolean &&
diff --git a/packages/edit-site/src/components/sidebar-dataviews/style.scss b/packages/edit-site/src/components/sidebar-dataviews/style.scss
index 3473c8e20e1a45..14e6bf1d03fca8 100644
--- a/packages/edit-site/src/components/sidebar-dataviews/style.scss
+++ b/packages/edit-site/src/components/sidebar-dataviews/style.scss
@@ -15,10 +15,6 @@
min-width: initial;
}
- .edit-site-sidebar-navigation-item.with-suffix {
- padding-right: $grid-unit-10;
- }
-
&:hover,
&:focus,
&[aria-current] {
diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-navigation-menu/use-navigation-menu-handlers.js b/packages/edit-site/src/components/sidebar-navigation-screen-navigation-menu/use-navigation-menu-handlers.js
index 7009ddc6fc9278..4a7e1deddc6d93 100644
--- a/packages/edit-site/src/components/sidebar-navigation-screen-navigation-menu/use-navigation-menu-handlers.js
+++ b/packages/edit-site/src/components/sidebar-navigation-screen-navigation-menu/use-navigation-menu-handlers.js
@@ -2,7 +2,7 @@
* WordPress dependencies
*/
import { store as coreStore } from '@wordpress/core-data';
-import { __, sprintf } from '@wordpress/i18n';
+import { __, _x, sprintf } from '@wordpress/i18n';
import { useSelect, useDispatch } from '@wordpress/data';
import { store as noticesStore } from '@wordpress/notices';
import { privateApis as routerPrivateApis } from '@wordpress/router';
@@ -150,7 +150,7 @@ function useDuplicateNavigationMenu() {
{
title: sprintf(
/* translators: %s: Navigation menu title */
- __( '%s (Copy)' ),
+ _x( '%s (Copy)', 'navigation menu' ),
menuTitle
),
content: navigationMenu?.content?.raw,
diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-navigation-menus/build-navigation-label.js b/packages/edit-site/src/components/sidebar-navigation-screen-navigation-menus/build-navigation-label.js
index d5e5ff02c63bfd..3e995eb8cdccfb 100644
--- a/packages/edit-site/src/components/sidebar-navigation-screen-navigation-menus/build-navigation-label.js
+++ b/packages/edit-site/src/components/sidebar-navigation-screen-navigation-menus/build-navigation-label.js
@@ -1,13 +1,13 @@
/**
* WordPress dependencies
*/
-import { __, sprintf } from '@wordpress/i18n';
+import { __, _x, sprintf } from '@wordpress/i18n';
import { decodeEntities } from '@wordpress/html-entities';
// Copied from packages/block-library/src/navigation/edit/navigation-menu-selector.js.
export default function buildNavigationLabel( title, id, status ) {
if ( ! title?.rendered ) {
- /* translators: %s is the index of the menu in the list of menus. */
+ /* translators: %s: the index of the menu in the list of menus. */
return sprintf( __( '(no title %s)' ), id );
}
@@ -16,8 +16,8 @@ export default function buildNavigationLabel( title, id, status ) {
}
return sprintf(
- // translators: %1s: title of the menu; %2s: status of the menu (draft, pending, etc.).
- __( '%1$s (%2$s)' ),
+ // translators: 1: title of the menu. 2: status of the menu (draft, pending, etc.).
+ _x( '%1$s (%2$s)', 'menu label' ),
decodeEntities( title?.rendered ),
status
);
diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-navigation-menus/index.js b/packages/edit-site/src/components/sidebar-navigation-screen-navigation-menus/index.js
index bc32b0a9061c1e..18e6a4210ee1b2 100644
--- a/packages/edit-site/src/components/sidebar-navigation-screen-navigation-menus/index.js
+++ b/packages/edit-site/src/components/sidebar-navigation-screen-navigation-menus/index.js
@@ -1,7 +1,7 @@
/**
* WordPress dependencies
*/
-import { __, sprintf } from '@wordpress/i18n';
+import { __, _x, sprintf } from '@wordpress/i18n';
import { useEntityRecords, store as coreStore } from '@wordpress/core-data';
import { useSelect } from '@wordpress/data';
@@ -27,7 +27,7 @@ import { NAVIGATION_POST_TYPE } from '../../utils/constants';
// Copied from packages/block-library/src/navigation/edit/navigation-menu-selector.js.
function buildMenuLabel( title, id, status ) {
if ( ! title ) {
- /* translators: %s is the index of the menu in the list of menus. */
+ /* translators: %s: the index of the menu in the list of menus. */
return sprintf( __( '(no title %s)' ), id );
}
@@ -36,8 +36,8 @@ function buildMenuLabel( title, id, status ) {
}
return sprintf(
- // translators: %1s: title of the menu; %2s: status of the menu (draft, pending, etc.).
- __( '%1$s (%2$s)' ),
+ // translators: 1: title of the menu. 2: status of the menu (draft, pending, etc.).
+ _x( '%1$s (%2$s)', 'menu label' ),
decodeEntities( title ),
status
);
diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-navigation-menus/style.scss b/packages/edit-site/src/components/sidebar-navigation-screen-navigation-menus/style.scss
index 3667db2d9331d2..334e90e93c42ce 100644
--- a/packages/edit-site/src/components/sidebar-navigation-screen-navigation-menus/style.scss
+++ b/packages/edit-site/src/components/sidebar-navigation-screen-navigation-menus/style.scss
@@ -19,7 +19,7 @@
padding-right: 0;
}
- .components-button {
+ .block-editor-list-view-block-select-button {
color: $gray-600;
&:hover,
&:focus,
diff --git a/packages/edit-site/src/components/sidebar-navigation-screen/index.js b/packages/edit-site/src/components/sidebar-navigation-screen/index.js
index 417e643bb8b04d..0080964310525b 100644
--- a/packages/edit-site/src/components/sidebar-navigation-screen/index.js
+++ b/packages/edit-site/src/components/sidebar-navigation-screen/index.js
@@ -109,7 +109,8 @@ export default function SidebarNavigationScreen( {
{ ! isPreviewingTheme()
? title
: sprintf(
- 'Previewing %1$s: %2$s',
+ /* translators: 1: theme name. 2: title */
+ __( 'Previewing %1$s: %2$s' ),
previewingThemeName,
title
) }
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 f0260581a8988f..ada2106f68cb7c 100644
--- a/packages/edit-site/src/components/sidebar-navigation-screen/style.scss
+++ b/packages/edit-site/src/components/sidebar-navigation-screen/style.scss
@@ -68,7 +68,11 @@
.edit-site-sidebar-navigation-screen__title {
flex-grow: 1;
overflow-wrap: break-word;
- padding: $grid-unit-05 * 0.5 0 0 0;
+
+ {&},
+ {&} .edit-site-sidebar-navigation-screen__title {
+ line-height: 32px;
+ }
}
.edit-site-sidebar-navigation-screen__actions {
diff --git a/packages/edit-site/src/components/site-hub/index.js b/packages/edit-site/src/components/site-hub/index.js
index 7fe929d20a1db2..3f34b8bd1cccb6 100644
--- a/packages/edit-site/src/components/site-hub/index.js
+++ b/packages/edit-site/src/components/site-hub/index.js
@@ -62,14 +62,13 @@ const SiteHub = memo(
) }
>
@@ -80,8 +79,7 @@ const SiteHub = memo(
openCommandCenter() }
@@ -149,8 +146,7 @@ export const SiteHubMobile = memo(
) }
>
openCommandCenter() }
diff --git a/packages/edit-site/src/components/site-hub/style.scss b/packages/edit-site/src/components/site-hub/style.scss
index 7fae845d4d2030..b1c1a3a41cc532 100644
--- a/packages/edit-site/src/components/site-hub/style.scss
+++ b/packages/edit-site/src/components/site-hub/style.scss
@@ -4,6 +4,7 @@
justify-content: space-between;
gap: $grid-unit-10;
margin-right: $grid-unit-15;
+ height: $grid-unit-70;
}
.edit-site-site-hub__actions {
@@ -29,6 +30,9 @@
overflow: hidden;
// Add space for the ↗ to render.
padding-right: $grid-unit-20;
+
+ // Create 12px gap between site icon and site title
+ margin-left: - $grid-unit-05;
position: relative;
text-decoration: none;
text-overflow: ellipsis;
diff --git a/packages/edit-site/src/components/sync-state-with-url/use-init-edited-entity-from-url.js b/packages/edit-site/src/components/sync-state-with-url/use-init-edited-entity-from-url.js
index ab6ea92bac4413..9f507693c4cfdc 100644
--- a/packages/edit-site/src/components/sync-state-with-url/use-init-edited-entity-from-url.js
+++ b/packages/edit-site/src/components/sync-state-with-url/use-init-edited-entity-from-url.js
@@ -5,6 +5,7 @@ import { useEffect, useMemo } from '@wordpress/element';
import { useSelect, useDispatch } from '@wordpress/data';
import { store as coreDataStore } from '@wordpress/core-data';
import { privateApis as routerPrivateApis } from '@wordpress/router';
+import { store as blockEditorStore } from '@wordpress/block-editor';
/**
* Internal dependencies
@@ -248,9 +249,14 @@ export default function useInitEditedEntityFromURL() {
useResolveEditedEntityAndContext( params );
const { setEditedEntity } = useDispatch( editSiteStore );
+ const { __unstableSetEditorMode, resetZoomLevel } = unlock(
+ useDispatch( blockEditorStore )
+ );
useEffect( () => {
if ( isReady ) {
+ __unstableSetEditorMode( 'edit' );
+ resetZoomLevel();
setEditedEntity( postType, postId, context );
}
}, [ isReady, postType, postId, context, setEditedEntity ] );
diff --git a/packages/edit-site/src/index.js b/packages/edit-site/src/index.js
index 1aceecc4d8b1fc..83d25bcd0c03ac 100644
--- a/packages/edit-site/src/index.js
+++ b/packages/edit-site/src/index.js
@@ -28,10 +28,7 @@ import { store as editSiteStore } from './store';
import { unlock } from './lock-unlock';
import App from './components/app';
-const {
- registerCoreBlockBindingsSources,
- bootstrapBlockBindingsSourcesFromServer,
-} = unlock( editorPrivateApis );
+const { registerCoreBlockBindingsSources } = unlock( editorPrivateApis );
/**
* Initializes the site editor screen.
@@ -48,7 +45,6 @@ export function initializeEditor( id, settings ) {
( { name } ) => name !== 'core/freeform'
);
registerCoreBlocks( coreBlocks );
- bootstrapBlockBindingsSourcesFromServer( settings?.blockBindingsSources );
registerCoreBlockBindingsSources();
dispatch( blocksStore ).setFreeformFallbackBlockName( 'core/html' );
registerLegacyWidgetBlock( { inserter: false } );
diff --git a/packages/edit-widgets/CHANGELOG.md b/packages/edit-widgets/CHANGELOG.md
index f1cbcef0667d7c..6115fc42560f82 100644
--- a/packages/edit-widgets/CHANGELOG.md
+++ b/packages/edit-widgets/CHANGELOG.md
@@ -2,6 +2,8 @@
## Unreleased
+## 6.8.0 (2024-09-19)
+
## 6.7.0 (2024-09-05)
## 6.6.0 (2024-08-21)
diff --git a/packages/edit-widgets/package.json b/packages/edit-widgets/package.json
index d24857f0f8a0ce..f7526640f134e8 100644
--- a/packages/edit-widgets/package.json
+++ b/packages/edit-widgets/package.json
@@ -1,6 +1,6 @@
{
"name": "@wordpress/edit-widgets",
- "version": "6.7.0",
+ "version": "6.8.15",
"description": "Widgets Page module for WordPress..",
"author": "The WordPress Contributors",
"license": "GPL-2.0-or-later",
diff --git a/packages/edit-widgets/src/components/layout/index.js b/packages/edit-widgets/src/components/layout/index.js
index 54338d10fb8503..4fc70a6c0c74eb 100644
--- a/packages/edit-widgets/src/components/layout/index.js
+++ b/packages/edit-widgets/src/components/layout/index.js
@@ -5,6 +5,7 @@ import { __, sprintf } from '@wordpress/i18n';
import { useDispatch } from '@wordpress/data';
import { PluginArea } from '@wordpress/plugins';
import { store as noticesStore } from '@wordpress/notices';
+import { __unstableUseNavigateRegions as useNavigateRegions } from '@wordpress/components';
/**
* Internal dependencies
@@ -31,17 +32,25 @@ function Layout( { blockEditorSettings } ) {
);
}
+ const navigateRegionsProps = useNavigateRegions();
+
return (
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
);
}
diff --git a/packages/edit-widgets/src/components/layout/interface.js b/packages/edit-widgets/src/components/layout/interface.js
index ee46251eca2245..bec40a6e830699 100644
--- a/packages/edit-widgets/src/components/layout/interface.js
+++ b/packages/edit-widgets/src/components/layout/interface.js
@@ -11,7 +11,6 @@ import {
store as interfaceStore,
} from '@wordpress/interface';
import { __ } from '@wordpress/i18n';
-import { store as keyboardShortcutsStore } from '@wordpress/keyboard-shortcuts';
import { store as preferencesStore } from '@wordpress/preferences';
/**
@@ -43,8 +42,6 @@ function Interface( { blockEditorSettings } ) {
hasSidebarEnabled,
isInserterOpened,
isListViewOpened,
- previousShortcut,
- nextShortcut,
} = useSelect(
( select ) => ( {
hasSidebarEnabled: !! select(
@@ -56,14 +53,6 @@ function Interface( { blockEditorSettings } ) {
'core/edit-widgets',
'showBlockBreadcrumbs'
),
- previousShortcut: select(
- keyboardShortcutsStore
- ).getAllShortcutKeyCombinations(
- 'core/edit-widgets/previous-region'
- ),
- nextShortcut: select(
- keyboardShortcutsStore
- ).getAllShortcutKeyCombinations( 'core/edit-widgets/next-region' ),
} ),
[]
);
@@ -112,10 +101,6 @@ function Interface( { blockEditorSettings } ) {
)
}
- shortcuts={ {
- previous: previousShortcut,
- next: nextShortcut,
- } }
/>
);
}
diff --git a/packages/editor/CHANGELOG.md b/packages/editor/CHANGELOG.md
index b380c2fd296d55..6f31c1735021f6 100644
--- a/packages/editor/CHANGELOG.md
+++ b/packages/editor/CHANGELOG.md
@@ -2,6 +2,8 @@
## Unreleased
+## 14.8.0 (2024-09-19)
+
## 14.7.0 (2024-09-05)
## 14.6.0 (2024-08-21)
diff --git a/packages/editor/package.json b/packages/editor/package.json
index 2e01aff3a0aa5f..18d698aa8c7687 100644
--- a/packages/editor/package.json
+++ b/packages/editor/package.json
@@ -1,6 +1,6 @@
{
"name": "@wordpress/editor",
- "version": "14.7.0",
+ "version": "14.8.17",
"description": "Enhanced block editor for WordPress posts.",
"author": "The WordPress Contributors",
"license": "GPL-2.0-or-later",
diff --git a/packages/editor/src/bindings/api.js b/packages/editor/src/bindings/api.js
index 2cfed5168a143e..2d32d76abbc3bc 100644
--- a/packages/editor/src/bindings/api.js
+++ b/packages/editor/src/bindings/api.js
@@ -1,18 +1,13 @@
/**
* WordPress dependencies
*/
-import {
- privateApis as blocksPrivateApis,
- store as blocksStore,
-} from '@wordpress/blocks';
-import { dispatch } from '@wordpress/data';
+import { registerBlockBindingsSource } from '@wordpress/blocks';
/**
* Internal dependencies
*/
import patternOverrides from './pattern-overrides';
import postMeta from './post-meta';
-import { unlock } from '../lock-unlock';
/**
* Function to register core block bindings sources provided by the editor.
@@ -25,33 +20,6 @@ import { unlock } from '../lock-unlock';
* ```
*/
export function registerCoreBlockBindingsSources() {
- const { registerBlockBindingsSource } = unlock( blocksPrivateApis );
registerBlockBindingsSource( patternOverrides );
registerBlockBindingsSource( postMeta );
}
-
-/**
- * Function to bootstrap core block bindings sources defined in the server.
- *
- * @param {Object} sources Object containing the sources to bootstrap.
- *
- * @example
- * ```js
- * import { bootstrapBlockBindingsSourcesFromServer } from '@wordpress/editor';
- *
- * bootstrapBlockBindingsSourcesFromServer( sources );
- * ```
- */
-export function bootstrapBlockBindingsSourcesFromServer( sources ) {
- if ( sources ) {
- const { addBootstrappedBlockBindingsSource } = unlock(
- dispatch( blocksStore )
- );
- for ( const [ name, args ] of Object.entries( sources ) ) {
- addBootstrappedBlockBindingsSource( {
- name,
- ...args,
- } );
- }
- }
-}
diff --git a/packages/editor/src/bindings/pattern-overrides.js b/packages/editor/src/bindings/pattern-overrides.js
index 88c6c73bdc61c1..baa1f72f47694b 100644
--- a/packages/editor/src/bindings/pattern-overrides.js
+++ b/packages/editor/src/bindings/pattern-overrides.js
@@ -7,9 +7,9 @@ const CONTENT = 'content';
export default {
name: 'core/pattern-overrides',
- getValues( { registry, clientId, context, bindings } ) {
+ getValues( { select, clientId, context, bindings } ) {
const patternOverridesContent = context[ 'pattern/overrides' ];
- const { getBlockAttributes } = registry.select( blockEditorStore );
+ const { getBlockAttributes } = select( blockEditorStore );
const currentBlockAttributes = getBlockAttributes( clientId );
const overridesValues = {};
@@ -32,9 +32,9 @@ export default {
}
return overridesValues;
},
- setValues( { registry, clientId, bindings } ) {
+ setValues( { select, dispatch, clientId, bindings } ) {
const { getBlockAttributes, getBlockParentsByBlockName, getBlocks } =
- registry.select( blockEditorStore );
+ select( blockEditorStore );
const currentBlockAttributes = getBlockAttributes( clientId );
const blockName = currentBlockAttributes?.metadata?.name;
if ( ! blockName ) {
@@ -61,12 +61,10 @@ export default {
const syncBlocksWithSameName = ( blocks ) => {
for ( const block of blocks ) {
if ( block.attributes?.metadata?.name === blockName ) {
- registry
- .dispatch( blockEditorStore )
- .updateBlockAttributes(
- block.clientId,
- attributes
- );
+ dispatch( blockEditorStore ).updateBlockAttributes(
+ block.clientId,
+ attributes
+ );
}
syncBlocksWithSameName( block.innerBlocks );
}
@@ -77,27 +75,26 @@ export default {
}
const currentBindingValue =
getBlockAttributes( patternClientId )?.[ CONTENT ];
- registry
- .dispatch( blockEditorStore )
- .updateBlockAttributes( patternClientId, {
- [ CONTENT ]: {
- ...currentBindingValue,
- [ blockName ]: {
- ...currentBindingValue?.[ blockName ],
- ...Object.entries( attributes ).reduce(
- ( acc, [ key, value ] ) => {
- // TODO: We need a way to represent `undefined` in the serialized overrides.
- // Also see: https://github.com/WordPress/gutenberg/pull/57249#discussion_r1452987871
- // We use an empty string to represent undefined for now until
- // we support a richer format for overrides and the block bindings API.
- acc[ key ] = value === undefined ? '' : value;
- return acc;
- },
- {}
- ),
- },
+
+ dispatch( blockEditorStore ).updateBlockAttributes( patternClientId, {
+ [ CONTENT ]: {
+ ...currentBindingValue,
+ [ blockName ]: {
+ ...currentBindingValue?.[ blockName ],
+ ...Object.entries( attributes ).reduce(
+ ( acc, [ key, value ] ) => {
+ // TODO: We need a way to represent `undefined` in the serialized overrides.
+ // Also see: https://github.com/WordPress/gutenberg/pull/57249#discussion_r1452987871
+ // We use an empty string to represent undefined for now until
+ // we support a richer format for overrides and the block bindings API.
+ acc[ key ] = value === undefined ? '' : value;
+ return acc;
+ },
+ {}
+ ),
},
- } );
+ },
+ } );
},
canUserEditValue: () => true,
};
diff --git a/packages/editor/src/bindings/post-meta.js b/packages/editor/src/bindings/post-meta.js
index 572cd0b525a003..a3602ce7d62076 100644
--- a/packages/editor/src/bindings/post-meta.js
+++ b/packages/editor/src/bindings/post-meta.js
@@ -9,26 +9,64 @@ import { store as coreDataStore } from '@wordpress/core-data';
import { store as editorStore } from '../store';
import { unlock } from '../lock-unlock';
-function getMetadata( registry, context, registeredFields ) {
- let metaFields = {};
- const type = registry.select( editorStore ).getCurrentPostType();
- const { getEditedEntityRecord } = registry.select( coreDataStore );
+/**
+ * Gets a list of post meta fields with their values and labels
+ * to be consumed in the needed callbacks.
+ * If the value is not available based on context, like in templates,
+ * it falls back to the default value, label, or key.
+ *
+ * @param {Object} select The select function from the data store.
+ * @param {Object} context The context provided.
+ * @return {Object} List of post meta fields with their value and label.
+ *
+ * @example
+ * ```js
+ * {
+ * field_1_key: {
+ * label: 'Field 1 Label',
+ * value: 'Field 1 Value',
+ * },
+ * field_2_key: {
+ * label: 'Field 2 Label',
+ * value: 'Field 2 Value',
+ * },
+ * ...
+ * }
+ * ```
+ */
+function getPostMetaFields( select, context ) {
+ const { getEditedEntityRecord } = select( coreDataStore );
+ const { getRegisteredPostMeta } = unlock( select( coreDataStore ) );
+ let entityMetaValues;
+ // Try to get the current entity meta values.
if ( context?.postType && context?.postId ) {
- metaFields = getEditedEntityRecord(
+ entityMetaValues = getEditedEntityRecord(
'postType',
context?.postType,
context?.postId
).meta;
- } else if ( type === 'wp_template' ) {
- // Populate the `metaFields` object with the default values.
- Object.entries( registeredFields || {} ).forEach(
- ( [ key, props ] ) => {
- if ( props.default ) {
- metaFields[ key ] = props.default;
- }
- }
- );
+ }
+
+ const registeredFields = getRegisteredPostMeta( context?.postType );
+ const metaFields = {};
+ Object.entries( registeredFields || {} ).forEach( ( [ key, props ] ) => {
+ // Don't include footnotes or private fields.
+ if ( key !== 'footnotes' && key.charAt( 0 ) !== '_' ) {
+ metaFields[ key ] = {
+ label: props.title || key,
+ value:
+ // When using the entity value, an empty string IS a valid value.
+ entityMetaValues?.[ key ] ??
+ // When using the default, an empty string IS NOT a valid value.
+ ( props.default || undefined ),
+ type: props.type,
+ };
+ }
+ } );
+
+ if ( ! Object.keys( metaFields || {} ).length ) {
+ return null;
}
return metaFields;
@@ -36,34 +74,33 @@ function getMetadata( registry, context, registeredFields ) {
export default {
name: 'core/post-meta',
- getValues( { registry, context, bindings } ) {
- const { getRegisteredPostMeta } = unlock(
- registry.select( coreDataStore )
- );
- const registeredFields = getRegisteredPostMeta( context?.postType );
- const metaFields = getMetadata( registry, context, registeredFields );
+ getValues( { select, context, bindings } ) {
+ const metaFields = getPostMetaFields( select, context );
const newValues = {};
for ( const [ attributeName, source ] of Object.entries( bindings ) ) {
// Use the value, the field label, or the field key.
- const metaKey = source.args.key;
- newValues[ attributeName ] =
- metaFields?.[ metaKey ] ??
- registeredFields?.[ metaKey ]?.title ??
- metaKey;
+ const fieldKey = source.args.key;
+ const { value: fieldValue, label: fieldLabel } =
+ metaFields?.[ fieldKey ] || {};
+ newValues[ attributeName ] = fieldValue ?? fieldLabel ?? fieldKey;
}
return newValues;
},
- setValues( { registry, context, bindings } ) {
+ setValues( { dispatch, context, bindings } ) {
const newMeta = {};
Object.values( bindings ).forEach( ( { args, newValue } ) => {
newMeta[ args.key ] = newValue;
} );
- registry
- .dispatch( coreDataStore )
- .editEntityRecord( 'postType', context?.postType, context?.postId, {
+
+ dispatch( coreDataStore ).editEntityRecord(
+ 'postType',
+ context?.postType,
+ context?.postId,
+ {
meta: newMeta,
- } );
+ }
+ );
},
canUserEditValue( { select, context, args } ) {
// Lock editing in query loop.
@@ -79,14 +116,9 @@ export default {
return false;
}
- // Check that the custom field is not protected and available in the REST API.
+ const fieldValue = getPostMetaFields( select, context )?.[ args.key ]
+ ?.value;
// Empty string or `false` could be a valid value, so we need to check if the field value is undefined.
- const fieldValue = select( coreDataStore ).getEntityRecord(
- 'postType',
- postType,
- context?.postId
- )?.meta?.[ args.key ];
-
if ( fieldValue === undefined ) {
return false;
}
@@ -109,32 +141,7 @@ export default {
return true;
},
- getFieldsList( { registry, context } ) {
- const { getRegisteredPostMeta } = unlock(
- registry.select( coreDataStore )
- );
- const registeredFields = getRegisteredPostMeta( context?.postType );
- const metaFields = getMetadata( registry, context, registeredFields );
-
- if ( ! metaFields || ! Object.keys( metaFields ).length ) {
- return null;
- }
-
- return Object.fromEntries(
- Object.entries( metaFields )
- // Remove footnotes or private keys from the list of fields.
- .filter(
- ( [ key ] ) =>
- key !== 'footnotes' && key.charAt( 0 ) !== '_'
- )
- // Return object with label and value.
- .map( ( [ key, value ] ) => [
- key,
- {
- label: registeredFields?.[ key ]?.title || key,
- value,
- },
- ] )
- );
+ getFieldsList( { select, context } ) {
+ return getPostMetaFields( select, context );
},
};
diff --git a/packages/editor/src/components/block-settings-menu/content-only-settings-menu.js b/packages/editor/src/components/block-settings-menu/content-only-settings-menu.js
index fcf7adfa77635c..e4b6089bf2d92e 100644
--- a/packages/editor/src/components/block-settings-menu/content-only-settings-menu.js
+++ b/packages/editor/src/components/block-settings-menu/content-only-settings-menu.js
@@ -46,12 +46,17 @@ function ContentOnlySettingsMenuItems( { clientId, onClose } ) {
getBlockAttributes( patternParent ).ref
);
} else {
- const { getCurrentTemplateId } = select( editorStore );
+ const { getCurrentTemplateId, getRenderingMode } =
+ select( editorStore );
const templateId = getCurrentTemplateId();
const { getContentLockingParent } = unlock(
select( blockEditorStore )
);
- if ( ! getContentLockingParent( clientId ) && templateId ) {
+ if (
+ getRenderingMode() === 'template-locked' &&
+ ! getContentLockingParent( clientId ) &&
+ templateId
+ ) {
record = select( coreStore ).getEntityRecord(
'postType',
'wp_template',
diff --git a/packages/editor/src/components/editor-interface/index.js b/packages/editor/src/components/editor-interface/index.js
index 645e5fb6f53a26..9f7f3fb7ad4b0e 100644
--- a/packages/editor/src/components/editor-interface/index.js
+++ b/packages/editor/src/components/editor-interface/index.js
@@ -15,7 +15,6 @@ import {
BlockBreadcrumb,
BlockToolbar,
} from '@wordpress/block-editor';
-import { store as keyboardShortcutsStore } from '@wordpress/keyboard-shortcuts';
import { useViewportMatch } from '@wordpress/compose';
import { useState, useCallback } from '@wordpress/element';
@@ -32,6 +31,8 @@ import TextEditor from '../text-editor';
import VisualEditor from '../visual-editor';
import EditorContentSlotFill from './content-slot-fill';
+import { unlock } from '../../lock-unlock';
+
const interfaceLabels = {
/* translators: accessibility text for the editor top bar landmark region. */
header: __( 'Editor top bar' ),
@@ -47,7 +48,6 @@ const interfaceLabels = {
export default function EditorInterface( {
className,
- enableRegionNavigation,
styles,
children,
forceIsDirty,
@@ -67,16 +67,15 @@ export default function EditorInterface( {
isListViewOpened,
isDistractionFree,
isPreviewMode,
- previousShortcut,
- nextShortcut,
showBlockBreadcrumbs,
documentLabel,
- blockEditorMode,
+ isZoomOut,
} = useSelect( ( select ) => {
const { get } = select( preferencesStore );
const { getEditorSettings, getPostTypeLabel } = select( editorStore );
const editorSettings = getEditorSettings();
const postTypeLabel = getPostTypeLabel();
+ const { isZoomOut: _isZoomOut } = unlock( select( blockEditorStore ) );
return {
mode: select( editorStore ).getEditorMode(),
@@ -85,17 +84,11 @@ export default function EditorInterface( {
isListViewOpened: select( editorStore ).isListViewOpened(),
isDistractionFree: get( 'core', 'distractionFree' ),
isPreviewMode: editorSettings.__unstableIsPreviewMode,
- previousShortcut: select(
- keyboardShortcutsStore
- ).getAllShortcutKeyCombinations( 'core/editor/previous-region' ),
- nextShortcut: select(
- keyboardShortcutsStore
- ).getAllShortcutKeyCombinations( 'core/editor/next-region' ),
showBlockBreadcrumbs: get( 'core', 'showBlockBreadcrumbs' ),
- // translators: Default label for the Document in the Block Breadcrumb.
- documentLabel: postTypeLabel || _x( 'Document', 'noun' ),
- blockEditorMode:
- select( blockEditorStore ).__unstableGetEditorMode(),
+ documentLabel:
+ // translators: Default label for the Document in the Block Breadcrumb.
+ postTypeLabel || _x( 'Document', 'noun, breadcrumb' ),
+ isZoomOut: _isZoomOut(),
};
}, [] );
const isLargeViewport = useViewportMatch( 'medium' );
@@ -119,7 +112,6 @@ export default function EditorInterface( {
return (
)
@@ -229,10 +221,6 @@ export default function EditorInterface( {
)
: undefined
}
- shortcuts={ {
- previous: previousShortcut,
- next: nextShortcut,
- } }
/>
);
}
diff --git a/packages/editor/src/components/entities-saved-states/index.js b/packages/editor/src/components/entities-saved-states/index.js
index 8c0d59573a44d9..ba84ef2b392f5b 100644
--- a/packages/editor/src/components/entities-saved-states/index.js
+++ b/packages/editor/src/components/entities-saved-states/index.js
@@ -128,11 +128,21 @@ export function EntitiesSavedStatesExtensible( {
aria-describedby={ renderDialog ? dialogDescription : undefined }
>
+
+ { __( 'Cancel' ) }
+
@@ -147,14 +157,6 @@ export function EntitiesSavedStatesExtensible( {
>
{ saveLabel }
-
- { __( 'Cancel' ) }
-
diff --git a/packages/editor/src/components/entities-saved-states/style.scss b/packages/editor/src/components/entities-saved-states/style.scss
index 981a0d92e5ff6b..e2c320678c322a 100644
--- a/packages/editor/src/components/entities-saved-states/style.scss
+++ b/packages/editor/src/components/entities-saved-states/style.scss
@@ -1,8 +1,8 @@
.entities-saved-states__panel-header {
box-sizing: border-box;
background: $white;
- padding-left: $grid-unit-10;
- padding-right: $grid-unit-10;
+ padding-left: $grid-unit-20;
+ padding-right: $grid-unit-20;
height: $header-height;
border-bottom: $border-width solid $gray-300;
}
diff --git a/packages/editor/src/components/global-styles-provider/index.js b/packages/editor/src/components/global-styles-provider/index.js
index 6f2d8177056cb1..3f3a3389801eb6 100644
--- a/packages/editor/src/components/global-styles-provider/index.js
+++ b/packages/editor/src/components/global-styles-provider/index.js
@@ -56,13 +56,27 @@ function useGlobalStylesUserConfig() {
select( coreStore ).__experimentalGetCurrentGlobalStylesId();
let record;
- const userCanEditGlobalStyles = canUser( 'update', {
- kind: 'root',
- name: 'globalStyles',
- id: _globalStylesId,
- } );
- if ( _globalStylesId ) {
+ // We want the global styles ID request to finish before triggering
+ // the OPTIONS request for user capabilities, otherwise it will
+ // fetch `/wp/v2/global-styles` instead of
+ // `/wp/v2/global-styles/{id}`!
+ // Please adjust the preloaded requests if this changes!
+ const userCanEditGlobalStyles = _globalStylesId
+ ? canUser( 'update', {
+ kind: 'root',
+ name: 'globalStyles',
+ id: _globalStylesId,
+ } )
+ : null;
+
+ if (
+ _globalStylesId &&
+ // We want the OPTIONS request for user capabilities to finish
+ // before getting the records, otherwise we'll fetch both!
+ typeof userCanEditGlobalStyles === 'boolean'
+ ) {
+ // Please adjust the preloaded requests if this changes!
if ( userCanEditGlobalStyles ) {
record = getEditedEntityRecord(
'root',
diff --git a/packages/editor/src/components/header/index.js b/packages/editor/src/components/header/index.js
index fb034ba8bb8574..eb0dd68647f56f 100644
--- a/packages/editor/src/components/header/index.js
+++ b/packages/editor/src/components/header/index.js
@@ -57,6 +57,7 @@ function Header( {
isPublishSidebarOpened,
showIconLabels,
hasFixedToolbar,
+ hasBlockSelection,
isNestedEntity,
} = useSelect( ( select ) => {
const { get: getPreference } = select( preferencesStore );
@@ -72,6 +73,8 @@ function Header( {
isPublishSidebarOpened: _isPublishSidebarOpened(),
showIconLabels: getPreference( 'core', 'showIconLabels' ),
hasFixedToolbar: getPreference( 'core', 'fixedToolbar' ),
+ hasBlockSelection:
+ !! select( blockEditorStore ).getBlockSelectionStart(),
isNestedEntity:
!! getEditorSettings().onNavigateToPreviousEntityRecord,
isZoomedOutView: __unstableGetEditorMode() === 'zoom-out',
@@ -81,7 +84,9 @@ function Header( {
const [ isBlockToolsCollapsed, setIsBlockToolsCollapsed ] =
useState( true );
- const hasCenter = isBlockToolsCollapsed && ! isTooNarrowForDocumentBar;
+ const hasCenter =
+ ( ! hasBlockSelection || isBlockToolsCollapsed ) &&
+ ! isTooNarrowForDocumentBar;
const hasBackButton = useHasBackButton();
// The edit-post-header classname is only kept for backward compatibilty
@@ -134,6 +139,7 @@ function Header( {
// when the publish sidebar has been closed.
) }
+
- { isEditorIframed && isWideViewport &&
}
+ { isEditorIframed && isWideViewport && (
+
+ ) }
{ ( isWideViewport || ! showIconLabels ) && (
diff --git a/packages/editor/src/components/header/style.scss b/packages/editor/src/components/header/style.scss
index 8712121fff3ea6..4259ef4b8a2084 100644
--- a/packages/editor/src/components/header/style.scss
+++ b/packages/editor/src/components/header/style.scss
@@ -130,6 +130,7 @@
// ... and display labels.
&::after {
content: attr(aria-label);
+ white-space: nowrap;
}
&[aria-disabled="true"] {
background-color: transparent;
diff --git a/packages/editor/src/components/page-attributes/parent.js b/packages/editor/src/components/page-attributes/parent.js
index 0f19ffcdd5daa4..17395589cd313b 100644
--- a/packages/editor/src/components/page-attributes/parent.js
+++ b/packages/editor/src/components/page-attributes/parent.js
@@ -212,8 +212,10 @@ function PostParentToggle( { isOpen, onClick } ) {
className="editor-post-parent__panel-toggle"
variant="tertiary"
aria-expanded={ isOpen }
- // translators: %s: Current post parent.
- aria-label={ sprintf( __( 'Change parent: %s' ), parentTitle ) }
+ aria-label={
+ // translators: %s: Current post parent.
+ sprintf( __( 'Change parent: %s' ), parentTitle )
+ }
onClick={ onClick }
>
{ parentTitle }
@@ -261,9 +263,9 @@ export function ParentRow() {
{ createInterpolateElement(
sprintf(
- /* translators: %1$s The home URL of the WordPress installation without the scheme. */
+ /* translators: %s: The home URL of the WordPress installation without the scheme. */
__(
- 'Child pages inherit characteristics from their parent, such as URL structure. For instance, if "Pricing" is a child of "Services", its URL would be %1$s
/services
/pricing.'
+ 'Child pages inherit characteristics from their parent, such as URL structure. For instance, if "Pricing" is a child of "Services", its URL would be %s
/services
/pricing.'
),
filterURLForDisplay( homeUrl ).replace(
/([/.])/g,
diff --git a/packages/editor/src/components/plugin-sidebar/index.js b/packages/editor/src/components/plugin-sidebar/index.js
index b9c0177e30fc42..56a954cadffb69 100644
--- a/packages/editor/src/components/plugin-sidebar/index.js
+++ b/packages/editor/src/components/plugin-sidebar/index.js
@@ -3,7 +3,6 @@
*/
import { useSelect } from '@wordpress/data';
import { __ } from '@wordpress/i18n';
-import { store as keyboardShortcutsStore } from '@wordpress/keyboard-shortcuts';
import { ComplementaryArea } from '@wordpress/interface';
/**
@@ -77,12 +76,9 @@ import { store as editorStore } from '../../store';
* ```
*/
export default function PluginSidebar( { className, ...props } ) {
- const { postTitle, shortcut } = useSelect( ( select ) => {
+ const { postTitle } = useSelect( ( select ) => {
return {
postTitle: select( editorStore ).getEditedPostAttribute( 'title' ),
- shortcut: select(
- keyboardShortcutsStore
- ).getShortcutRepresentation( 'core/editor/toggle-sidebar' ),
};
}, [] );
return (
@@ -91,7 +87,6 @@ export default function PluginSidebar( { className, ...props } ) {
className="editor-sidebar"
smallScreenTitle={ postTitle || __( '(no title)' ) }
scope="core"
- toggleShortcut={ shortcut }
{ ...props }
/>
);
diff --git a/packages/editor/src/components/post-author/panel.js b/packages/editor/src/components/post-author/panel.js
index a3c53aa417163e..6c6a51918902dc 100644
--- a/packages/editor/src/components/post-author/panel.js
+++ b/packages/editor/src/components/post-author/panel.js
@@ -25,8 +25,10 @@ function PostAuthorToggle( { isOpen, onClick } ) {
className="editor-post-author__panel-toggle"
variant="tertiary"
aria-expanded={ isOpen }
- // translators: %s: Current post link.
- aria-label={ sprintf( __( 'Change author: %s' ), authorName ) }
+ aria-label={
+ // translators: %s: Author name.
+ sprintf( __( 'Change author: %s' ), authorName )
+ }
onClick={ onClick }
>
{ authorName }
diff --git a/packages/editor/src/components/post-content-information/index.js b/packages/editor/src/components/post-content-information/index.js
index 569339ef40c8b9..17a43b1ba12962 100644
--- a/packages/editor/src/components/post-content-information/index.js
+++ b/packages/editor/src/components/post-content-information/index.js
@@ -70,7 +70,7 @@ export default function PostContentInformation() {
readingTime <= 1
? __( '1 minute' )
: sprintf(
- // translators: %s: the number of minutes to read the post.
+ /* translators: %s: the number of minutes to read the post. */
_n( '%s minute', '%s minutes', readingTime ),
readingTime.toLocaleString()
);
diff --git a/packages/editor/src/components/post-last-revision/index.js b/packages/editor/src/components/post-last-revision/index.js
index c0ce37198c951d..fd68f9703cb4e2 100644
--- a/packages/editor/src/components/post-last-revision/index.js
+++ b/packages/editor/src/components/post-last-revision/index.js
@@ -44,7 +44,7 @@ function PostLastRevision() {
icon={ backup }
iconPosition="right"
text={ sprintf(
- /* translators: %s: number of revisions */
+ /* translators: %s: number of revisions. */
__( 'Revisions (%s)' ),
revisionsCount
) }
diff --git a/packages/editor/src/components/post-publish-button/index.js b/packages/editor/src/components/post-publish-button/index.js
index 0460b27b616e81..71e18a4d6a9c82 100644
--- a/packages/editor/src/components/post-publish-button/index.js
+++ b/packages/editor/src/components/post-publish-button/index.js
@@ -2,7 +2,7 @@
* WordPress dependencies
*/
import { Button } from '@wordpress/components';
-import { Component, createRef } from '@wordpress/element';
+import { Component } from '@wordpress/element';
import { withSelect, withDispatch } from '@wordpress/data';
import { compose } from '@wordpress/compose';
@@ -17,7 +17,6 @@ const noop = () => {};
export class PostPublishButton extends Component {
constructor( props ) {
super( props );
- this.buttonNode = createRef();
this.createOnClick = this.createOnClick.bind( this );
this.closeEntitiesSavedStates =
@@ -28,21 +27,6 @@ export class PostPublishButton extends Component {
};
}
- componentDidMount() {
- if ( this.props.focusOnMount ) {
- // This timeout is necessary to make sure the `useEffect` hook of
- // `useFocusReturn` gets the correct element (the button that opens the
- // PostPublishPanel) otherwise it will get this button.
- this.timeoutID = setTimeout( () => {
- this.buttonNode.current.focus();
- }, 0 );
- }
- }
-
- componentWillUnmount() {
- clearTimeout( this.timeoutID );
- }
-
createOnClick( callback ) {
return ( ...args ) => {
const { hasNonPostEntityChanges, setEntitiesSavedStatesCallback } =
@@ -182,7 +166,6 @@ export class PostPublishButton extends Component {
return (
<>
{
+ this.cancelButtonNode.current.focus();
+ }, 0 );
+ }
+
+ componentWillUnmount() {
+ clearTimeout( this.timeoutID );
}
componentDidUpdate( prevProps ) {
@@ -85,15 +99,9 @@ export class PostPublishPanel extends Component {
/>
) : (
<>
-
+
>
) }
diff --git a/packages/editor/src/components/post-publish-panel/maybe-upload-media.js b/packages/editor/src/components/post-publish-panel/maybe-upload-media.js
index 8c8d757c583399..32ea69c425e0b5 100644
--- a/packages/editor/src/components/post-publish-panel/maybe-upload-media.js
+++ b/packages/editor/src/components/post-publish-panel/maybe-upload-media.js
@@ -98,8 +98,8 @@ function Image( { clientId, alt, url } ) {
animate={ { opacity: 1 } }
exit={ { opacity: 0, scale: 0 } }
style={ {
- width: '36px',
- height: '36px',
+ width: '32px',
+ height: '32px',
objectFit: 'cover',
borderRadius: '2px',
cursor: 'pointer',
@@ -256,7 +256,7 @@ export default function MaybeUploadMediaPanel() {
) : (
diff --git a/packages/editor/src/components/post-publish-panel/style.scss b/packages/editor/src/components/post-publish-panel/style.scss
index 9892cf5430f9a2..7b075717651781 100644
--- a/packages/editor/src/components/post-publish-panel/style.scss
+++ b/packages/editor/src/components/post-publish-panel/style.scss
@@ -68,12 +68,12 @@
}
.editor-post-publish-panel__header-publish-button {
- padding-right: $grid-unit-05;
+ padding-left: $grid-unit-05;
justify-content: center;
}
.editor-post-publish-panel__header-cancel-button {
- padding-left: $grid-unit-05;
+ padding-right: $grid-unit-05;
}
.editor-post-publish-panel__header-published {
diff --git a/packages/editor/src/components/post-publish-panel/test/__snapshots__/index.js.snap b/packages/editor/src/components/post-publish-panel/test/__snapshots__/index.js.snap
index 1dd75fffaa7b6a..b074159ac423d4 100644
--- a/packages/editor/src/components/post-publish-panel/test/__snapshots__/index.js.snap
+++ b/packages/editor/src/components/post-publish-panel/test/__snapshots__/index.js.snap
@@ -433,24 +433,24 @@ exports[`PostPublishPanel should render the pre-publish panel if post status is
class="editor-post-publish-panel__header"
>
@@ -586,24 +586,24 @@ exports[`PostPublishPanel should render the pre-publish panel if the post is not
class="editor-post-publish-panel__header"
>
@@ -783,24 +783,24 @@ exports[`PostPublishPanel should render the spinner if the post is being saved 1
class="editor-post-publish-panel__header"
>
diff --git a/packages/editor/src/components/post-schedule/label.js b/packages/editor/src/components/post-schedule/label.js
index 89e7d02d69ce7f..f6cf3811db7916 100644
--- a/packages/editor/src/components/post-schedule/label.js
+++ b/packages/editor/src/components/post-schedule/label.js
@@ -48,7 +48,7 @@ export function getFullPostScheduleLabel( dateAttribute ) {
const timezoneAbbreviation = getTimezoneAbbreviation();
const formattedDate = dateI18n(
- // translators: If using a space between 'g:i' and 'a', use a non-breaking space.
+ // translators: Use a non-breaking space between 'g:i' and 'a' if appropriate.
_x( 'F j, Y g:i\xa0a', 'post schedule full date format' ),
date
);
diff --git a/packages/editor/src/components/post-taxonomies/flat-term-selector.js b/packages/editor/src/components/post-taxonomies/flat-term-selector.js
index 5f581e898c953e..cd9377766af503 100644
--- a/packages/editor/src/components/post-taxonomies/flat-term-selector.js
+++ b/packages/editor/src/components/post-taxonomies/flat-term-selector.js
@@ -2,8 +2,12 @@
* WordPress dependencies
*/
import { __, _x, sprintf } from '@wordpress/i18n';
-import { useEffect, useMemo, useState } from '@wordpress/element';
-import { FormTokenField, withFilters } from '@wordpress/components';
+import { Fragment, useEffect, useMemo, useState } from '@wordpress/element';
+import {
+ FormTokenField,
+ withFilters,
+ __experimentalVStack as VStack,
+} from '@wordpress/components';
import { useSelect, useDispatch } from '@wordpress/data';
import deprecated from '@wordpress/deprecated';
import { store as coreStore } from '@wordpress/core-data';
@@ -53,6 +57,13 @@ const termNamesToIds = ( names, terms ) => {
.filter( ( id ) => id !== undefined );
};
+const Wrapper = ( { children, __nextHasNoMarginBottom } ) =>
+ __nextHasNoMarginBottom ? (
+ { children }
+ ) : (
+ { children }
+ );
+
/**
* Renders a flat term selector component.
*
@@ -289,7 +300,7 @@ export function FlatTermSelector( { slug, __nextHasNoMarginBottom } ) {
);
return (
- <>
+
- >
+
);
}
diff --git a/packages/editor/src/components/post-taxonomies/hierarchical-term-selector.js b/packages/editor/src/components/post-taxonomies/hierarchical-term-selector.js
index 071453f4f3f626..e3e9e866df37ec 100644
--- a/packages/editor/src/components/post-taxonomies/hierarchical-term-selector.js
+++ b/packages/editor/src/components/post-taxonomies/hierarchical-term-selector.js
@@ -310,7 +310,7 @@ export function HierarchicalTermSelector( { slug } ) {
const defaultName =
slug === 'category' ? __( 'Category' ) : __( 'Term' );
const termAddedMessage = sprintf(
- /* translators: %s: taxonomy name */
+ /* translators: %s: term name. */
_x( '%s added', 'term' ),
taxonomy?.labels?.singular_name ?? defaultName
);
@@ -341,7 +341,7 @@ export function HierarchicalTermSelector( { slug } ) {
const resultCount = getResultCount( newFilteredTermsTree );
const resultsFoundMessage = sprintf(
- /* translators: %d: number of results */
+ /* translators: %d: number of results. */
_n( '%d result found.', '%d results found.', resultCount ),
resultCount
);
diff --git a/packages/editor/src/components/post-url/panel.js b/packages/editor/src/components/post-url/panel.js
index aca36566c04727..d6d6a5817ab969 100644
--- a/packages/editor/src/components/post-url/panel.js
+++ b/packages/editor/src/components/post-url/panel.js
@@ -86,8 +86,10 @@ function PostURLToggle( { isOpen, onClick } ) {
className="editor-post-url__panel-toggle"
variant="tertiary"
aria-expanded={ isOpen }
- // translators: %s: Current post link.
- aria-label={ sprintf( __( 'Change link: %s' ), decodedSlug ) }
+ aria-label={
+ // translators: %s: Current post link.
+ sprintf( __( 'Change link: %s' ), decodedSlug )
+ }
onClick={ onClick }
>
diff --git a/packages/editor/src/components/preferences-modal/index.js b/packages/editor/src/components/preferences-modal/index.js
index a8cfd8245522cd..7ea7ea456ce28e 100644
--- a/packages/editor/src/components/preferences-modal/index.js
+++ b/packages/editor/src/components/preferences-modal/index.js
@@ -36,25 +36,40 @@ const {
} = unlock( preferencesPrivateApis );
export default function EditorPreferencesModal( { extraSections = {} } ) {
+ const isActive = useSelect( ( select ) => {
+ return select( interfaceStore ).isModalActive( 'editor/preferences' );
+ }, [] );
+ const { closeModal } = useDispatch( interfaceStore );
+
+ if ( ! isActive ) {
+ return null;
+ }
+
+ // Please wrap all contents inside PreferencesModalContents to prevent all
+ // hooks from executing when the modal is not open.
+ return (
+
+
+
+ );
+}
+
+function PreferencesModalContents( { extraSections = {} } ) {
const isLargeViewport = useViewportMatch( 'medium' );
- const { isActive, showBlockBreadcrumbsOption } = useSelect(
+ const showBlockBreadcrumbsOption = useSelect(
( select ) => {
const { getEditorSettings } = select( editorStore );
const { get } = select( preferencesStore );
- const { isModalActive } = select( interfaceStore );
const isRichEditingEnabled = getEditorSettings().richEditingEnabled;
const isDistractionFreeEnabled = get( 'core', 'distractionFree' );
- return {
- showBlockBreadcrumbsOption:
- ! isDistractionFreeEnabled &&
- isLargeViewport &&
- isRichEditingEnabled,
- isActive: isModalActive( 'editor/preferences' ),
- };
+ return (
+ ! isDistractionFreeEnabled &&
+ isLargeViewport &&
+ isRichEditingEnabled
+ );
},
[ isLargeViewport ]
);
- const { closeModal } = useDispatch( interfaceStore );
const { setIsListViewOpened, setIsInserterOpened } =
useDispatch( editorStore );
const { set: setPreference } = useDispatch( preferencesStore );
@@ -330,13 +345,5 @@ export default function EditorPreferencesModal( { extraSections = {} } ) {
]
);
- if ( ! isActive ) {
- return null;
- }
-
- return (
-
-
-
- );
+ return ;
}
diff --git a/packages/editor/src/components/preferences-modal/test/index.js b/packages/editor/src/components/preferences-modal/test/index.js
index 01ac1a88fbe7d8..70102eea40f2ee 100644
--- a/packages/editor/src/components/preferences-modal/test/index.js
+++ b/packages/editor/src/components/preferences-modal/test/index.js
@@ -19,7 +19,7 @@ jest.mock( '@wordpress/compose/src/hooks/use-viewport-match', () => jest.fn() );
describe( 'EditPostPreferencesModal', () => {
it( 'should not render when the modal is not active', () => {
- useSelect.mockImplementation( () => [ false, false, false ] );
+ useSelect.mockImplementation( () => false );
render( );
expect(
screen.queryByRole( 'dialog', { name: 'Preferences' } )
diff --git a/packages/editor/src/components/preview-dropdown/index.js b/packages/editor/src/components/preview-dropdown/index.js
index ecc5bc610a3027..0fbb2beb62665e 100644
--- a/packages/editor/src/components/preview-dropdown/index.js
+++ b/packages/editor/src/components/preview-dropdown/index.js
@@ -26,7 +26,9 @@ import { ActionItem } from '@wordpress/interface';
* Internal dependencies
*/
import { store as editorStore } from '../../store';
+import { store as blockEditorStore } from '@wordpress/block-editor';
import PostPreviewButton from '../post-preview-button';
+import { unlock } from '../../lock-unlock';
export default function PreviewDropdown( { forceIsAutosaveable, disabled } ) {
const { deviceType, homeUrl, isTemplate, isViewable, showIconLabels } =
@@ -44,6 +46,14 @@ export default function PreviewDropdown( { forceIsAutosaveable, disabled } ) {
};
}, [] );
const { setDeviceType } = useDispatch( editorStore );
+ const { __unstableSetEditorMode } = useDispatch( blockEditorStore );
+ const { resetZoomLevel } = unlock( useDispatch( blockEditorStore ) );
+
+ const handleDevicePreviewChange = ( newDeviceType ) => {
+ setDeviceType( newDeviceType );
+ __unstableSetEditorMode( 'edit' );
+ resetZoomLevel();
+ };
const isMobile = useViewportMatch( 'medium', '<' );
if ( isMobile ) {
@@ -113,7 +123,7 @@ export default function PreviewDropdown( { forceIsAutosaveable, disabled } ) {
{ isTemplate && (
diff --git a/packages/editor/src/components/provider/index.js b/packages/editor/src/components/provider/index.js
index 11b1478d58434a..e266ac83226dab 100644
--- a/packages/editor/src/components/provider/index.js
+++ b/packages/editor/src/components/provider/index.js
@@ -13,6 +13,7 @@ import {
BlockEditorProvider,
BlockContextProvider,
privateApis as blockEditorPrivateApis,
+ store as blockEditorStore,
} from '@wordpress/block-editor';
import { store as noticesStore } from '@wordpress/notices';
import { privateApis as editPatternsPrivateApis } from '@wordpress/patterns';
@@ -164,50 +165,59 @@ export const ExperimentalEditorProvider = withRegistryProvider(
BlockEditorProviderComponent = ExperimentalBlockEditorProvider,
__unstableTemplate: template,
} ) => {
- const { editorSettings, selection, isReady, mode, postTypes } =
- useSelect( ( select ) => {
- const {
- getEditorSettings,
- getEditorSelection,
- getRenderingMode,
- __unstableIsEditorReady,
- } = select( editorStore );
- const { getPostTypes } = select( coreStore );
+ const { editorSettings, selection, isReady, mode, postTypeEntities } =
+ useSelect(
+ ( select ) => {
+ const {
+ getEditorSettings,
+ getEditorSelection,
+ getRenderingMode,
+ __unstableIsEditorReady,
+ } = select( editorStore );
+ const { getEntitiesConfig } = select( coreStore );
+
+ return {
+ editorSettings: getEditorSettings(),
+ isReady: __unstableIsEditorReady(),
+ mode: getRenderingMode(),
+ selection: getEditorSelection(),
+ postTypeEntities:
+ post.type === 'wp_template'
+ ? getEntitiesConfig( 'postType' )
+ : null,
+ };
+ },
+ [ post.type ]
+ );
+
+ const isZoomOut = useSelect( ( select ) => {
+ const { __unstableGetEditorMode } = unlock(
+ select( blockEditorStore )
+ );
+
+ return __unstableGetEditorMode() === 'zoom-out';
+ } );
- return {
- editorSettings: getEditorSettings(),
- isReady: __unstableIsEditorReady(),
- mode: getRenderingMode(),
- selection: getEditorSelection(),
- postTypes: getPostTypes( { per_page: -1 } ),
- };
- }, [] );
const shouldRenderTemplate = !! template && mode !== 'post-only';
const rootLevelPost = shouldRenderTemplate ? template : post;
const defaultBlockContext = useMemo( () => {
const postContext = {};
- // If it is a template, try to inherit the post type from the slug.
+ // If it is a template, try to inherit the post type from the name.
if ( post.type === 'wp_template' ) {
- if ( ! post.is_custom ) {
- const [ kind ] = post.slug.split( '-' );
- switch ( kind ) {
- case 'page':
- postContext.postType = 'page';
- break;
- case 'single':
- // Infer the post type from the slug.
- const postTypesSlugs =
- postTypes?.map( ( entity ) => entity.slug ) ||
- [];
- const match = post.slug.match(
- `^single-(${ postTypesSlugs.join(
- '|'
- ) })(?:-.+)?$`
- );
- if ( match ) {
- postContext.postType = match[ 1 ];
- }
- break;
+ if ( post.slug === 'page' ) {
+ postContext.postType = 'page';
+ } else if ( post.slug === 'single' ) {
+ postContext.postType = 'post';
+ } else if ( post.slug.split( '-' )[ 0 ] === 'single' ) {
+ // If the slug is single-{postType}, infer the post type from the name.
+ const postTypeNames =
+ postTypeEntities?.map( ( entity ) => entity.name ) ||
+ [];
+ const match = post.slug.match(
+ `^single-(${ postTypeNames.join( '|' ) })(?:-.+)?$`
+ );
+ if ( match ) {
+ postContext.postType = match[ 1 ];
}
}
} else if (
@@ -229,9 +239,10 @@ export const ExperimentalEditorProvider = withRegistryProvider(
shouldRenderTemplate,
post.id,
post.type,
+ post.slug,
rootLevelPost.type,
rootLevelPost.slug,
- postTypes,
+ postTypeEntities,
] );
const { id, type } = rootLevelPost;
const blockEditorSettings = useBlockEditorSettings(
@@ -331,9 +342,13 @@ export const ExperimentalEditorProvider = withRegistryProvider(
{ children }
{ ! settings.__unstableIsPreviewMode && (
<>
-
-
-
+ { ! isZoomOut && (
+ <>
+
+
+
+ >
+ ) }
{ mode === 'template-locked' && (
) }
diff --git a/packages/editor/src/components/sidebar/header.js b/packages/editor/src/components/sidebar/header.js
index fc4d44ba9e2958..ed2f7f89fe6e7a 100644
--- a/packages/editor/src/components/sidebar/header.js
+++ b/packages/editor/src/components/sidebar/header.js
@@ -20,8 +20,9 @@ const SidebarHeader = ( _, ref ) => {
const { getPostTypeLabel } = select( editorStore );
return {
- // translators: Default label for the Document sidebar tab, not selected.
- documentLabel: getPostTypeLabel() || _x( 'Document', 'noun' ),
+ documentLabel:
+ // translators: Default label for the Document sidebar tab, not selected.
+ getPostTypeLabel() || _x( 'Document', 'noun, sidebar' ),
};
}, [] );
diff --git a/packages/editor/src/components/sidebar/index.js b/packages/editor/src/components/sidebar/index.js
index efca709e0eec75..601bcd8f311bb8 100644
--- a/packages/editor/src/components/sidebar/index.js
+++ b/packages/editor/src/components/sidebar/index.js
@@ -13,7 +13,7 @@ import {
useEffect,
useRef,
} from '@wordpress/element';
-import { isRTL, __ } from '@wordpress/i18n';
+import { isRTL, __, _x } from '@wordpress/i18n';
import { drawerLeft, drawerRight } from '@wordpress/icons';
import { store as keyboardShortcutsStore } from '@wordpress/keyboard-shortcuts';
import { privateApis as componentsPrivateApis } from '@wordpress/components';
@@ -101,8 +101,10 @@ const SidebarContent = ( {
// see https://github.com/WordPress/gutenberg/pull/55360#pullrequestreview-1737671049
className="editor-sidebar__panel"
headerClassName="editor-sidebar__panel-tabs"
- /* translators: button label text should, if possible, be under 16 characters. */
- title={ __( 'Settings' ) }
+ title={
+ /* translators: button label text should, if possible, be under 16 characters. */
+ _x( 'Settings', 'sidebar button label' )
+ }
toggleShortcut={ keyboardShortcut }
icon={ isRTL() ? drawerLeft : drawerRight }
isActiveByDefault={ SIDEBAR_ACTIVE_BY_DEFAULT }
diff --git a/packages/editor/src/components/sidebar/post-summary.js b/packages/editor/src/components/sidebar/post-summary.js
index 72f1080770fd9d..3539f7ba964ec7 100644
--- a/packages/editor/src/components/sidebar/post-summary.js
+++ b/packages/editor/src/components/sidebar/post-summary.js
@@ -87,11 +87,11 @@ export default function PostSummary( { onActionPerformed } ) {
+ { fills }
- { fills }
) }
diff --git a/packages/editor/src/components/text-editor/index.js b/packages/editor/src/components/text-editor/index.js
index fa0688859b5a69..6997c66826a12d 100644
--- a/packages/editor/src/components/text-editor/index.js
+++ b/packages/editor/src/components/text-editor/index.js
@@ -6,6 +6,7 @@ import { useDispatch, useSelect } from '@wordpress/data';
import { __ } from '@wordpress/i18n';
import { store as keyboardShortcutsStore } from '@wordpress/keyboard-shortcuts';
import { useEffect, useRef } from '@wordpress/element';
+import { store as blockEditorStore } from '@wordpress/block-editor';
/**
* Internal dependencies
@@ -13,6 +14,7 @@ import { useEffect, useRef } from '@wordpress/element';
import { store as editorStore } from '../../store';
import PostTextEditor from '../post-text-editor';
import PostTitleRaw from '../post-title/post-title-raw';
+import { unlock } from '../../lock-unlock';
export default function TextEditor( { autoFocus = false } ) {
const { switchEditorMode } = useDispatch( editorStore );
@@ -26,13 +28,20 @@ export default function TextEditor( { autoFocus = false } ) {
};
}, [] );
+ const { resetZoomLevel, __unstableSetEditorMode } = unlock(
+ useDispatch( blockEditorStore )
+ );
+
const titleRef = useRef();
useEffect( () => {
+ resetZoomLevel();
+ __unstableSetEditorMode( 'edit' );
+
if ( autoFocus ) {
return;
}
titleRef?.current?.focus();
- }, [ autoFocus ] );
+ }, [ autoFocus, resetZoomLevel, __unstableSetEditorMode ] );
return (
diff --git a/packages/editor/src/components/time-to-read/index.js b/packages/editor/src/components/time-to-read/index.js
index a71a4b1dac8388..5d748abc3049cb 100644
--- a/packages/editor/src/components/time-to-read/index.js
+++ b/packages/editor/src/components/time-to-read/index.js
@@ -47,10 +47,10 @@ export default function TimeToRead() {
} )
: createInterpolateElement(
sprintf(
- /* translators: %s is the number of minutes the post will take to read. */
+ /* translators: %s: the number of minutes to read the post. */
_n(
- '
%d minute',
- '
%d minutes',
+ '
%s minute',
+ '
%s minutes',
minutesToRead
),
minutesToRead
diff --git a/packages/editor/src/components/visual-editor/edit-template-blocks-notification.js b/packages/editor/src/components/visual-editor/edit-template-blocks-notification.js
index 3ccbe79127c013..bacf1beb1abecd 100644
--- a/packages/editor/src/components/visual-editor/edit-template-blocks-notification.js
+++ b/packages/editor/src/components/visual-editor/edit-template-blocks-notification.js
@@ -61,7 +61,11 @@ export default function EditTemplateBlocksNotification( { contentRef } ) {
) {
return;
}
- setIsDialogOpen( true );
+
+ if ( ! event.defaultPrevented ) {
+ event.preventDefault();
+ setIsDialogOpen( true );
+ }
};
const canvas = contentRef.current;
diff --git a/packages/editor/src/components/visual-editor/index.js b/packages/editor/src/components/visual-editor/index.js
index 2ff115272d614b..d154e77b58003e 100644
--- a/packages/editor/src/components/visual-editor/index.js
+++ b/packages/editor/src/components/visual-editor/index.js
@@ -48,6 +48,7 @@ const {
useLayoutStyles,
ExperimentalBlockCanvas: BlockCanvas,
useFlashEditableBlocks,
+ useZoomOutModeExit,
} = unlock( blockEditorPrivateApis );
/**
@@ -174,17 +175,19 @@ function VisualEditor( {
hasRootPaddingAwareAlignments,
themeHasDisabledLayoutStyles,
themeSupportsLayout,
- isZoomOutMode,
+ isZoomedOut,
} = useSelect( ( select ) => {
- const { getSettings, __unstableGetEditorMode } =
- select( blockEditorStore );
+ const { getSettings, isZoomOut: _isZoomOut } = unlock(
+ select( blockEditorStore )
+ );
+
const _settings = getSettings();
return {
themeHasDisabledLayoutStyles: _settings.disableLayoutStyles,
themeSupportsLayout: _settings.supportsLayout,
hasRootPaddingAwareAlignments:
_settings.__experimentalFeatures?.useRootPaddingAwareAlignments,
- isZoomOutMode: __unstableGetEditorMode() === 'zoom-out',
+ isZoomedOut: _isZoomOut(),
};
}, [] );
@@ -333,13 +336,14 @@ function VisualEditor( {
useSelectNearestEditableBlock( {
isEnabled: renderingMode === 'template-locked',
} ),
+ useZoomOutModeExit(),
] );
const zoomOutProps =
- isZoomOutMode && ! isTabletViewport
+ isZoomedOut && ! isTabletViewport
? {
scale: 'default',
- frameSize: '48px',
+ frameSize: '40px',
}
: {};
@@ -355,7 +359,7 @@ function VisualEditor( {
// Disable resizing in mobile viewport.
! isMobileViewport &&
// Dsiable resizing in zoomed-out mode.
- ! isZoomOutMode;
+ ! isZoomedOut;
const shouldIframe =
! disableIframe || [ 'Tablet', 'Mobile' ].includes( deviceType );
@@ -363,7 +367,10 @@ function VisualEditor( {
return [
...( styles ?? [] ),
{
- css: `.is-root-container{display:flow-root;${
+ // Ensures margins of children are contained so that the body background paints behind them.
+ // Otherwise, the background of html (when zoomed out) would show there and appear broken. It’s
+ // important mostly for post-only views yet conceivably an issue in templated views too.
+ css: `:where(.block-editor-iframe__body){display:flow-root;}.is-root-container{display:flow-root;${
// Some themes will have `min-height: 100vh` for the root container,
// which isn't a requirement in auto resize mode.
enableResizing ? 'min-height:0!important;' : ''
diff --git a/packages/editor/src/components/zoom-out-toggle/index.js b/packages/editor/src/components/zoom-out-toggle/index.js
index e8c7b1e50510ab..18d5fcdbe2de19 100644
--- a/packages/editor/src/components/zoom-out-toggle/index.js
+++ b/packages/editor/src/components/zoom-out-toggle/index.js
@@ -7,26 +7,45 @@ import { __ } from '@wordpress/i18n';
import { useDispatch, useSelect } from '@wordpress/data';
import { store as blockEditorStore } from '@wordpress/block-editor';
import { square as zoomOutIcon } from '@wordpress/icons';
+import { store as preferencesStore } from '@wordpress/preferences';
-const ZoomOutToggle = () => {
- const { isZoomOutMode } = useSelect( ( select ) => ( {
- isZoomOutMode:
- select( blockEditorStore ).__unstableGetEditorMode() === 'zoom-out',
+/**
+ * Internal dependencies
+ */
+import { unlock } from '../../lock-unlock';
+
+const ZoomOutToggle = ( { disabled } ) => {
+ const { isZoomOut, showIconLabels } = useSelect( ( select ) => ( {
+ isZoomOut: unlock( select( blockEditorStore ) ).isZoomOut(),
+ showIconLabels: select( preferencesStore ).get(
+ 'core',
+ 'showIconLabels'
+ ),
} ) );
- const { __unstableSetEditorMode } = useDispatch( blockEditorStore );
+ const { resetZoomLevel, setZoomLevel, __unstableSetEditorMode } = unlock(
+ useDispatch( blockEditorStore )
+ );
const handleZoomOut = () => {
- __unstableSetEditorMode( isZoomOutMode ? 'edit' : 'zoom-out' );
+ if ( isZoomOut ) {
+ resetZoomLevel();
+ } else {
+ setZoomLevel( 50 );
+ }
+ __unstableSetEditorMode( isZoomOut ? 'edit' : 'zoom-out' );
};
return (
);
};
diff --git a/packages/editor/src/dataviews/actions/delete-post.tsx b/packages/editor/src/dataviews/actions/delete-post.tsx
index 381c2964f943f6..05f8791da79000 100644
--- a/packages/editor/src/dataviews/actions/delete-post.tsx
+++ b/packages/editor/src/dataviews/actions/delete-post.tsx
@@ -3,7 +3,7 @@
*/
import { trash } from '@wordpress/icons';
import { useDispatch } from '@wordpress/data';
-import { __, _n, sprintf } from '@wordpress/i18n';
+import { __, _x, _n, sprintf } from '@wordpress/i18n';
import { useState } from '@wordpress/element';
import {
Button,
@@ -67,8 +67,8 @@ const deletePostAction: Action< Post > = {
items.length
)
: sprintf(
- // translators: %s: The template or template part's titles
- __( 'Delete "%s"?' ),
+ // translators: %s: The template or template part's title
+ _x( 'Delete "%s"?', 'template part' ),
getItemTitle( items[ 0 ] )
) }
diff --git a/packages/editor/src/dataviews/actions/duplicate-template-part.tsx b/packages/editor/src/dataviews/actions/duplicate-template-part.tsx
index fa3cf39ba76268..95e7e6bb672fcc 100644
--- a/packages/editor/src/dataviews/actions/duplicate-template-part.tsx
+++ b/packages/editor/src/dataviews/actions/duplicate-template-part.tsx
@@ -2,7 +2,7 @@
* WordPress dependencies
*/
import { useDispatch } from '@wordpress/data';
-import { __, sprintf, _x } from '@wordpress/i18n';
+import { _x, sprintf } from '@wordpress/i18n';
import { store as noticesStore } from '@wordpress/notices';
import { useMemo } from '@wordpress/element';
// @ts-ignore
@@ -42,7 +42,7 @@ const duplicateTemplatePart: Action< TemplatePart > = {
createSuccessNotice(
sprintf(
// translators: %s: The new template part's title e.g. 'Call to action (copy)'.
- __( '"%s" duplicated.' ),
+ _x( '"%s" duplicated.', 'template part' ),
getItemTitle( item )
),
{ type: 'snackbar', id: 'edit-site-patterns-success' }
@@ -55,7 +55,7 @@ const duplicateTemplatePart: Action< TemplatePart > = {
defaultArea={ item.area }
defaultTitle={ sprintf(
/* translators: %s: Existing template part title */
- __( '%s (Copy)' ),
+ _x( '%s (Copy)', 'template part' ),
getItemTitle( item )
) }
onCreate={ onTemplatePartSuccess }
diff --git a/packages/editor/src/hooks/pattern-overrides.js b/packages/editor/src/hooks/pattern-overrides.js
index 6f81f368351f38..8882856a89e0d9 100644
--- a/packages/editor/src/hooks/pattern-overrides.js
+++ b/packages/editor/src/hooks/pattern-overrides.js
@@ -6,7 +6,7 @@ import { privateApis as patternsPrivateApis } from '@wordpress/patterns';
import { createHigherOrderComponent } from '@wordpress/compose';
import { useBlockEditingMode } from '@wordpress/block-editor';
import { useSelect } from '@wordpress/data';
-import { store as blocksStore } from '@wordpress/blocks';
+import { getBlockBindingsSource } from '@wordpress/blocks';
/**
* Internal dependencies
@@ -58,7 +58,6 @@ function ControlsWithStoreSubscription( props ) {
const blockEditingMode = useBlockEditingMode();
const { hasPatternOverridesSource, isEditingSyncedPattern } = useSelect(
( select ) => {
- const { getBlockBindingsSource } = unlock( select( blocksStore ) );
const { getCurrentPostType, getEditedPostAttribute } =
select( editorStore );
diff --git a/packages/editor/src/private-apis.js b/packages/editor/src/private-apis.js
index d4a5c3eebbb4ce..f9a6d4d17904ee 100644
--- a/packages/editor/src/private-apis.js
+++ b/packages/editor/src/private-apis.js
@@ -23,10 +23,7 @@ import {
mergeBaseAndUserConfigs,
GlobalStylesProvider,
} from './components/global-styles-provider';
-import {
- registerCoreBlockBindingsSources,
- bootstrapBlockBindingsSourcesFromServer,
-} from './bindings/api';
+import { registerCoreBlockBindingsSources } from './bindings/api';
const { store: interfaceStore, ...remainingInterfaceApis } = interfaceApis;
@@ -47,7 +44,6 @@ lock( privateApis, {
ViewMoreMenuGroup,
ResizableEditor,
registerCoreBlockBindingsSources,
- bootstrapBlockBindingsSourcesFromServer,
// This is a temporary private API while we're updating the site editor to use EditorProvider.
interfaceStore,
diff --git a/packages/editor/src/store/actions.js b/packages/editor/src/store/actions.js
index 59faa6b5b73624..1a6a01471a39b9 100644
--- a/packages/editor/src/store/actions.js
+++ b/packages/editor/src/store/actions.js
@@ -12,7 +12,11 @@ import {
import { store as noticesStore } from '@wordpress/notices';
import { store as coreStore } from '@wordpress/core-data';
import { store as blockEditorStore } from '@wordpress/block-editor';
-import { applyFilters } from '@wordpress/hooks';
+import {
+ applyFilters,
+ applyFiltersAsync,
+ doActionAsync,
+} from '@wordpress/hooks';
import { store as preferencesStore } from '@wordpress/preferences';
import { __ } from '@wordpress/i18n';
@@ -184,7 +188,7 @@ export const savePost =
}
const previousRecord = select.getCurrentPost();
- const edits = {
+ let edits = {
id: previousRecord.id,
...registry
.select( coreStore )
@@ -199,9 +203,9 @@ export const savePost =
let error = false;
try {
- error = await applyFilters(
- 'editor.__unstablePreSavePost',
- Promise.resolve( false ),
+ edits = await applyFiltersAsync(
+ 'editor.preSavePost',
+ edits,
options
);
} catch ( err ) {
@@ -236,14 +240,29 @@ export const savePost =
);
}
+ // Run the hook with legacy unstable name for backward compatibility
if ( ! error ) {
- await applyFilters(
- 'editor.__unstableSavePost',
- Promise.resolve(),
- options
- ).catch( ( err ) => {
+ try {
+ await applyFilters(
+ 'editor.__unstableSavePost',
+ Promise.resolve(),
+ options
+ );
+ } catch ( err ) {
error = err;
- } );
+ }
+ }
+
+ if ( ! error ) {
+ try {
+ await doActionAsync(
+ 'editor.savePost',
+ { id: previousRecord.id },
+ options
+ );
+ } catch ( err ) {
+ error = err;
+ }
}
dispatch( { type: 'REQUEST_POST_UPDATE_FINISH', options } );
diff --git a/packages/editor/src/store/private-actions.js b/packages/editor/src/store/private-actions.js
index 358d00af396bbb..74c1f1ea100b37 100644
--- a/packages/editor/src/store/private-actions.js
+++ b/packages/editor/src/store/private-actions.js
@@ -2,7 +2,7 @@
* WordPress dependencies
*/
import { store as coreStore } from '@wordpress/core-data';
-import { __, sprintf } from '@wordpress/i18n';
+import { __, _x, sprintf } from '@wordpress/i18n';
import { store as noticesStore } from '@wordpress/notices';
import { store as blockEditorStore } from '@wordpress/block-editor';
import { store as preferencesStore } from '@wordpress/preferences';
@@ -410,8 +410,8 @@ export const removeTemplates =
decodeEntities( title )
)
: sprintf(
- /* translators: The template/part's name. */
- __( '"%s" deleted.' ),
+ /* translators: %s: The template/part's name. */
+ _x( '"%s" deleted.', 'template part' ),
decodeEntities( title )
);
} else {
diff --git a/packages/element/CHANGELOG.md b/packages/element/CHANGELOG.md
index ccfa6a6021d71f..ee63e4b5e70fa8 100644
--- a/packages/element/CHANGELOG.md
+++ b/packages/element/CHANGELOG.md
@@ -2,6 +2,8 @@
## Unreleased
+## 6.8.0 (2024-09-19)
+
## 6.7.0 (2024-09-05)
## 6.6.0 (2024-08-21)
diff --git a/packages/element/package.json b/packages/element/package.json
index d6062abe6ecf61..5696a8e00f8964 100644
--- a/packages/element/package.json
+++ b/packages/element/package.json
@@ -1,6 +1,6 @@
{
"name": "@wordpress/element",
- "version": "6.7.0",
+ "version": "6.8.1",
"description": "Element React module for WordPress.",
"author": "The WordPress Contributors",
"license": "GPL-2.0-or-later",
diff --git a/packages/env/CHANGELOG.md b/packages/env/CHANGELOG.md
index b3dcf350596a0f..0b32fd808d9603 100644
--- a/packages/env/CHANGELOG.md
+++ b/packages/env/CHANGELOG.md
@@ -2,6 +2,8 @@
## Unreleased
+## 10.8.0 (2024-09-19)
+
## 10.7.0 (2024-09-05)
## 10.6.0 (2024-08-21)
diff --git a/packages/env/package.json b/packages/env/package.json
index 7f3b15b1e15622..a1a96996d97d54 100644
--- a/packages/env/package.json
+++ b/packages/env/package.json
@@ -1,6 +1,6 @@
{
"name": "@wordpress/env",
- "version": "10.7.0",
+ "version": "10.8.1",
"description": "A zero-config, self contained local WordPress environment for development and testing.",
"author": "The WordPress Contributors",
"license": "GPL-2.0-or-later",
diff --git a/packages/escape-html/CHANGELOG.md b/packages/escape-html/CHANGELOG.md
index 490cbdb184f5b1..c13edebda4480a 100644
--- a/packages/escape-html/CHANGELOG.md
+++ b/packages/escape-html/CHANGELOG.md
@@ -2,6 +2,8 @@
## Unreleased
+## 3.8.0 (2024-09-19)
+
## 3.7.0 (2024-09-05)
## 3.6.0 (2024-08-21)
diff --git a/packages/escape-html/package.json b/packages/escape-html/package.json
index 8dd1a66cf2d5e9..947530549391e3 100644
--- a/packages/escape-html/package.json
+++ b/packages/escape-html/package.json
@@ -1,6 +1,6 @@
{
"name": "@wordpress/escape-html",
- "version": "3.7.0",
+ "version": "3.8.1",
"description": "Escape HTML utils.",
"author": "The WordPress Contributors",
"license": "GPL-2.0-or-later",
diff --git a/packages/eslint-plugin/CHANGELOG.md b/packages/eslint-plugin/CHANGELOG.md
index 14d44d21acda9f..725872f8bf1011 100644
--- a/packages/eslint-plugin/CHANGELOG.md
+++ b/packages/eslint-plugin/CHANGELOG.md
@@ -2,6 +2,8 @@
## Unreleased
+## 21.1.0 (2024-09-19)
+
## 21.0.0 (2024-09-05)
### Breaking Changes
diff --git a/packages/eslint-plugin/package.json b/packages/eslint-plugin/package.json
index 73996275c9154f..7a9815a1e21ade 100644
--- a/packages/eslint-plugin/package.json
+++ b/packages/eslint-plugin/package.json
@@ -1,6 +1,6 @@
{
"name": "@wordpress/eslint-plugin",
- "version": "21.0.0",
+ "version": "21.1.2",
"description": "ESLint plugin for WordPress development.",
"author": "The WordPress Contributors",
"license": "GPL-2.0-or-later",
diff --git a/packages/fields/package.json b/packages/fields/package.json
index 2e417c9f4de570..5ec892cd95c142 100644
--- a/packages/fields/package.json
+++ b/packages/fields/package.json
@@ -1,6 +1,6 @@
{
"name": "@wordpress/fields",
- "version": "0.0.1",
+ "version": "0.0.15",
"description": "DataViews is a component that provides an API to render datasets using different types of layouts (table, grid, list, etc.).",
"author": "The WordPress Contributors",
"license": "GPL-2.0-or-later",
@@ -9,7 +9,6 @@
"gutenberg",
"dataviews"
],
- "private": true,
"homepage": "https://github.com/WordPress/gutenberg/tree/HEAD/packages/fields/README.md",
"repository": {
"type": "git",
diff --git a/packages/fields/src/actions/base-post/duplicate-post.tsx b/packages/fields/src/actions/base-post/duplicate-post.tsx
index 0035a40c009342..9609058cad52a7 100644
--- a/packages/fields/src/actions/base-post/duplicate-post.tsx
+++ b/packages/fields/src/actions/base-post/duplicate-post.tsx
@@ -38,7 +38,7 @@ const duplicatePost: Action< BasePost > = {
...items[ 0 ],
title: sprintf(
/* translators: %s: Existing template title */
- __( '%s (Copy)' ),
+ _x( '%s (Copy)', 'template' ),
getItemTitle( items[ 0 ] )
),
} );
@@ -104,7 +104,7 @@ const duplicatePost: Action< BasePost > = {
createSuccessNotice(
sprintf(
- // translators: %s: Title of the created template e.g: "Category".
+ // translators: %s: Title of the created post or template, e.g: "Hello world".
__( '"%s" successfully created.' ),
decodeEntities( newItem.title?.rendered || item.title )
),
diff --git a/packages/fields/src/actions/common/view-post-revisions.tsx b/packages/fields/src/actions/common/view-post-revisions.tsx
index 617a5263a707d6..5e73d9765e091e 100644
--- a/packages/fields/src/actions/common/view-post-revisions.tsx
+++ b/packages/fields/src/actions/common/view-post-revisions.tsx
@@ -17,7 +17,7 @@ const viewPostRevisions: Action< Post > = {
const revisionsCount =
items[ 0 ]._links?.[ 'version-history' ]?.[ 0 ]?.count ?? 0;
return sprintf(
- /* translators: %s: number of revisions */
+ /* translators: %s: number of revisions. */
__( 'View revisions (%s)' ),
revisionsCount
);
diff --git a/packages/format-library/CHANGELOG.md b/packages/format-library/CHANGELOG.md
index c003ac587fcdfa..11a32094cffeab 100644
--- a/packages/format-library/CHANGELOG.md
+++ b/packages/format-library/CHANGELOG.md
@@ -2,6 +2,8 @@
## Unreleased
+## 5.8.0 (2024-09-19)
+
## 5.7.0 (2024-09-05)
## 5.6.0 (2024-08-21)
diff --git a/packages/format-library/package.json b/packages/format-library/package.json
index 780f513bdb0374..92c20564d96f53 100644
--- a/packages/format-library/package.json
+++ b/packages/format-library/package.json
@@ -1,6 +1,6 @@
{
"name": "@wordpress/format-library",
- "version": "5.7.0",
+ "version": "5.8.14",
"description": "Format library for the WordPress editor.",
"author": "The WordPress Contributors",
"license": "GPL-2.0-or-later",
diff --git a/packages/hooks/CHANGELOG.md b/packages/hooks/CHANGELOG.md
index de9e11c38fe905..060e061b5c2843 100644
--- a/packages/hooks/CHANGELOG.md
+++ b/packages/hooks/CHANGELOG.md
@@ -2,6 +2,12 @@
## Unreleased
+### New Features
+
+- added new `doActionAsync` and `applyFiltersAsync` functions to run hooks in async mode ([#64204](https://github.com/WordPress/gutenberg/pull/64204)).
+
+## 4.8.0 (2024-09-19)
+
## 4.7.0 (2024-09-05)
## 4.6.0 (2024-08-21)
diff --git a/packages/hooks/README.md b/packages/hooks/README.md
index 3e9897c79952cd..f80d2e63af37ba 100644
--- a/packages/hooks/README.md
+++ b/packages/hooks/README.md
@@ -41,7 +41,9 @@ One notable difference between the JS and PHP hooks API is that in the JS versio
- `removeAllActions( 'hookName' )`
- `removeAllFilters( 'hookName' )`
- `doAction( 'hookName', arg1, arg2, moreArgs, finalArg )`
+- `doActionAsync( 'hookName', arg1, arg2, moreArgs, finalArg )`
- `applyFilters( 'hookName', content, arg1, arg2, moreArgs, finalArg )`
+- `applyFiltersAsync( 'hookName', content, arg1, arg2, moreArgs, finalArg )`
- `doingAction( 'hookName' )`
- `doingFilter( 'hookName' )`
- `didAction( 'hookName' )`
diff --git a/packages/hooks/package.json b/packages/hooks/package.json
index 51074af0d239b0..c3f0c2535004b9 100644
--- a/packages/hooks/package.json
+++ b/packages/hooks/package.json
@@ -1,6 +1,6 @@
{
"name": "@wordpress/hooks",
- "version": "4.7.0",
+ "version": "4.8.2",
"description": "WordPress hooks library.",
"author": "The WordPress Contributors",
"license": "GPL-2.0-or-later",
diff --git a/packages/hooks/src/createCurrentHook.js b/packages/hooks/src/createCurrentHook.js
index 634901fe55f63a..3ada0322496004 100644
--- a/packages/hooks/src/createCurrentHook.js
+++ b/packages/hooks/src/createCurrentHook.js
@@ -11,11 +11,8 @@
function createCurrentHook( hooks, storeKey ) {
return function currentHook() {
const hooksStore = hooks[ storeKey ];
-
- return (
- hooksStore.__current[ hooksStore.__current.length - 1 ]?.name ??
- null
- );
+ const currentArray = Array.from( hooksStore.__current );
+ return currentArray.at( -1 )?.name ?? null;
};
}
diff --git a/packages/hooks/src/createDoingHook.js b/packages/hooks/src/createDoingHook.js
index 652ab06b4ba728..9fccf38171f332 100644
--- a/packages/hooks/src/createDoingHook.js
+++ b/packages/hooks/src/createDoingHook.js
@@ -24,13 +24,13 @@ function createDoingHook( hooks, storeKey ) {
// If the hookName was not passed, check for any current hook.
if ( 'undefined' === typeof hookName ) {
- return 'undefined' !== typeof hooksStore.__current[ 0 ];
+ return hooksStore.__current.size > 0;
}
- // Return the __current hook.
- return hooksStore.__current[ 0 ]
- ? hookName === hooksStore.__current[ 0 ].name
- : false;
+ // Find if the `hookName` hook is in `__current`.
+ return Array.from( hooksStore.__current ).some(
+ ( hook ) => hook.name === hookName
+ );
};
}
diff --git a/packages/hooks/src/createHooks.js b/packages/hooks/src/createHooks.js
index 361383a3a97fc9..1f9b1a8206b020 100644
--- a/packages/hooks/src/createHooks.js
+++ b/packages/hooks/src/createHooks.js
@@ -20,11 +20,11 @@ export class _Hooks {
constructor() {
/** @type {import('.').Store} actions */
this.actions = Object.create( null );
- this.actions.__current = [];
+ this.actions.__current = new Set();
/** @type {import('.').Store} filters */
this.filters = Object.create( null );
- this.filters.__current = [];
+ this.filters.__current = new Set();
this.addAction = createAddHook( this, 'actions' );
this.addFilter = createAddHook( this, 'filters' );
@@ -34,8 +34,10 @@ export class _Hooks {
this.hasFilter = createHasHook( this, 'filters' );
this.removeAllActions = createRemoveHook( this, 'actions', true );
this.removeAllFilters = createRemoveHook( this, 'filters', true );
- this.doAction = createRunHook( this, 'actions' );
- this.applyFilters = createRunHook( this, 'filters', true );
+ this.doAction = createRunHook( this, 'actions', false, false );
+ this.doActionAsync = createRunHook( this, 'actions', false, true );
+ this.applyFilters = createRunHook( this, 'filters', true, false );
+ this.applyFiltersAsync = createRunHook( this, 'filters', true, true );
this.currentAction = createCurrentHook( this, 'actions' );
this.currentFilter = createCurrentHook( this, 'filters' );
this.doingAction = createDoingHook( this, 'actions' );
diff --git a/packages/hooks/src/createRunHook.js b/packages/hooks/src/createRunHook.js
index c2bf6fd187ce08..f2a56dbdc0d717 100644
--- a/packages/hooks/src/createRunHook.js
+++ b/packages/hooks/src/createRunHook.js
@@ -3,15 +3,15 @@
* registered to a hook of the specified type, optionally returning the final
* value of the call chain.
*
- * @param {import('.').Hooks} hooks Hooks instance.
+ * @param {import('.').Hooks} hooks Hooks instance.
* @param {import('.').StoreKey} storeKey
- * @param {boolean} [returnFirstArg=false] Whether each hook callback is expected to
- * return its first argument.
+ * @param {boolean} returnFirstArg Whether each hook callback is expected to return its first argument.
+ * @param {boolean} async Whether the hook callback should be run asynchronously
*
* @return {(hookName:string, ...args: unknown[]) => undefined|unknown} Function that runs hook callbacks.
*/
-function createRunHook( hooks, storeKey, returnFirstArg = false ) {
- return function runHooks( hookName, ...args ) {
+function createRunHook( hooks, storeKey, returnFirstArg, async ) {
+ return function runHook( hookName, ...args ) {
const hooksStore = hooks[ storeKey ];
if ( ! hooksStore[ hookName ] ) {
@@ -42,26 +42,43 @@ function createRunHook( hooks, storeKey, returnFirstArg = false ) {
currentIndex: 0,
};
- hooksStore.__current.push( hookInfo );
-
- while ( hookInfo.currentIndex < handlers.length ) {
- const handler = handlers[ hookInfo.currentIndex ];
-
- const result = handler.callback.apply( null, args );
- if ( returnFirstArg ) {
- args[ 0 ] = result;
+ async function asyncRunner() {
+ try {
+ hooksStore.__current.add( hookInfo );
+ let result = returnFirstArg ? args[ 0 ] : undefined;
+ while ( hookInfo.currentIndex < handlers.length ) {
+ const handler = handlers[ hookInfo.currentIndex ];
+ result = await handler.callback.apply( null, args );
+ if ( returnFirstArg ) {
+ args[ 0 ] = result;
+ }
+ hookInfo.currentIndex++;
+ }
+ return returnFirstArg ? result : undefined;
+ } finally {
+ hooksStore.__current.delete( hookInfo );
}
-
- hookInfo.currentIndex++;
}
- hooksStore.__current.pop();
-
- if ( returnFirstArg ) {
- return args[ 0 ];
+ function syncRunner() {
+ try {
+ hooksStore.__current.add( hookInfo );
+ let result = returnFirstArg ? args[ 0 ] : undefined;
+ while ( hookInfo.currentIndex < handlers.length ) {
+ const handler = handlers[ hookInfo.currentIndex ];
+ result = handler.callback.apply( null, args );
+ if ( returnFirstArg ) {
+ args[ 0 ] = result;
+ }
+ hookInfo.currentIndex++;
+ }
+ return returnFirstArg ? result : undefined;
+ } finally {
+ hooksStore.__current.delete( hookInfo );
+ }
}
- return undefined;
+ return ( async ? asyncRunner : syncRunner )();
};
}
diff --git a/packages/hooks/src/index.js b/packages/hooks/src/index.js
index 653a9537145d91..1d13397e406c6b 100644
--- a/packages/hooks/src/index.js
+++ b/packages/hooks/src/index.js
@@ -25,7 +25,7 @@ import createHooks from './createHooks';
*/
/**
- * @typedef {Record
& {__current: Current[]}} Store
+ * @typedef {Record & {__current: Set}} Store
*/
/**
@@ -48,7 +48,9 @@ const {
removeAllActions,
removeAllFilters,
doAction,
+ doActionAsync,
applyFilters,
+ applyFiltersAsync,
currentAction,
currentFilter,
doingAction,
@@ -70,7 +72,9 @@ export {
removeAllActions,
removeAllFilters,
doAction,
+ doActionAsync,
applyFilters,
+ applyFiltersAsync,
currentAction,
currentFilter,
doingAction,
diff --git a/packages/hooks/src/test/index.test.js b/packages/hooks/src/test/index.test.js
index 9b7eb3b8e0e223..5fdaf5fc7207a1 100644
--- a/packages/hooks/src/test/index.test.js
+++ b/packages/hooks/src/test/index.test.js
@@ -12,7 +12,9 @@ import {
removeAllActions,
removeAllFilters,
doAction,
+ doActionAsync,
applyFilters,
+ applyFiltersAsync,
currentAction,
currentFilter,
doingAction,
@@ -943,3 +945,151 @@ test( 'checking hasFilter with named callbacks and removeAllActions', () => {
expect( hasFilter( 'test.filter', 'my_callback' ) ).toBe( false );
expect( hasFilter( 'test.filter', 'my_second_callback' ) ).toBe( false );
} );
+
+describe( 'async filter', () => {
+ test( 'runs all registered handlers', async () => {
+ addFilter( 'test.async.filter', 'callback_plus1', ( value ) => {
+ return new Promise( ( r ) =>
+ setTimeout( () => r( value + 1 ), 10 )
+ );
+ } );
+ addFilter( 'test.async.filter', 'callback_times2', ( value ) => {
+ return new Promise( ( r ) =>
+ setTimeout( () => r( value * 2 ), 10 )
+ );
+ } );
+
+ expect( await applyFiltersAsync( 'test.async.filter', 2 ) ).toBe( 6 );
+ } );
+
+ test( 'aborts when handler throws an error', async () => {
+ const sqrt = jest.fn( async ( value ) => {
+ if ( value < 0 ) {
+ throw new Error( 'cannot pass negative value to sqrt' );
+ }
+ return Math.sqrt( value );
+ } );
+
+ const plus1 = jest.fn( async ( value ) => {
+ return value + 1;
+ } );
+
+ addFilter( 'test.async.filter', 'callback_sqrt', sqrt );
+ addFilter( 'test.async.filter', 'callback_plus1', plus1 );
+
+ await expect(
+ applyFiltersAsync( 'test.async.filter', -1 )
+ ).rejects.toThrow( 'cannot pass negative value to sqrt' );
+ expect( sqrt ).toHaveBeenCalledTimes( 1 );
+ expect( plus1 ).not.toHaveBeenCalled();
+ } );
+
+ test( 'is correctly tracked by doingFilter and didFilter', async () => {
+ addFilter( 'test.async.filter', 'callback_doing', async ( value ) => {
+ await new Promise( ( r ) => setTimeout( () => r(), 10 ) );
+ expect( doingFilter( 'test.async.filter' ) ).toBe( true );
+ return value;
+ } );
+
+ expect( doingFilter( 'test.async.filter' ) ).toBe( false );
+ expect( didFilter( 'test.async.filter' ) ).toBe( 0 );
+ await applyFiltersAsync( 'test.async.filter', 0 );
+ expect( doingFilter( 'test.async.filter' ) ).toBe( false );
+ expect( didFilter( 'test.async.filter' ) ).toBe( 1 );
+ } );
+
+ test( 'is correctly tracked when multiple filters run at once', async () => {
+ addFilter( 'test.async.filter1', 'callback_doing', async ( value ) => {
+ await new Promise( ( r ) => setTimeout( () => r(), 10 ) );
+ expect( doingFilter( 'test.async.filter1' ) ).toBe( true );
+ await new Promise( ( r ) => setTimeout( () => r(), 10 ) );
+ return value;
+ } );
+ addFilter( 'test.async.filter2', 'callback_doing', async ( value ) => {
+ await new Promise( ( r ) => setTimeout( () => r(), 10 ) );
+ expect( doingFilter( 'test.async.filter2' ) ).toBe( true );
+ await new Promise( ( r ) => setTimeout( () => r(), 10 ) );
+ return value;
+ } );
+
+ await Promise.all( [
+ applyFiltersAsync( 'test.async.filter1', 0 ),
+ applyFiltersAsync( 'test.async.filter2', 0 ),
+ ] );
+ } );
+} );
+
+describe( 'async action', () => {
+ test( 'runs all registered handlers sequentially', async () => {
+ const outputs = [];
+ const action1 = async () => {
+ outputs.push( 1 );
+ await new Promise( ( r ) => setTimeout( () => r(), 10 ) );
+ outputs.push( 2 );
+ };
+
+ const action2 = async () => {
+ outputs.push( 3 );
+ await new Promise( ( r ) => setTimeout( () => r(), 10 ) );
+ outputs.push( 4 );
+ };
+
+ addAction( 'test.async.action', 'action1', action1 );
+ addAction( 'test.async.action', 'action2', action2 );
+
+ await doActionAsync( 'test.async.action' );
+ expect( outputs ).toEqual( [ 1, 2, 3, 4 ] );
+ } );
+
+ test( 'aborts when handler throws an error', async () => {
+ const outputs = [];
+ const action1 = async () => {
+ throw new Error( 'aborting' );
+ };
+
+ const action2 = async () => {
+ outputs.push( 3 );
+ await new Promise( ( r ) => setTimeout( () => r(), 10 ) );
+ outputs.push( 4 );
+ };
+
+ addAction( 'test.async.action', 'action1', action1 );
+ addAction( 'test.async.action', 'action2', action2 );
+
+ await expect( doActionAsync( 'test.async.action' ) ).rejects.toThrow(
+ 'aborting'
+ );
+ expect( outputs ).toEqual( [] );
+ } );
+
+ test( 'is correctly tracked by doingAction and didAction', async () => {
+ addAction( 'test.async.action', 'callback_doing', async () => {
+ await new Promise( ( r ) => setTimeout( () => r(), 10 ) );
+ expect( doingAction( 'test.async.action' ) ).toBe( true );
+ } );
+
+ expect( doingAction( 'test.async.action' ) ).toBe( false );
+ expect( didAction( 'test.async.action' ) ).toBe( 0 );
+ await doActionAsync( 'test.async.action', 0 );
+ expect( doingAction( 'test.async.action' ) ).toBe( false );
+ expect( didAction( 'test.async.action' ) ).toBe( 1 );
+ } );
+
+ test( 'is correctly tracked when multiple actions run at once', async () => {
+ addAction( 'test.async.action1', 'callback_doing', async () => {
+ await new Promise( ( r ) => setTimeout( () => r(), 10 ) );
+ expect( doingAction( 'test.async.action1' ) ).toBe( true );
+ await new Promise( ( r ) => setTimeout( () => r(), 10 ) );
+ } );
+ addAction( 'test.async.action2', 'callback_doing', async () => {
+ await new Promise( ( r ) => setTimeout( () => r(), 10 ) );
+ expect( doingAction( 'test.async.action2' ) ).toBe( true );
+ await new Promise( ( r ) => setTimeout( () => r(), 10 ) );
+ } );
+
+ await Promise.all( [
+ doActionAsync( 'test.async.action1', 0 ),
+ doActionAsync( 'test.async.action2', 0 ),
+ ] );
+ } );
+} );
diff --git a/packages/html-entities/CHANGELOG.md b/packages/html-entities/CHANGELOG.md
index 6887a70b391f98..e49251a8653624 100644
--- a/packages/html-entities/CHANGELOG.md
+++ b/packages/html-entities/CHANGELOG.md
@@ -2,6 +2,8 @@
## Unreleased
+## 4.8.0 (2024-09-19)
+
## 4.7.0 (2024-09-05)
## 4.6.0 (2024-08-21)
diff --git a/packages/html-entities/package.json b/packages/html-entities/package.json
index 30e3c1728307d7..62b10d4eb79c5a 100644
--- a/packages/html-entities/package.json
+++ b/packages/html-entities/package.json
@@ -1,6 +1,6 @@
{
"name": "@wordpress/html-entities",
- "version": "4.7.0",
+ "version": "4.8.1",
"description": "HTML entity utilities for WordPress.",
"author": "The WordPress Contributors",
"license": "GPL-2.0-or-later",
diff --git a/packages/i18n/CHANGELOG.md b/packages/i18n/CHANGELOG.md
index d2bf7ad7ea7eb3..928a0d6d0ef74b 100644
--- a/packages/i18n/CHANGELOG.md
+++ b/packages/i18n/CHANGELOG.md
@@ -2,6 +2,8 @@
## Unreleased
+## 5.8.0 (2024-09-19)
+
## 5.7.0 (2024-09-05)
## 5.6.0 (2024-08-21)
diff --git a/packages/i18n/package.json b/packages/i18n/package.json
index fb3c488c1a4dea..1d8d483c9070d1 100644
--- a/packages/i18n/package.json
+++ b/packages/i18n/package.json
@@ -1,6 +1,6 @@
{
"name": "@wordpress/i18n",
- "version": "5.7.0",
+ "version": "5.8.2",
"description": "WordPress internationalization (i18n) library.",
"author": "The WordPress Contributors",
"license": "GPL-2.0-or-later",
diff --git a/packages/icons/CHANGELOG.md b/packages/icons/CHANGELOG.md
index a59229e1b0eba7..d43faea8e3ba39 100644
--- a/packages/icons/CHANGELOG.md
+++ b/packages/icons/CHANGELOG.md
@@ -2,9 +2,12 @@
## Unreleased
+## 10.8.0 (2024-09-19)
+
### New Features
- Add new `bell` and `bell-unread` icons.
+- Add new `arrowUpLeft` and `arrowDownRight` icons.
## 10.7.0 (2024-09-05)
diff --git a/packages/icons/package.json b/packages/icons/package.json
index ab23fef8eff667..d8c7cf6cc84806 100644
--- a/packages/icons/package.json
+++ b/packages/icons/package.json
@@ -1,6 +1,6 @@
{
"name": "@wordpress/icons",
- "version": "10.7.0",
+ "version": "10.8.2",
"description": "WordPress Icons package, based on dashicon.",
"author": "The WordPress Contributors",
"license": "GPL-2.0-or-later",
diff --git a/packages/icons/src/index.js b/packages/icons/src/index.js
index 9ab41bd3620279..63d06471370d63 100644
--- a/packages/icons/src/index.js
+++ b/packages/icons/src/index.js
@@ -10,9 +10,11 @@ export { default as alignNone } from './library/align-none';
export { default as alignRight } from './library/align-right';
export { default as archive } from './library/archive';
export { default as arrowDown } from './library/arrow-down';
+export { default as arrowDownRight } from './library/arrow-down-right';
export { default as arrowLeft } from './library/arrow-left';
export { default as arrowRight } from './library/arrow-right';
export { default as arrowUp } from './library/arrow-up';
+export { default as arrowUpLeft } from './library/arrow-up-left';
export { default as atSymbol } from './library/at-symbol';
export { default as aspectRatio } from './library/aspect-ratio';
export { default as audio } from './library/audio';
diff --git a/packages/icons/src/library/arrow-down-right.js b/packages/icons/src/library/arrow-down-right.js
new file mode 100644
index 00000000000000..3755b63873cefc
--- /dev/null
+++ b/packages/icons/src/library/arrow-down-right.js
@@ -0,0 +1,12 @@
+/**
+ * WordPress dependencies
+ */
+import { SVG, Path } from '@wordpress/primitives';
+
+const arrowDownRight = (
+
+);
+
+export default arrowDownRight;
diff --git a/packages/icons/src/library/arrow-up-left.js b/packages/icons/src/library/arrow-up-left.js
new file mode 100644
index 00000000000000..1b3686f6ec1e62
--- /dev/null
+++ b/packages/icons/src/library/arrow-up-left.js
@@ -0,0 +1,12 @@
+/**
+ * WordPress dependencies
+ */
+import { SVG, Path } from '@wordpress/primitives';
+
+const arrowUpLeft = (
+
+);
+
+export default arrowUpLeft;
diff --git a/packages/interactivity-router/CHANGELOG.md b/packages/interactivity-router/CHANGELOG.md
index 00c47f3ed074b6..1f8e91dec05474 100644
--- a/packages/interactivity-router/CHANGELOG.md
+++ b/packages/interactivity-router/CHANGELOG.md
@@ -2,6 +2,8 @@
## Unreleased
+## 2.8.0 (2024-09-19)
+
## 2.7.0 (2024-09-05)
## 2.6.0 (2024-08-21)
diff --git a/packages/interactivity-router/package.json b/packages/interactivity-router/package.json
index 7282ee0b00f9c9..32b2d050d1fd6f 100644
--- a/packages/interactivity-router/package.json
+++ b/packages/interactivity-router/package.json
@@ -1,6 +1,6 @@
{
"name": "@wordpress/interactivity-router",
- "version": "2.7.0",
+ "version": "2.8.6",
"description": "Package that exposes state and actions from the `core/router` store, part of the Interactivity API.",
"author": "The WordPress Contributors",
"license": "GPL-2.0-or-later",
diff --git a/packages/interactivity-router/src/index.ts b/packages/interactivity-router/src/index.ts
index 3bd44c7aebd71f..b2e8e2d4395dcd 100644
--- a/packages/interactivity-router/src/index.ts
+++ b/packages/interactivity-router/src/index.ts
@@ -221,11 +221,6 @@ interface Store {
navigation: {
hasStarted: boolean;
hasFinished: boolean;
- message: string;
- texts?: {
- loading?: string;
- loaded?: string;
- };
};
};
actions: {
@@ -240,7 +235,6 @@ export const { state, actions } = store< Store >( 'core/router', {
navigation: {
hasStarted: false,
hasFinished: false,
- message: '',
},
},
actions: {
@@ -403,10 +397,16 @@ function a11ySpeak( messageKey: keyof typeof navigationTexts ) {
} catch {}
} else {
// Fallback to localized strings from Interactivity API state.
+ // @todo This block is for Core < 6.7.0. Remove when support is dropped.
+
+ // @ts-expect-error
if ( state.navigation.texts?.loading ) {
+ // @ts-expect-error
navigationTexts.loading = state.navigation.texts.loading;
}
+ // @ts-expect-error
if ( state.navigation.texts?.loaded ) {
+ // @ts-expect-error
navigationTexts.loaded = state.navigation.texts.loaded;
}
}
@@ -414,19 +414,11 @@ function a11ySpeak( messageKey: keyof typeof navigationTexts ) {
const message = navigationTexts[ messageKey ];
- if ( globalThis.IS_GUTENBERG_PLUGIN ) {
- import( '@wordpress/a11y' ).then(
- ( { speak } ) => speak( message ),
- // Ignore failures to load the a11y module.
- () => {}
- );
- } else {
- state.navigation.message =
- // Announce that the page has been loaded. If the message is the
- // same, we use a no-break space similar to the @wordpress/a11y
- // package: https://github.com/WordPress/gutenberg/blob/c395242b8e6ee20f8b06c199e4fc2920d7018af1/packages/a11y/src/filter-message.js#L20-L26
- message + ( state.navigation.message === message ? '\u00A0' : '' );
- }
+ import( '@wordpress/a11y' ).then(
+ ( { speak } ) => speak( message ),
+ // Ignore failures to load the a11y module.
+ () => {}
+ );
}
// Add click and prefetch to all links.
diff --git a/packages/interactivity/CHANGELOG.md b/packages/interactivity/CHANGELOG.md
index 6e8fbc6b74e08d..904892788ef7cd 100644
--- a/packages/interactivity/CHANGELOG.md
+++ b/packages/interactivity/CHANGELOG.md
@@ -2,6 +2,25 @@
## Unreleased
+### Bug Fixes
+
+- Fix reactivity of undefined objects and arrays added with `deepMerge()` ([#66183](https://github.com/WordPress/gutenberg/pull/66183)).
+
+## 6.10.0 (2024-10-16)
+
+### Internal
+
+- Upgrade preact libraries [#66008](https://github.com/WordPress/gutenberg/pull/66008).
+
+### Bug Fixes
+
+- Fix an issue where "default" could not be used as a directive suffix ([#65815](https://github.com/WordPress/gutenberg/pull/65815)).
+- Correctly handle lazily added, deeply nested properties with `deepMerge()` ([#65465](https://github.com/WordPress/gutenberg/pull/65465)).
+
+## 6.9.0 (2024-10-03)
+
+## 6.8.0 (2024-09-19)
+
### Enhancements
- Refactor internal context proxies implementation ([#64713](https://github.com/WordPress/gutenberg/pull/64713)).
diff --git a/packages/interactivity/package.json b/packages/interactivity/package.json
index 6be9f3c4a0d7d8..ca05aa0995a331 100644
--- a/packages/interactivity/package.json
+++ b/packages/interactivity/package.json
@@ -1,6 +1,6 @@
{
"name": "@wordpress/interactivity",
- "version": "6.7.0",
+ "version": "6.8.5",
"description": "Package that provides a standard and simple way to handle the frontend interactivity of Gutenberg blocks.",
"author": "The WordPress Contributors",
"license": "GPL-2.0-or-later",
diff --git a/packages/interactivity/src/directives.tsx b/packages/interactivity/src/directives.tsx
index cde39d830499a2..31e07d095e0a4c 100644
--- a/packages/interactivity/src/directives.tsx
+++ b/packages/interactivity/src/directives.tsx
@@ -18,7 +18,14 @@ import {
splitTask,
isPlainObject,
} from './utils';
-import { directive, getEvaluate, type DirectiveEntry } from './hooks';
+import {
+ directive,
+ getEvaluate,
+ isDefaultDirectiveSuffix,
+ isNonDefaultDirectiveSuffix,
+ type DirectiveCallback,
+ type DirectiveEntry,
+} from './hooks';
import { getScope } from './scopes';
import { proxifyState, proxifyContext, deepMerge } from './proxies';
@@ -86,11 +93,13 @@ const cssStringToObject = (
*
* @param type 'window' or 'document'
*/
-const getGlobalEventDirective = ( type: 'window' | 'document' ) => {
+const getGlobalEventDirective = (
+ type: 'window' | 'document'
+): DirectiveCallback => {
return ( { directives, evaluate } ) => {
directives[ `on-${ type }` ]
- .filter( ( { suffix } ) => suffix !== 'default' )
- .forEach( ( entry: DirectiveEntry ) => {
+ .filter( isNonDefaultDirectiveSuffix )
+ .forEach( ( entry ) => {
const eventName = entry.suffix.split( '--', 1 )[ 0 ];
useInit( () => {
const cb = ( event: Event ) => evaluate( entry, event );
@@ -108,11 +117,13 @@ const getGlobalEventDirective = ( type: 'window' | 'document' ) => {
*
* @param type 'window' or 'document'
*/
-const getGlobalAsyncEventDirective = ( type: 'window' | 'document' ) => {
+const getGlobalAsyncEventDirective = (
+ type: 'window' | 'document'
+): DirectiveCallback => {
return ( { directives, evaluate } ) => {
directives[ `on-async-${ type }` ]
- .filter( ( { suffix } ) => suffix !== 'default' )
- .forEach( ( entry: DirectiveEntry ) => {
+ .filter( isNonDefaultDirectiveSuffix )
+ .forEach( ( entry ) => {
const eventName = entry.suffix.split( '--', 1 )[ 0 ];
useInit( () => {
const cb = async ( event: Event ) => {
@@ -139,17 +150,20 @@ export default () => {
context: inheritedContext,
} ) => {
const { Provider } = inheritedContext;
- const defaultEntry = context.find(
- ( { suffix } ) => suffix === 'default'
- );
- const inheritedValue = useContext( inheritedContext );
+ const defaultEntry = context.find( isDefaultDirectiveSuffix );
+ const { client: inheritedClient, server: inheritedServer } =
+ useContext( inheritedContext );
const ns = defaultEntry!.namespace;
- const currentValue = useRef( proxifyState( ns, {} ) );
+ const client = useRef( proxifyState( ns, {} ) );
+ const server = useRef( proxifyState( ns, {}, { readOnly: true } ) );
// No change should be made if `defaultEntry` does not exist.
const contextStack = useMemo( () => {
- const result = { ...inheritedValue };
+ const result = {
+ client: { ...inheritedClient },
+ server: { ...inheritedServer },
+ };
if ( defaultEntry ) {
const { namespace, value } = defaultEntry;
// Check that the value is a JSON object. Send a console warning if not.
@@ -159,17 +173,22 @@ export default () => {
);
}
deepMerge(
- currentValue.current,
+ client.current,
deepClone( value ) as object,
false
);
- result[ namespace ] = proxifyContext(
- currentValue.current,
- inheritedValue[ namespace ]
+ deepMerge( server.current, deepClone( value ) as object );
+ result.client[ namespace ] = proxifyContext(
+ client.current,
+ inheritedClient[ namespace ]
+ );
+ result.server[ namespace ] = proxifyContext(
+ server.current,
+ inheritedServer[ namespace ]
);
}
return result;
- }, [ defaultEntry, inheritedValue ] );
+ }, [ defaultEntry, inheritedClient, inheritedServer ] );
return createElement( Provider, { value: contextStack }, children );
},
@@ -246,15 +265,13 @@ export default () => {
// data-wp-on--[event]
directive( 'on', ( { directives: { on }, element, evaluate } ) => {
const events = new Map< string, Set< DirectiveEntry > >();
- on.filter( ( { suffix } ) => suffix !== 'default' ).forEach(
- ( entry ) => {
- const event = entry.suffix.split( '--' )[ 0 ];
- if ( ! events.has( event ) ) {
- events.set( event, new Set< DirectiveEntry >() );
- }
- events.get( event )!.add( entry );
+ on.filter( isNonDefaultDirectiveSuffix ).forEach( ( entry ) => {
+ const event = entry.suffix.split( '--' )[ 0 ];
+ if ( ! events.has( event ) ) {
+ events.set( event, new Set< DirectiveEntry >() );
}
- );
+ events.get( event )!.add( entry );
+ } );
events.forEach( ( entries, eventType ) => {
const existingHandler = element.props[ `on${ eventType }` ];
@@ -298,7 +315,7 @@ export default () => {
( { directives: { 'on-async': onAsync }, element, evaluate } ) => {
const events = new Map< string, Set< DirectiveEntry > >();
onAsync
- .filter( ( { suffix } ) => suffix !== 'default' )
+ .filter( isNonDefaultDirectiveSuffix )
.forEach( ( entry ) => {
const event = entry.suffix.split( '--' )[ 0 ];
if ( ! events.has( event ) ) {
@@ -340,7 +357,7 @@ export default () => {
'class',
( { directives: { class: classNames }, element, evaluate } ) => {
classNames
- .filter( ( { suffix } ) => suffix !== 'default' )
+ .filter( isNonDefaultDirectiveSuffix )
.forEach( ( entry ) => {
const className = entry.suffix;
const result = evaluate( entry );
@@ -381,119 +398,112 @@ export default () => {
// data-wp-style--[style-prop]
directive( 'style', ( { directives: { style }, element, evaluate } ) => {
- style
- .filter( ( { suffix } ) => suffix !== 'default' )
- .forEach( ( entry ) => {
- const styleProp = entry.suffix;
- const result = evaluate( entry );
- element.props.style = element.props.style || {};
- if ( typeof element.props.style === 'string' ) {
- element.props.style = cssStringToObject(
- element.props.style
- );
- }
+ style.filter( isNonDefaultDirectiveSuffix ).forEach( ( entry ) => {
+ const styleProp = entry.suffix;
+ const result = evaluate( entry );
+ element.props.style = element.props.style || {};
+ if ( typeof element.props.style === 'string' ) {
+ element.props.style = cssStringToObject( element.props.style );
+ }
+ if ( ! result ) {
+ delete element.props.style[ styleProp ];
+ } else {
+ element.props.style[ styleProp ] = result;
+ }
+
+ useInit( () => {
+ /*
+ * This seems necessary because Preact doesn't change the styles on
+ * the hydration, so we have to do it manually. It doesn't need deps
+ * because it only needs to do it the first time.
+ */
if ( ! result ) {
- delete element.props.style[ styleProp ];
+ (
+ element.ref as RefObject< HTMLElement >
+ ).current!.style.removeProperty( styleProp );
} else {
- element.props.style[ styleProp ] = result;
+ ( element.ref as RefObject< HTMLElement > ).current!.style[
+ styleProp
+ ] = result;
}
-
- useInit( () => {
- /*
- * This seems necessary because Preact doesn't change the styles on
- * the hydration, so we have to do it manually. It doesn't need deps
- * because it only needs to do it the first time.
- */
- if ( ! result ) {
- (
- element.ref as RefObject< HTMLElement >
- ).current!.style.removeProperty( styleProp );
- } else {
- (
- element.ref as RefObject< HTMLElement >
- ).current!.style[ styleProp ] = result;
- }
- } );
} );
+ } );
} );
// data-wp-bind--[attribute]
directive( 'bind', ( { directives: { bind }, element, evaluate } ) => {
- bind.filter( ( { suffix } ) => suffix !== 'default' ).forEach(
- ( entry ) => {
- const attribute = entry.suffix;
- const result = evaluate( entry );
- element.props[ attribute ] = result;
+ bind.filter( isNonDefaultDirectiveSuffix ).forEach( ( entry ) => {
+ const attribute = entry.suffix;
+ const result = evaluate( entry );
+ element.props[ attribute ] = result;
+
+ /*
+ * This is necessary because Preact doesn't change the attributes on the
+ * hydration, so we have to do it manually. It only needs to do it the
+ * first time. After that, Preact will handle the changes.
+ */
+ useInit( () => {
+ const el = ( element.ref as RefObject< HTMLElement > ).current!;
/*
- * This is necessary because Preact doesn't change the attributes on the
- * hydration, so we have to do it manually. It only needs to do it the
- * first time. After that, Preact will handle the changes.
+ * We set the value directly to the corresponding HTMLElement instance
+ * property excluding the following special cases. We follow Preact's
+ * logic: https://github.com/preactjs/preact/blob/ea49f7a0f9d1ff2c98c0bdd66aa0cbc583055246/src/diff/props.js#L110-L129
*/
- useInit( () => {
- const el = ( element.ref as RefObject< HTMLElement > )
- .current!;
-
- /*
- * We set the value directly to the corresponding HTMLElement instance
- * property excluding the following special cases. We follow Preact's
- * logic: https://github.com/preactjs/preact/blob/ea49f7a0f9d1ff2c98c0bdd66aa0cbc583055246/src/diff/props.js#L110-L129
- */
- if ( attribute === 'style' ) {
- if ( typeof result === 'string' ) {
- el.style.cssText = result;
- }
- return;
- } else if (
- attribute !== 'width' &&
- attribute !== 'height' &&
- attribute !== 'href' &&
- attribute !== 'list' &&
- attribute !== 'form' &&
- /*
- * The value for `tabindex` follows the parsing rules for an
- * integer. If that fails, or if the attribute isn't present, then
- * the browsers should "follow platform conventions to determine if
- * the element should be considered as a focusable area",
- * practically meaning that most elements get a default of `-1` (not
- * focusable), but several also get a default of `0` (focusable in
- * order after all elements with a positive `tabindex` value).
- *
- * @see https://html.spec.whatwg.org/#tabindex-value
- */
- attribute !== 'tabIndex' &&
- attribute !== 'download' &&
- attribute !== 'rowSpan' &&
- attribute !== 'colSpan' &&
- attribute !== 'role' &&
- attribute in el
- ) {
- try {
- el[ attribute ] =
- result === null || result === undefined
- ? ''
- : result;
- return;
- } catch ( err ) {}
+ if ( attribute === 'style' ) {
+ if ( typeof result === 'string' ) {
+ el.style.cssText = result;
}
+ return;
+ } else if (
+ attribute !== 'width' &&
+ attribute !== 'height' &&
+ attribute !== 'href' &&
+ attribute !== 'list' &&
+ attribute !== 'form' &&
/*
- * aria- and data- attributes have no boolean representation.
- * A `false` value is different from the attribute not being
- * present, so we can't remove it.
- * We follow Preact's logic: https://github.com/preactjs/preact/blob/ea49f7a0f9d1ff2c98c0bdd66aa0cbc583055246/src/diff/props.js#L131C24-L136
+ * The value for `tabindex` follows the parsing rules for an
+ * integer. If that fails, or if the attribute isn't present, then
+ * the browsers should "follow platform conventions to determine if
+ * the element should be considered as a focusable area",
+ * practically meaning that most elements get a default of `-1` (not
+ * focusable), but several also get a default of `0` (focusable in
+ * order after all elements with a positive `tabindex` value).
+ *
+ * @see https://html.spec.whatwg.org/#tabindex-value
*/
- if (
- result !== null &&
- result !== undefined &&
- ( result !== false || attribute[ 4 ] === '-' )
- ) {
- el.setAttribute( attribute, result );
- } else {
- el.removeAttribute( attribute );
- }
- } );
- }
- );
+ attribute !== 'tabIndex' &&
+ attribute !== 'download' &&
+ attribute !== 'rowSpan' &&
+ attribute !== 'colSpan' &&
+ attribute !== 'role' &&
+ attribute in el
+ ) {
+ try {
+ el[ attribute ] =
+ result === null || result === undefined
+ ? ''
+ : result;
+ return;
+ } catch ( err ) {}
+ }
+ /*
+ * aria- and data- attributes have no boolean representation.
+ * A `false` value is different from the attribute not being
+ * present, so we can't remove it.
+ * We follow Preact's logic: https://github.com/preactjs/preact/blob/ea49f7a0f9d1ff2c98c0bdd66aa0cbc583055246/src/diff/props.js#L131C24-L136
+ */
+ if (
+ result !== null &&
+ result !== undefined &&
+ ( result !== false || attribute[ 4 ] === '-' )
+ ) {
+ el.setAttribute( attribute, result );
+ } else {
+ el.removeAttribute( attribute );
+ }
+ } );
+ } );
} );
// data-wp-ignore
@@ -518,7 +528,7 @@ export default () => {
// data-wp-text
directive( 'text', ( { directives: { text }, element, evaluate } ) => {
- const entry = text.find( ( { suffix } ) => suffix === 'default' );
+ const entry = text.find( isDefaultDirectiveSuffix );
if ( ! entry ) {
element.props.children = null;
return;
@@ -555,25 +565,33 @@ export default () => {
const inheritedValue = useContext( inheritedContext );
const [ entry ] = each;
- const { namespace, suffix } = entry;
+ const { namespace } = entry;
const list = evaluate( entry );
+ const itemProp = isNonDefaultDirectiveSuffix( entry )
+ ? kebabToCamelCase( entry.suffix )
+ : 'item';
return list.map( ( item ) => {
- const itemProp =
- suffix === 'default' ? 'item' : kebabToCamelCase( suffix );
const itemContext = proxifyContext(
proxifyState( namespace, {} ),
- inheritedValue[ namespace ]
+ inheritedValue.client[ namespace ]
);
const mergedContext = {
- ...inheritedValue,
- [ namespace ]: itemContext,
+ client: {
+ ...inheritedValue.client,
+ [ namespace ]: itemContext,
+ },
+ server: { ...inheritedValue.server },
};
// Set the item after proxifying the context.
- mergedContext[ namespace ][ itemProp ] = item;
+ mergedContext.client[ namespace ][ itemProp ] = item;
- const scope = { ...getScope(), context: mergedContext };
+ const scope = {
+ ...getScope(),
+ context: mergedContext.client,
+ serverContext: mergedContext.server,
+ };
const key = eachKey
? getEvaluate( { scope } )( eachKey[ 0 ] )
: item;
diff --git a/packages/interactivity/src/hooks.tsx b/packages/interactivity/src/hooks.tsx
index 215da8afef9b5b..d0423b5ebb6574 100644
--- a/packages/interactivity/src/hooks.tsx
+++ b/packages/interactivity/src/hooks.tsx
@@ -23,9 +23,29 @@ import { getScope, setScope, resetScope, type Scope } from './scopes';
export interface DirectiveEntry {
value: string | object;
namespace: string;
+ suffix: string | null;
+}
+
+export interface NonDefaultSuffixDirectiveEntry extends DirectiveEntry {
suffix: string;
}
+export interface DefaultSuffixDirectiveEntry extends DirectiveEntry {
+ suffix: null;
+}
+
+export function isNonDefaultDirectiveSuffix(
+ entry: DirectiveEntry
+): entry is NonDefaultSuffixDirectiveEntry {
+ return entry.suffix !== null;
+}
+
+export function isDefaultDirectiveSuffix(
+ entry: DirectiveEntry
+): entry is DefaultSuffixDirectiveEntry {
+ return entry.suffix === null;
+}
+
type DirectiveEntries = Record< string, DirectiveEntry[] >;
interface DirectiveArgs {
@@ -56,7 +76,7 @@ interface DirectiveArgs {
evaluate: Evaluate;
}
-interface DirectiveCallback {
+export interface DirectiveCallback {
( args: DirectiveArgs ): VNode< any > | null | void;
}
@@ -93,7 +113,7 @@ interface DirectivesProps {
}
// Main context.
-const context = createContext< any >( {} );
+const context = createContext< any >( { client: {}, server: {} } );
// WordPress Directives.
const directiveCallbacks: Record< string, DirectiveCallback > = {};
@@ -107,7 +127,7 @@ const directivePriorities: Record< string, number > = {};
* directive(
* 'alert', // Name without the `data-wp-` prefix.
* ( { directives: { alert }, element, evaluate } ) => {
- * const defaultEntry = alert.find( entry => entry.suffix === 'default' );
+ * const defaultEntry = alert.find( isDefaultDirectiveSuffix );
* element.props.onclick = () => { alert( evaluate( defaultEntry ) ); }
* }
* )
@@ -126,7 +146,7 @@ const directivePriorities: Record< string, number > = {};
*
* ```
* Note that, in the previous example, the directive callback gets the path
- * value (`state.alert`) from the directive entry with suffix `default`. A
+ * value (`state.alert`) from the directive entry with suffix `null`. A
* custom suffix can also be specified by appending `--` to the directive
* attribute, followed by the suffix, like in the following HTML snippet:
*
@@ -253,7 +273,9 @@ const Directives = ( {
// element ref, state and props.
const scope = useRef< Scope >( {} as Scope ).current;
scope.evaluate = useCallback( getEvaluate( { scope } ), [] );
- scope.context = useContext( context );
+ const { client, server } = useContext( context );
+ scope.context = client;
+ scope.serverContext = server;
/* eslint-disable react-hooks/rules-of-hooks */
scope.ref = previousScope?.ref || useRef( null );
/* eslint-enable react-hooks/rules-of-hooks */
@@ -305,9 +327,7 @@ options.vnode = ( vnode: VNode< any > ) => {
const props = vnode.props;
const directives = props.__directives;
if ( directives.key ) {
- vnode.key = directives.key.find(
- ( { suffix } ) => suffix === 'default'
- ).value;
+ vnode.key = directives.key.find( isDefaultDirectiveSuffix ).value;
}
delete props.__directives;
const priorityLevels = getPriorityLevels( directives );
diff --git a/packages/interactivity/src/index.ts b/packages/interactivity/src/index.ts
index 336c2a97226db7..9d013e4e744ed5 100644
--- a/packages/interactivity/src/index.ts
+++ b/packages/interactivity/src/index.ts
@@ -16,8 +16,8 @@ import { getNamespace } from './namespaces';
import { parseServerData, populateServerData } from './store';
import { proxifyState } from './proxies';
-export { store, getConfig } from './store';
-export { getContext, getElement } from './scopes';
+export { store, getConfig, getServerState } from './store';
+export { getContext, getServerContext, getElement } from './scopes';
export {
withScope,
useWatch,
diff --git a/packages/interactivity/src/proxies/state.ts b/packages/interactivity/src/proxies/state.ts
index ec49c4b27c4adb..d4d573acba8847 100644
--- a/packages/interactivity/src/proxies/state.ts
+++ b/packages/interactivity/src/proxies/state.ts
@@ -46,6 +46,8 @@ const proxyToProps: WeakMap<
export const hasPropSignal = ( proxy: object, key: string ) =>
proxyToProps.has( proxy ) && proxyToProps.get( proxy )!.has( key );
+const readOnlyProxies = new WeakSet();
+
/**
* Returns the {@link PropSignal | `PropSignal`} instance associated with the
* specified prop in the passed proxy.
@@ -77,8 +79,11 @@ const getPropSignal = (
if ( get ) {
prop.setGetter( get );
} else {
+ const readOnly = readOnlyProxies.has( proxy );
prop.setValue(
- shouldProxy( value ) ? proxifyState( ns, value ) : value
+ shouldProxy( value )
+ ? proxifyState( ns, value, { readOnly } )
+ : value
);
}
}
@@ -148,6 +153,9 @@ const stateHandlers: ProxyHandler< object > = {
value: unknown,
receiver: object
): boolean {
+ if ( readOnlyProxies.has( receiver ) ) {
+ return false;
+ }
setNamespace( getNamespaceFromProxy( receiver ) );
try {
return Reflect.set( target, key, value, receiver );
@@ -161,6 +169,10 @@ const stateHandlers: ProxyHandler< object > = {
key: string,
desc: PropertyDescriptor
): boolean {
+ if ( readOnlyProxies.has( getProxyFromObject( target )! ) ) {
+ return false;
+ }
+
const isNew = ! ( key in target );
const result = Reflect.defineProperty( target, key, desc );
@@ -199,6 +211,10 @@ const stateHandlers: ProxyHandler< object > = {
},
deleteProperty( target: object, key: string ): boolean {
+ if ( readOnlyProxies.has( getProxyFromObject( target )! ) ) {
+ return false;
+ }
+
const result = Reflect.deleteProperty( target, key );
if ( result ) {
@@ -230,8 +246,10 @@ const stateHandlers: ProxyHandler< object > = {
* Returns the proxy associated with the given state object, creating it if it
* does not exist.
*
- * @param namespace The namespace that will be associated to this proxy.
- * @param obj The object to proxify.
+ * @param namespace The namespace that will be associated to this proxy.
+ * @param obj The object to proxify.
+ * @param options Options.
+ * @param options.readOnly Read-only.
*
* @throws Error if the object cannot be proxified. Use {@link shouldProxy} to
* check if a proxy can be created for a specific object.
@@ -240,8 +258,15 @@ const stateHandlers: ProxyHandler< object > = {
*/
export const proxifyState = < T extends object >(
namespace: string,
- obj: T
-): T => createProxy( namespace, obj, stateHandlers ) as T;
+ obj: T,
+ options?: { readOnly?: boolean }
+): T => {
+ const proxy = createProxy( namespace, obj, stateHandlers ) as T;
+ if ( options?.readOnly ) {
+ readOnlyProxies.add( proxy );
+ }
+ return proxy;
+};
/**
* Reads the value of the specified property without subscribing to it.
@@ -275,50 +300,64 @@ const deepMergeRecursive = (
source: any,
override: boolean = true
) => {
- if ( isPlainObject( target ) && isPlainObject( source ) ) {
- let hasNewKeys = false;
- for ( const key in source ) {
- const isNew = ! ( key in target );
- hasNewKeys = hasNewKeys || isNew;
+ if ( ! ( isPlainObject( target ) && isPlainObject( source ) ) ) {
+ return;
+ }
- const desc = Object.getOwnPropertyDescriptor( source, key );
- if (
- typeof desc?.get === 'function' ||
- typeof desc?.set === 'function'
- ) {
- if ( override || isNew ) {
- Object.defineProperty( target, key, {
- ...desc,
- configurable: true,
- enumerable: true,
- } );
-
- const proxy = getProxyFromObject( target );
- if ( desc?.get && proxy && hasPropSignal( proxy, key ) ) {
- const propSignal = getPropSignal( proxy, key );
- propSignal.setGetter( desc.get );
- }
- }
- } else if ( isPlainObject( source[ key ] ) ) {
- if ( isNew ) {
- target[ key ] = {};
- }
+ let hasNewKeys = false;
- deepMergeRecursive( target[ key ], source[ key ], override );
- } else if ( override || isNew ) {
- Object.defineProperty( target, key, desc! );
+ for ( const key in source ) {
+ const isNew = ! ( key in target );
+ hasNewKeys = hasNewKeys || isNew;
- const proxy = getProxyFromObject( target );
- if ( desc?.value && proxy && hasPropSignal( proxy, key ) ) {
- const propSignal = getPropSignal( proxy, key );
- propSignal.setValue( desc.value );
+ const desc = Object.getOwnPropertyDescriptor( source, key )!;
+ const proxy = getProxyFromObject( target );
+ const propSignal =
+ !! proxy &&
+ hasPropSignal( proxy, key ) &&
+ getPropSignal( proxy, key );
+
+ if (
+ typeof desc.get === 'function' ||
+ typeof desc.set === 'function'
+ ) {
+ if ( override || isNew ) {
+ Object.defineProperty( target, key, {
+ ...desc,
+ configurable: true,
+ enumerable: true,
+ } );
+ if ( desc.get && propSignal ) {
+ propSignal.setGetter( desc.get );
+ }
+ }
+ } else if ( isPlainObject( source[ key ] ) ) {
+ if ( isNew || ( override && ! isPlainObject( target[ key ] ) ) ) {
+ target[ key ] = {};
+ if ( propSignal ) {
+ const ns = getNamespaceFromProxy( proxy );
+ propSignal.setValue(
+ proxifyState( ns, target[ key ] as Object )
+ );
}
}
+ if ( isPlainObject( target[ key ] ) ) {
+ deepMergeRecursive( target[ key ], source[ key ], override );
+ }
+ } else if ( override || isNew ) {
+ Object.defineProperty( target, key, desc );
+ if ( propSignal ) {
+ const { value } = desc;
+ const ns = getNamespaceFromProxy( proxy );
+ propSignal.setValue(
+ shouldProxy( value ) ? proxifyState( ns, value ) : value
+ );
+ }
}
+ }
- if ( hasNewKeys && objToIterable.has( target ) ) {
- objToIterable.get( target )!.value++;
- }
+ if ( hasNewKeys && objToIterable.has( target ) ) {
+ objToIterable.get( target )!.value++;
}
};
diff --git a/packages/interactivity/src/proxies/test/context-proxy.ts b/packages/interactivity/src/proxies/test/context-proxy.ts
index 306b3e4a8aa94f..1e4a969d0f9dc6 100644
--- a/packages/interactivity/src/proxies/test/context-proxy.ts
+++ b/packages/interactivity/src/proxies/test/context-proxy.ts
@@ -6,7 +6,7 @@ import { effect } from '@preact/signals';
/**
* Internal dependencies
*/
-import { proxifyContext, proxifyState } from '../';
+import { proxifyContext, proxifyState, deepMerge } from '../';
describe( 'Interactivity API', () => {
describe( 'context proxy', () => {
@@ -277,6 +277,66 @@ describe( 'Interactivity API', () => {
'fromFallback',
] );
} );
+
+ it( 'should handle deeply nested properties that are initially undefined', () => {
+ const fallback: any = proxifyContext(
+ proxifyState( 'test', {} ),
+ {}
+ );
+ const context: any = proxifyContext(
+ proxifyState( 'test', {} ),
+ fallback
+ );
+
+ let deepValue: any;
+ const spy = jest.fn( () => {
+ deepValue = context.a?.b?.c?.d;
+ } );
+ effect( spy );
+
+ // Initial call, the deep value is undefined
+ expect( spy ).toHaveBeenCalledTimes( 1 );
+ expect( deepValue ).toBeUndefined();
+
+ // Add a deeply nested object to the context
+ context.a = { b: { c: { d: 'test value' } } };
+
+ // The effect should be called again
+ expect( spy ).toHaveBeenCalledTimes( 2 );
+ expect( deepValue ).toBe( 'test value' );
+
+ // Reading the value directly should also work
+ expect( context.a.b.c.d ).toBe( 'test value' );
+ } );
+
+ it( 'should handle deeply nested properties that are initially undefined and merged with deepMerge', () => {
+ const fallbackState = proxifyState( 'test', {} );
+ const fallback: any = proxifyContext( fallbackState, {} );
+ const contextState = proxifyState( 'test', {} );
+ const context: any = proxifyContext( contextState, fallback );
+
+ let deepValue: any;
+ const spy = jest.fn( () => {
+ deepValue = context.a?.b?.c?.d;
+ } );
+ effect( spy );
+
+ // Initial call, the deep value is undefined
+ expect( spy ).toHaveBeenCalledTimes( 1 );
+ expect( deepValue ).toBeUndefined();
+
+ // Use deepMerge to add a deeply nested object to the context
+ deepMerge( contextState, {
+ a: { b: { c: { d: 'test value' } } },
+ } );
+
+ // The effect should be called again
+ expect( spy ).toHaveBeenCalledTimes( 2 );
+ expect( deepValue ).toBe( 'test value' );
+
+ // Reading the value directly should also work
+ expect( context.a.b.c.d ).toBe( 'test value' );
+ } );
} );
describe( 'proxifyContext', () => {
diff --git a/packages/interactivity/src/proxies/test/deep-merge.ts b/packages/interactivity/src/proxies/test/deep-merge.ts
index bf31c4b552133c..267e4850f9af91 100644
--- a/packages/interactivity/src/proxies/test/deep-merge.ts
+++ b/packages/interactivity/src/proxies/test/deep-merge.ts
@@ -379,7 +379,10 @@ describe( 'Interactivity API', () => {
const target = proxifyState< any >( 'test', { a: 1, b: 2 } );
const source = { a: 1, b: 2, c: 3 };
- const spy = jest.fn( () => Object.keys( target ) );
+ let keys: any;
+ const spy = jest.fn( () => {
+ keys = Object.keys( target );
+ } );
effect( spy );
expect( spy ).toHaveBeenCalledTimes( 1 );
@@ -387,7 +390,115 @@ describe( 'Interactivity API', () => {
deepMerge( target, source, false );
expect( spy ).toHaveBeenCalledTimes( 2 );
- expect( spy ).toHaveLastReturnedWith( [ 'a', 'b', 'c' ] );
+ expect( keys ).toEqual( [ 'a', 'b', 'c' ] );
+ } );
+
+ it( 'should handle deeply nested properties that are initially undefined', () => {
+ const target: any = proxifyState( 'test', {} );
+
+ let deepValue: any;
+ const spy = jest.fn( () => {
+ deepValue = target.a?.b?.c?.d;
+ } );
+ effect( spy );
+
+ // Initial call, the deep value is undefined
+ expect( spy ).toHaveBeenCalledTimes( 1 );
+ expect( deepValue ).toBeUndefined();
+
+ // Use deepMerge to add a deeply nested object to the target
+ deepMerge( target, { a: { b: { c: { d: 'test value' } } } } );
+
+ // The effect should be called again
+ expect( spy ).toHaveBeenCalledTimes( 2 );
+ expect( deepValue ).toBe( 'test value' );
+
+ // Reading the value directly should also work
+ expect( target.a.b.c.d ).toBe( 'test value' );
+
+ // Modify the nested value
+ target.a.b.c.d = 'new test value';
+
+ // The effect should be called again
+ expect( spy ).toHaveBeenCalledTimes( 3 );
+ expect( deepValue ).toBe( 'new test value' );
+ } );
+
+ it( 'should overwrite values that become objects', () => {
+ const target: any = proxifyState( 'test', { message: 'hello' } );
+
+ let message: any;
+ const spy = jest.fn( () => ( message = target.message ) );
+ effect( spy );
+
+ expect( spy ).toHaveBeenCalledTimes( 1 );
+ expect( message ).toBe( 'hello' );
+
+ deepMerge( target, {
+ message: { content: 'hello', fontStyle: 'italic' },
+ } );
+
+ expect( spy ).toHaveBeenCalledTimes( 2 );
+ expect( message ).toEqual( {
+ content: 'hello',
+ fontStyle: 'italic',
+ } );
+
+ expect( target.message ).toEqual( {
+ content: 'hello',
+ fontStyle: 'italic',
+ } );
+ } );
+
+ it( 'should not overwrite values that become objects if `override` is false', () => {
+ const target: any = proxifyState( 'test', { message: 'hello' } );
+
+ let message: any;
+ const spy = jest.fn( () => ( message = target.message ) );
+ effect( spy );
+
+ expect( spy ).toHaveBeenCalledTimes( 1 );
+ expect( message ).toBe( 'hello' );
+
+ deepMerge(
+ target,
+ { message: { content: 'hello', fontStyle: 'italic' } },
+ false
+ );
+
+ expect( spy ).toHaveBeenCalledTimes( 1 );
+ expect( message ).toBe( 'hello' );
+ expect( target.message ).toBe( 'hello' );
+ expect( target.message.content ).toBeUndefined();
+ expect( target.message.fontStyle ).toBeUndefined();
+ } );
+
+ it( 'should keep reactivity of arrays that are initially undefined', () => {
+ const target: any = proxifyState( 'test', {} );
+
+ let deepValue: any;
+ const spy = jest.fn( () => {
+ deepValue = target.array?.[ 0 ];
+ } );
+ effect( spy );
+
+ // Initial call, the deep value is undefined
+ expect( spy ).toHaveBeenCalledTimes( 1 );
+ expect( deepValue ).toBeUndefined();
+
+ // Use deepMerge to add an array to the target
+ deepMerge( target, { array: [ 'value 1' ] } );
+
+ // The effect should be called again
+ expect( spy ).toHaveBeenCalledTimes( 2 );
+ expect( deepValue ).toBe( 'value 1' );
+
+ // Modify the array value
+ target.array[ 0 ] = 'value 2';
+
+ // The effect should be called again
+ expect( spy ).toHaveBeenCalledTimes( 3 );
+ expect( deepValue ).toBe( 'value 2' );
} );
} );
} );
diff --git a/packages/interactivity/src/proxies/test/state-proxy.ts b/packages/interactivity/src/proxies/test/state-proxy.ts
index 92500189fc8309..4b0d2b0a708c3a 100644
--- a/packages/interactivity/src/proxies/test/state-proxy.ts
+++ b/packages/interactivity/src/proxies/test/state-proxy.ts
@@ -9,7 +9,7 @@ import { effect } from '@preact/signals';
/**
* Internal dependencies
*/
-import { proxifyState, peek } from '../';
+import { proxifyState, peek, deepMerge } from '../';
import { setScope, resetScope, getContext, getElement } from '../../scopes';
import { setNamespace, resetNamespace } from '../../namespaces';
@@ -1265,5 +1265,202 @@ describe( 'Interactivity API', () => {
expect( x ).toBe( undefined );
} );
} );
+
+ describe( 'read-only', () => {
+ it( "should not allow modifying a prop's value", () => {
+ const readOnlyState = proxifyState(
+ 'test',
+ { prop: 'value', nested: { prop: 'value' } },
+ { readOnly: true }
+ );
+
+ expect( () => {
+ readOnlyState.prop = 'new value';
+ } ).toThrow();
+ expect( () => {
+ readOnlyState.nested.prop = 'new value';
+ } ).toThrow();
+ } );
+
+ it( 'should not allow modifying a prop descriptor', () => {
+ const readOnlyState = proxifyState(
+ 'test',
+ { prop: 'value', nested: { prop: 'value' } },
+ { readOnly: true }
+ );
+
+ expect( () => {
+ Object.defineProperty( readOnlyState, 'prop', {
+ get: () => 'value from getter',
+ writable: true,
+ enumerable: false,
+ } );
+ } ).toThrow();
+ expect( () => {
+ Object.defineProperty( readOnlyState.nested, 'prop', {
+ get: () => 'value from getter',
+ writable: true,
+ enumerable: false,
+ } );
+ } ).toThrow();
+ } );
+
+ it( 'should not allow adding new props', () => {
+ const readOnlyState = proxifyState< any >(
+ 'test',
+ { prop: 'value', nested: { prop: 'value' } },
+ { readOnly: true }
+ );
+
+ expect( () => {
+ readOnlyState.newProp = 'value';
+ } ).toThrow();
+ expect( () => {
+ readOnlyState.nested.newProp = 'value';
+ } ).toThrow();
+ } );
+
+ it( 'should not allow removing props', () => {
+ const readOnlyState = proxifyState< any >(
+ 'test',
+ { prop: 'value', nested: { prop: 'value' } },
+ { readOnly: true }
+ );
+
+ expect( () => {
+ delete readOnlyState.prop;
+ } ).toThrow();
+ expect( () => {
+ delete readOnlyState.nested.prop;
+ } ).toThrow();
+ } );
+
+ it( 'should not allow adding items to an array', () => {
+ const readOnlyState = proxifyState(
+ 'test',
+ { array: [ 1, 2, 3 ], nested: { array: [ 1, 2, 3 ] } },
+ { readOnly: true }
+ );
+
+ expect( () => readOnlyState.array.push( 4 ) ).toThrow();
+ expect( () => readOnlyState.nested.array.push( 4 ) ).toThrow();
+ } );
+
+ it( 'should not allow removing items from an array', () => {
+ const readOnlyState = proxifyState(
+ 'test',
+ { array: [ 1, 2, 3 ], nested: { array: [ 1, 2, 3 ] } },
+ { readOnly: true }
+ );
+
+ expect( () => readOnlyState.array.pop() ).toThrow();
+ expect( () => readOnlyState.nested.array.pop() ).toThrow();
+ } );
+
+ it( 'should allow subscribing to prop changes', () => {
+ const readOnlyState = proxifyState(
+ 'test',
+ {
+ prop: 'value',
+ nested: { prop: 'value' },
+ },
+ { readOnly: true }
+ );
+
+ const spy1 = jest.fn( () => readOnlyState.prop );
+ const spy2 = jest.fn( () => readOnlyState.nested.prop );
+
+ effect( spy1 );
+ effect( spy2 );
+ expect( spy1 ).toHaveBeenCalledTimes( 1 );
+ expect( spy2 ).toHaveBeenCalledTimes( 1 );
+ expect( spy1 ).toHaveLastReturnedWith( 'value' );
+ expect( spy2 ).toHaveLastReturnedWith( 'value' );
+
+ deepMerge( readOnlyState, { prop: 'new value' } );
+
+ expect( spy1 ).toHaveBeenCalledTimes( 2 );
+ expect( spy2 ).toHaveBeenCalledTimes( 1 );
+ expect( spy1 ).toHaveLastReturnedWith( 'new value' );
+ expect( spy2 ).toHaveLastReturnedWith( 'value' );
+
+ deepMerge( readOnlyState, { nested: { prop: 'new value' } } );
+
+ expect( spy1 ).toHaveBeenCalledTimes( 2 );
+ expect( spy2 ).toHaveBeenCalledTimes( 2 );
+ expect( spy1 ).toHaveLastReturnedWith( 'new value' );
+ expect( spy2 ).toHaveLastReturnedWith( 'new value' );
+ } );
+
+ it( 'should allow subscribing to new props', () => {
+ const readOnlyState = proxifyState< any >(
+ 'test',
+ {
+ prop: 'value',
+ nested: { prop: 'value' },
+ },
+ { readOnly: true }
+ );
+
+ const spy1 = jest.fn( () => readOnlyState.newProp );
+ const spy2 = jest.fn( () => readOnlyState.nested.newProp );
+
+ effect( spy1 );
+ effect( spy2 );
+ expect( spy1 ).toHaveBeenCalledTimes( 1 );
+ expect( spy2 ).toHaveBeenCalledTimes( 1 );
+ expect( spy1 ).toHaveLastReturnedWith( undefined );
+ expect( spy2 ).toHaveLastReturnedWith( undefined );
+
+ deepMerge( readOnlyState, { newProp: 'value' } );
+
+ expect( spy1 ).toHaveBeenCalledTimes( 2 );
+ expect( spy2 ).toHaveBeenCalledTimes( 1 );
+ expect( spy1 ).toHaveLastReturnedWith( 'value' );
+ expect( spy2 ).toHaveLastReturnedWith( undefined );
+
+ deepMerge( readOnlyState, { nested: { newProp: 'value' } } );
+
+ expect( spy1 ).toHaveBeenCalledTimes( 2 );
+ expect( spy2 ).toHaveBeenCalledTimes( 2 );
+ expect( spy1 ).toHaveLastReturnedWith( 'value' );
+ expect( spy2 ).toHaveLastReturnedWith( 'value' );
+ } );
+
+ it( 'should allow subscribing to array changes', () => {
+ const readOnlyState = proxifyState< any >(
+ 'test',
+ {
+ array: [ 1, 2, 3 ],
+ nested: { array: [ 1, 2, 3 ] },
+ },
+ { readOnly: true }
+ );
+
+ const spy1 = jest.fn( () => readOnlyState.array[ 0 ] );
+ const spy2 = jest.fn( () => readOnlyState.nested.array[ 0 ] );
+
+ effect( spy1 );
+ effect( spy2 );
+ expect( spy1 ).toHaveBeenCalledTimes( 1 );
+ expect( spy2 ).toHaveBeenCalledTimes( 1 );
+ expect( spy1 ).toHaveLastReturnedWith( 1 );
+ expect( spy2 ).toHaveLastReturnedWith( 1 );
+
+ deepMerge( readOnlyState, { array: [ 4, 5, 6 ] } );
+
+ expect( spy1 ).toHaveBeenCalledTimes( 2 );
+ expect( spy2 ).toHaveBeenCalledTimes( 1 );
+ expect( spy1 ).toHaveLastReturnedWith( 4 );
+ expect( spy2 ).toHaveLastReturnedWith( 1 );
+
+ deepMerge( readOnlyState, { nested: { array: [] } } );
+
+ expect( spy1 ).toHaveBeenCalledTimes( 2 );
+ expect( spy2 ).toHaveBeenCalledTimes( 2 );
+ expect( spy1 ).toHaveLastReturnedWith( 4 );
+ expect( spy2 ).toHaveLastReturnedWith( undefined );
+ } );
+ } );
} );
} );
diff --git a/packages/interactivity/src/scopes.ts b/packages/interactivity/src/scopes.ts
index 2e78755ec4bbe6..722305f6bee112 100644
--- a/packages/interactivity/src/scopes.ts
+++ b/packages/interactivity/src/scopes.ts
@@ -12,6 +12,7 @@ import type { Evaluate } from './hooks';
export interface Scope {
evaluate: Evaluate;
context: object;
+ serverContext: object;
ref: RefObject< HTMLElement >;
attributes: createElement.JSX.HTMLAttributes;
}
@@ -96,3 +97,46 @@ export const getElement = () => {
attributes: deepImmutable( attributes ),
} );
};
+
+/**
+ * Retrieves the part of the inherited context defined and updated from the
+ * server.
+ *
+ * The object returned is read-only, and includes the context defined in PHP
+ * with `wp_interactivity_data_wp_context()`, including the corresponding
+ * inherited properties. When `actions.navigate()` is called, this object is
+ * updated to reflect the changes in the new visited page, without affecting the
+ * context returned by `getContext()`. Directives can subscribe to those changes
+ * to update the context if needed.
+ *
+ * @example
+ * ```js
+ * store('...', {
+ * callbacks: {
+ * updateServerContext() {
+ * const context = getContext();
+ * const serverContext = getServerContext();
+ * // Override some property with the new value that came from the server.
+ * context.overridableProp = serverContext.overridableProp;
+ * },
+ * },
+ * });
+ * ```
+ *
+ * @param namespace Store namespace. By default, the namespace where the calling
+ * function exists is used.
+ * @return The server context content.
+ */
+export const getServerContext = < T extends object >(
+ namespace?: string
+): T => {
+ const scope = getScope();
+ if ( globalThis.SCRIPT_DEBUG ) {
+ if ( ! scope ) {
+ throw Error(
+ 'Cannot call `getServerContext()` when there is no scope. If you are using an async function, please consider using a generator instead. If you are using some sort of async callbacks, like `setTimeout`, please wrap the callback with `withScope(callback)`.'
+ );
+ }
+ }
+ return scope.serverContext[ namespace || getNamespace() ];
+};
diff --git a/packages/interactivity/src/store.ts b/packages/interactivity/src/store.ts
index c74764b902e194..b1ad07459c62c0 100644
--- a/packages/interactivity/src/store.ts
+++ b/packages/interactivity/src/store.ts
@@ -12,6 +12,7 @@ export const stores = new Map();
const rawStores = new Map();
const storeLocks = new Map();
const storeConfigs = new Map();
+const serverStates = new Map();
/**
* Get the defined config for the store with the passed namespace.
@@ -22,6 +23,39 @@ const storeConfigs = new Map();
export const getConfig = ( namespace?: string ) =>
storeConfigs.get( namespace || getNamespace() ) || {};
+/**
+ * Get the part of the state defined and updated from the server.
+ *
+ * The object returned is read-only, and includes the state defined in PHP with
+ * `wp_interactivity_state()`. When using `actions.navigate()`, this object is
+ * updated to reflect the changes in its properites, without affecting the state
+ * returned by `store()`. Directives can subscribe to those changes to update
+ * the state if needed.
+ *
+ * @example
+ * ```js
+ * const { state } = store('myStore', {
+ * callbacks: {
+ * updateServerState() {
+ * const serverState = getServerState();
+ * // Override some property with the new value that came from the server.
+ * state.overridableProp = serverState.overridableProp;
+ * },
+ * },
+ * });
+ * ```
+ *
+ * @param namespace Store's namespace from which to retrieve the server state.
+ * @return The server state for the given namespace.
+ */
+export const getServerState = ( namespace?: string ) => {
+ const ns = namespace || getNamespace();
+ if ( ! serverStates.has( ns ) ) {
+ serverStates.set( ns, proxifyState( ns, {}, { readOnly: true } ) );
+ }
+ return serverStates.get( ns );
+};
+
interface StoreOptions {
/**
* Property to block/unblock private store namespaces.
@@ -187,6 +221,7 @@ export const populateServerData = ( data?: {
Object.entries( data!.state ).forEach( ( [ namespace, state ] ) => {
const st = store< any >( namespace, {}, { lock: universalUnlock } );
deepMerge( st.state, state, false );
+ deepMerge( getServerState( namespace ), state );
} );
}
if ( isPlainObject( data?.config ) ) {
diff --git a/packages/interactivity/src/vdom.ts b/packages/interactivity/src/vdom.ts
index 98deca656cfa6c..b533a130e4a6f0 100644
--- a/packages/interactivity/src/vdom.ts
+++ b/packages/interactivity/src/vdom.ts
@@ -7,6 +7,7 @@ import { h, type ComponentChild, type JSX } from 'preact';
*/
import { directivePrefix as p } from './constants';
import { warn } from './utils';
+import { type DirectiveEntry } from './hooks';
const ignoreAttr = `data-${ p }-ignore`;
const islandAttr = `data-${ p }-interactive`;
@@ -139,26 +140,25 @@ export function toVdom( root: Node ): Array< ComponentChild > {
}
if ( directives.length ) {
- props.__directives = directives.reduce(
- ( obj, [ name, ns, value ] ) => {
- const directiveMatch = directiveParser.exec( name );
- if ( directiveMatch === null ) {
- warn( `Found malformed directive name: ${ name }.` );
- return obj;
- }
- const prefix = directiveMatch[ 1 ] || '';
- const suffix = directiveMatch[ 2 ] || 'default';
-
- obj[ prefix ] = obj[ prefix ] || [];
- obj[ prefix ].push( {
- namespace: ns ?? currentNamespace(),
- value,
- suffix,
- } );
+ props.__directives = directives.reduce<
+ Record< string, Array< DirectiveEntry > >
+ >( ( obj, [ name, ns, value ] ) => {
+ const directiveMatch = directiveParser.exec( name );
+ if ( directiveMatch === null ) {
+ warn( `Found malformed directive name: ${ name }.` );
return obj;
- },
- {}
- );
+ }
+ const prefix = directiveMatch[ 1 ] || '';
+ const suffix = directiveMatch[ 2 ] || null;
+
+ obj[ prefix ] = obj[ prefix ] || [];
+ obj[ prefix ].push( {
+ namespace: ns ?? currentNamespace()!,
+ value: value as DirectiveEntry[ 'value' ],
+ suffix,
+ } );
+ return obj;
+ }, {} );
}
// @ts-expect-error Fixed in upcoming preact release https://github.com/preactjs/preact/pull/4334
diff --git a/packages/interface/CHANGELOG.md b/packages/interface/CHANGELOG.md
index 64f8c370bb6ece..64ef4902f4a3ad 100644
--- a/packages/interface/CHANGELOG.md
+++ b/packages/interface/CHANGELOG.md
@@ -1,6 +1,12 @@
-## Unreleased
+## 6.8.6 (2024-10-14)
+
+### Bug fix
+
+- `InterfaceSkeleton` no longer supports region navigation and its props `enableRegionNavigation` and `shortcuts` are removed. ([#63611](https://github.com/WordPress/gutenberg/pull/63611)). It’s recommended to add region navigation with the higher-order component `navigateRegions` or the hook `__unstableUseNavigateRegions` from `@wordpress/components`.
+
+## 6.8.0 (2024-09-19)
## 6.7.0 (2024-09-05)
diff --git a/packages/interface/package.json b/packages/interface/package.json
index d15d648eaef446..4e580bed0ea271 100644
--- a/packages/interface/package.json
+++ b/packages/interface/package.json
@@ -1,6 +1,6 @@
{
"name": "@wordpress/interface",
- "version": "6.7.0",
+ "version": "6.8.10",
"description": "Interface module for WordPress. The package contains shared functionality across the modern JavaScript-based WordPress screens.",
"author": "The WordPress Contributors",
"license": "GPL-2.0-or-later",
diff --git a/packages/interface/src/components/complementary-area-toggle/index.js b/packages/interface/src/components/complementary-area-toggle/index.js
index b6690b7df5fc5d..2f8d8dd413674b 100644
--- a/packages/interface/src/components/complementary-area-toggle/index.js
+++ b/packages/interface/src/components/complementary-area-toggle/index.js
@@ -10,6 +10,25 @@ import { useDispatch, useSelect } from '@wordpress/data';
import { store as interfaceStore } from '../../store';
import complementaryAreaContext from '../complementary-area-context';
+/**
+ * Whether the role supports checked state.
+ *
+ * @param {import('react').AriaRole} role Role.
+ * @return {boolean} Whether the role supports checked state.
+ * @see https://www.w3.org/TR/wai-aria-1.1/#aria-checked
+ */
+function roleSupportsCheckedState( role ) {
+ return [
+ 'checkbox',
+ 'option',
+ 'radio',
+ 'switch',
+ 'menuitemcheckbox',
+ 'menuitemradio',
+ 'treeitem',
+ ].includes( role );
+}
+
function ComplementaryAreaToggle( {
as = Button,
scope,
@@ -17,6 +36,7 @@ function ComplementaryAreaToggle( {
icon,
selectedIcon,
name,
+ shortcut,
...props
} ) {
const ComponentToUse = as;
@@ -26,12 +46,18 @@ function ComplementaryAreaToggle( {
identifier,
[ identifier, scope ]
);
+
const { enableComplementaryArea, disableComplementaryArea } =
useDispatch( interfaceStore );
+
return (
{
if ( isSelected ) {
disableComplementaryArea( scope );
@@ -39,6 +65,7 @@ function ComplementaryAreaToggle( {
enableComplementaryArea( scope, identifier );
}
} }
+ shortcut={ shortcut }
{ ...props }
/>
);
diff --git a/packages/interface/src/components/complementary-area/index.js b/packages/interface/src/components/complementary-area/index.js
index 363a6ee9dea76c..d9fa8e71acb23a 100644
--- a/packages/interface/src/components/complementary-area/index.js
+++ b/packages/interface/src/components/complementary-area/index.js
@@ -275,6 +275,7 @@ function ComplementaryArea( {
showTooltip={ ! showIconLabels }
variant={ showIconLabels ? 'tertiary' : undefined }
size="compact"
+ shortcut={ toggleShortcut }
/>
) }
diff --git a/packages/interface/src/components/interface-skeleton/index.js b/packages/interface/src/components/interface-skeleton/index.js
index a1fd20b642206f..2417cc909d1698 100644
--- a/packages/interface/src/components/interface-skeleton/index.js
+++ b/packages/interface/src/components/interface-skeleton/index.js
@@ -8,13 +8,11 @@ import clsx from 'clsx';
*/
import { forwardRef, useEffect } from '@wordpress/element';
import {
- __unstableUseNavigateRegions as useNavigateRegions,
__unstableMotion as motion,
__unstableAnimatePresence as AnimatePresence,
} from '@wordpress/components';
import { __, _x } from '@wordpress/i18n';
import {
- useMergeRefs,
useReducedMotion,
useViewportMatch,
useResizeObserver,
@@ -85,10 +83,6 @@ function InterfaceSkeleton(
actions,
labels,
className,
- enableRegionNavigation = true,
- // Todo: does this need to be a prop.
- // Can we use a dependency to keyboard-shortcuts directly?
- shortcuts,
},
ref
) {
@@ -101,7 +95,6 @@ function InterfaceSkeleton(
duration: disableMotion ? 0 : ANIMATION_DURATION,
ease: [ 0.6, 0, 0.4, 1 ],
};
- const navigateRegionsProps = useNavigateRegions( shortcuts );
useHTMLClass( 'interface-interface-skeleton__html-container' );
const defaultLabels = {
@@ -112,7 +105,7 @@ function InterfaceSkeleton(
/* translators: accessibility text for the secondary sidebar landmark region. */
secondarySidebar: __( 'Block Library' ),
/* translators: accessibility text for the settings landmark region. */
- sidebar: __( 'Settings' ),
+ sidebar: _x( 'Settings', 'settings landmark area' ),
/* translators: accessibility text for the publish landmark region. */
actions: __( 'Publish' ),
/* translators: accessibility text for the footer landmark region. */
@@ -123,15 +116,10 @@ function InterfaceSkeleton(
return (
diff --git a/packages/interface/src/components/navigable-region/index.js b/packages/interface/src/components/navigable-region/index.js
index a4c051185b63ab..50b98d49070d43 100644
--- a/packages/interface/src/components/navigable-region/index.js
+++ b/packages/interface/src/components/navigable-region/index.js
@@ -1,24 +1,29 @@
+/**
+ * WordPress dependencies
+ */
+import { forwardRef } from '@wordpress/element';
+
/**
* External dependencies
*/
import clsx from 'clsx';
-export default function NavigableRegion( {
- children,
- className,
- ariaLabel,
- as: Tag = 'div',
- ...props
-} ) {
- return (
-
- { children }
-
- );
-}
+const NavigableRegion = forwardRef(
+ ( { children, className, ariaLabel, as: Tag = 'div', ...props }, ref ) => {
+ return (
+
+ { children }
+
+ );
+ }
+);
+
+NavigableRegion.displayName = 'NavigableRegion';
+export default NavigableRegion;
diff --git a/packages/is-shallow-equal/CHANGELOG.md b/packages/is-shallow-equal/CHANGELOG.md
index 81f2c4b164a3ab..ef424aaeebf220 100644
--- a/packages/is-shallow-equal/CHANGELOG.md
+++ b/packages/is-shallow-equal/CHANGELOG.md
@@ -2,6 +2,8 @@
## Unreleased
+## 5.8.0 (2024-09-19)
+
## 5.7.0 (2024-09-05)
## 5.6.0 (2024-08-21)
diff --git a/packages/is-shallow-equal/package.json b/packages/is-shallow-equal/package.json
index 553c3182bb5668..cc8093ae14d550 100644
--- a/packages/is-shallow-equal/package.json
+++ b/packages/is-shallow-equal/package.json
@@ -1,6 +1,6 @@
{
"name": "@wordpress/is-shallow-equal",
- "version": "5.7.0",
+ "version": "5.8.1",
"description": "Test for shallow equality between two objects or arrays.",
"author": "The WordPress Contributors",
"license": "GPL-2.0-or-later",
diff --git a/packages/jest-console/CHANGELOG.md b/packages/jest-console/CHANGELOG.md
index 7f7f80b815a850..2ed1dc24299f45 100644
--- a/packages/jest-console/CHANGELOG.md
+++ b/packages/jest-console/CHANGELOG.md
@@ -2,6 +2,8 @@
## Unreleased
+## 8.8.0 (2024-09-19)
+
## 8.7.0 (2024-09-05)
## 8.6.0 (2024-08-21)
diff --git a/packages/jest-console/package.json b/packages/jest-console/package.json
index 4381a0f287235f..1cbb8d68dc2232 100644
--- a/packages/jest-console/package.json
+++ b/packages/jest-console/package.json
@@ -1,6 +1,6 @@
{
"name": "@wordpress/jest-console",
- "version": "8.7.0",
+ "version": "8.8.1",
"description": "Custom Jest matchers for the Console object.",
"author": "The WordPress Contributors",
"license": "GPL-2.0-or-later",
diff --git a/packages/jest-preset-default/CHANGELOG.md b/packages/jest-preset-default/CHANGELOG.md
index e48d25dc1e6424..a3ecded683cd17 100644
--- a/packages/jest-preset-default/CHANGELOG.md
+++ b/packages/jest-preset-default/CHANGELOG.md
@@ -2,6 +2,8 @@
## Unreleased
+## 12.8.0 (2024-09-19)
+
## 12.7.0 (2024-09-05)
## 12.6.0 (2024-08-21)
diff --git a/packages/jest-preset-default/package.json b/packages/jest-preset-default/package.json
index 27638c711d4d1e..af599dfce09493 100644
--- a/packages/jest-preset-default/package.json
+++ b/packages/jest-preset-default/package.json
@@ -1,6 +1,6 @@
{
"name": "@wordpress/jest-preset-default",
- "version": "12.7.0",
+ "version": "12.8.1",
"description": "Default Jest preset for WordPress development.",
"author": "The WordPress Contributors",
"license": "GPL-2.0-or-later",
diff --git a/packages/jest-puppeteer-axe/CHANGELOG.md b/packages/jest-puppeteer-axe/CHANGELOG.md
index 572b4aa137a141..3e61ea09412d63 100644
--- a/packages/jest-puppeteer-axe/CHANGELOG.md
+++ b/packages/jest-puppeteer-axe/CHANGELOG.md
@@ -2,6 +2,8 @@
## Unreleased
+## 7.8.0 (2024-09-19)
+
## 7.7.0 (2024-09-05)
## 7.6.0 (2024-08-21)
diff --git a/packages/jest-puppeteer-axe/package.json b/packages/jest-puppeteer-axe/package.json
index b8ccbfcd36beff..7f0ff64ed7744b 100644
--- a/packages/jest-puppeteer-axe/package.json
+++ b/packages/jest-puppeteer-axe/package.json
@@ -1,6 +1,6 @@
{
"name": "@wordpress/jest-puppeteer-axe",
- "version": "7.7.0",
+ "version": "7.8.1",
"description": "Axe API integration with Jest and Puppeteer.",
"author": "The WordPress Contributors",
"license": "GPL-2.0-or-later",
diff --git a/packages/keyboard-shortcuts/CHANGELOG.md b/packages/keyboard-shortcuts/CHANGELOG.md
index fe3506ea4a5f19..356e850692af4d 100644
--- a/packages/keyboard-shortcuts/CHANGELOG.md
+++ b/packages/keyboard-shortcuts/CHANGELOG.md
@@ -2,6 +2,8 @@
## Unreleased
+## 5.8.0 (2024-09-19)
+
## 5.7.0 (2024-09-05)
## 5.6.0 (2024-08-21)
diff --git a/packages/keyboard-shortcuts/package.json b/packages/keyboard-shortcuts/package.json
index dd560424b66ff7..ee0ac7391f5754 100644
--- a/packages/keyboard-shortcuts/package.json
+++ b/packages/keyboard-shortcuts/package.json
@@ -1,6 +1,6 @@
{
"name": "@wordpress/keyboard-shortcuts",
- "version": "5.7.0",
+ "version": "5.8.3",
"description": "Handling keyboard shortcuts.",
"author": "The WordPress Contributors",
"license": "GPL-2.0-or-later",
diff --git a/packages/keycodes/CHANGELOG.md b/packages/keycodes/CHANGELOG.md
index 51050728f2f5a3..a578adb34bd3d3 100644
--- a/packages/keycodes/CHANGELOG.md
+++ b/packages/keycodes/CHANGELOG.md
@@ -2,6 +2,8 @@
## Unreleased
+## 4.8.0 (2024-09-19)
+
## 4.7.0 (2024-09-05)
## 4.6.0 (2024-08-21)
diff --git a/packages/keycodes/package.json b/packages/keycodes/package.json
index 5d8ec0545d7e3f..1081c75644452f 100644
--- a/packages/keycodes/package.json
+++ b/packages/keycodes/package.json
@@ -1,6 +1,6 @@
{
"name": "@wordpress/keycodes",
- "version": "4.7.0",
+ "version": "4.8.2",
"description": "Keycodes utilities for WordPress. Used to check for keyboard events across browsers/operating systems.",
"author": "The WordPress Contributors",
"license": "GPL-2.0-or-later",
diff --git a/packages/lazy-import/CHANGELOG.md b/packages/lazy-import/CHANGELOG.md
index daea6da3404d3f..6853a036fdaa50 100644
--- a/packages/lazy-import/CHANGELOG.md
+++ b/packages/lazy-import/CHANGELOG.md
@@ -2,6 +2,8 @@
## Unreleased
+## 2.8.0 (2024-09-19)
+
## 2.7.0 (2024-09-05)
## 2.6.0 (2024-08-21)
diff --git a/packages/lazy-import/package.json b/packages/lazy-import/package.json
index 62aab79635e9c4..3fbc99c1e9dbad 100644
--- a/packages/lazy-import/package.json
+++ b/packages/lazy-import/package.json
@@ -1,6 +1,6 @@
{
"name": "@wordpress/lazy-import",
- "version": "2.7.0",
+ "version": "2.8.1",
"description": "Lazily import a module, installing it automatically if missing.",
"author": "The WordPress Contributors",
"license": "GPL-2.0-or-later",
diff --git a/packages/list-reusable-blocks/CHANGELOG.md b/packages/list-reusable-blocks/CHANGELOG.md
index 9f62523fd94e11..852f30a2999175 100644
--- a/packages/list-reusable-blocks/CHANGELOG.md
+++ b/packages/list-reusable-blocks/CHANGELOG.md
@@ -2,6 +2,8 @@
## Unreleased
+## 5.8.0 (2024-09-19)
+
## 5.7.0 (2024-09-05)
## 5.6.0 (2024-08-21)
diff --git a/packages/list-reusable-blocks/package.json b/packages/list-reusable-blocks/package.json
index 839532327faa24..7661b78ae44979 100644
--- a/packages/list-reusable-blocks/package.json
+++ b/packages/list-reusable-blocks/package.json
@@ -1,6 +1,6 @@
{
"name": "@wordpress/list-reusable-blocks",
- "version": "5.7.0",
+ "version": "5.8.10",
"description": "Adding Export/Import support to the reusable blocks listing.",
"author": "The WordPress Contributors",
"license": "GPL-2.0-or-later",
diff --git a/packages/list-reusable-blocks/src/components/import-dropdown/index.js b/packages/list-reusable-blocks/src/components/import-dropdown/index.js
index d20ba9fcf10999..fdad08f80d213c 100644
--- a/packages/list-reusable-blocks/src/components/import-dropdown/index.js
+++ b/packages/list-reusable-blocks/src/components/import-dropdown/index.js
@@ -17,8 +17,8 @@ function ImportDropdown( { onUpload } ) {
contentClassName="list-reusable-blocks-import-dropdown__content"
renderToggle={ ( { isOpen, onToggle } ) => (
{ children }
@@ -66,8 +65,7 @@ export function DotTip( {
Got it
@@ -27,7 +27,7 @@ exports[`DotTip should render correctly 1`] = `
);
diff --git a/test/e2e/specs/editor/blocks/cover.spec.js b/test/e2e/specs/editor/blocks/cover.spec.js
index cf3005c9eaa18f..a9a93caa4f4341 100644
--- a/test/e2e/specs/editor/blocks/cover.spec.js
+++ b/test/e2e/specs/editor/blocks/cover.spec.js
@@ -226,6 +226,77 @@ test.describe( 'Cover', () => {
await expect( overlay ).toHaveCSS( 'background-color', 'rgb(0, 0, 0)' );
await expect( overlay ).toHaveCSS( 'opacity', '0.5' );
} );
+
+ test( 'other cover blocks are not over the navigation block when the menu is open', async ( {
+ editor,
+ page,
+ } ) => {
+ // Insert a Cover block
+ await editor.insertBlock( { name: 'core/cover' } );
+ const coverBlock = editor.canvas.getByRole( 'document', {
+ name: 'Block: Cover',
+ } );
+
+ // Choose a color swatch to transform the placeholder block into
+ // a functioning block.
+ await coverBlock
+ .getByRole( 'option', {
+ name: 'Color: Black',
+ } )
+ .click();
+
+ // Insert a Navigation block inside the Cover block
+ await editor.selectBlocks( coverBlock );
+ await coverBlock.getByRole( 'button', { name: 'Add block' } ).click();
+ await page.keyboard.type( 'Navigation' );
+ const blockResults = page.getByRole( 'listbox', {
+ name: 'Blocks',
+ } );
+ const blockResultOptions = blockResults.getByRole( 'option' );
+ await blockResultOptions.nth( 0 ).click();
+
+ // Insert a second Cover block.
+ await editor.insertBlock( { name: 'core/cover' } );
+ const secondCoverBlock = editor.canvas
+ .getByRole( 'document', {
+ name: 'Block: Cover',
+ } )
+ .last();
+
+ // Choose a color swatch to transform the placeholder block into
+ // a functioning block.
+ await secondCoverBlock
+ .getByRole( 'option', {
+ name: 'Color: Black',
+ } )
+ .click();
+
+ // Set the viewport to a small screen and open menu.
+ await page.setViewportSize( { width: 375, height: 1000 } );
+ const navigationBlock = editor.canvas.getByRole( 'document', {
+ name: 'Block: Navigation',
+ } );
+ await editor.selectBlocks( navigationBlock );
+ await editor.canvas
+ .getByRole( 'button', { name: 'Open menu' } )
+ .click();
+
+ // Check if inner container of the second cover is clickable.
+ const secondInnerContainer = secondCoverBlock.locator(
+ '.wp-block-cover__inner-container'
+ );
+ let isClickable;
+ try {
+ isClickable = await secondInnerContainer.click( {
+ trial: true,
+ timeout: 1000, // This test will always take 1 second to run.
+ } );
+ } catch ( error ) {
+ isClickable = false;
+ }
+
+ expect( isClickable ).toBe( false );
+ } );
} );
class CoverBlockUtils {
diff --git a/test/e2e/specs/editor/various/__snapshots__/Copy-cut-paste-should-paste-link-to-formatted-text-1-chromium.txt b/test/e2e/specs/editor/various/__snapshots__/Copy-cut-paste-should-paste-link-to-formatted-text-1-chromium.txt
new file mode 100644
index 00000000000000..73ed4090549910
--- /dev/null
+++ b/test/e2e/specs/editor/various/__snapshots__/Copy-cut-paste-should-paste-link-to-formatted-text-1-chromium.txt
@@ -0,0 +1,3 @@
+
+test
+
\ No newline at end of file
diff --git a/test/e2e/specs/editor/various/block-bindings.spec.js b/test/e2e/specs/editor/various/block-bindings.spec.js
deleted file mode 100644
index c556c469698ebd..00000000000000
--- a/test/e2e/specs/editor/various/block-bindings.spec.js
+++ /dev/null
@@ -1,2415 +0,0 @@
-/**
- * External dependencies
- */
-const path = require( 'path' );
-/**
- * WordPress dependencies
- */
-const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' );
-
-test.describe( 'Block bindings', () => {
- let imagePlaceholderSrc;
- let imageCustomFieldSrc;
- test.beforeAll( async ( { requestUtils } ) => {
- await requestUtils.activateTheme( 'emptytheme' );
- await requestUtils.activatePlugin( 'gutenberg-test-block-bindings' );
- await requestUtils.deleteAllMedia();
- const placeholderMedia = await requestUtils.uploadMedia(
- path.join( './test/e2e/assets', '10x10_e2e_test_image_z9T8jK.png' )
- );
- imagePlaceholderSrc = placeholderMedia.source_url;
- } );
-
- test.afterEach( async ( { requestUtils } ) => {
- await requestUtils.deleteAllPosts();
- } );
-
- test.afterAll( async ( { requestUtils } ) => {
- await requestUtils.deleteAllMedia();
- await requestUtils.activateTheme( 'twentytwentyone' );
- await requestUtils.deactivatePlugin( 'gutenberg-test-block-bindings' );
- } );
-
- test.describe( 'Template context', () => {
- test.beforeEach( async ( { admin, editor } ) => {
- await admin.visitSiteEditor( {
- postId: 'emptytheme//index',
- postType: 'wp_template',
- canvas: 'edit',
- } );
- await editor.openDocumentSettingsSidebar();
- } );
-
- test.describe( 'Paragraph', () => {
- test( 'should show the key of the custom field in post meta', async ( {
- editor,
- } ) => {
- await editor.insertBlock( {
- name: 'core/paragraph',
- attributes: {
- content: 'paragraph default content',
- metadata: {
- bindings: {
- content: {
- source: 'core/post-meta',
- args: { key: 'text_custom_field' },
- },
- },
- },
- },
- } );
- const paragraphBlock = editor.canvas.getByRole( 'document', {
- name: 'Block: Paragraph',
- } );
- await expect( paragraphBlock ).toHaveText(
- 'text_custom_field'
- );
- } );
-
- test( 'should show the key of the custom field in server sources with key', async ( {
- editor,
- } ) => {
- await editor.insertBlock( {
- name: 'core/paragraph',
- attributes: {
- content: 'paragraph default content',
- metadata: {
- bindings: {
- content: {
- source: 'core/server-source',
- args: { key: 'text_custom_field' },
- },
- },
- },
- },
- } );
- const paragraphBlock = editor.canvas.getByRole( 'document', {
- name: 'Block: Paragraph',
- } );
- await expect( paragraphBlock ).toHaveText(
- 'text_custom_field'
- );
- } );
-
- test( 'should show the source label in server sources without key', async ( {
- editor,
- } ) => {
- await editor.insertBlock( {
- name: 'core/paragraph',
- attributes: {
- content: 'paragraph default content',
- metadata: {
- bindings: {
- content: {
- source: 'core/server-source',
- },
- },
- },
- },
- } );
- const paragraphBlock = editor.canvas.getByRole( 'document', {
- name: 'Block: Paragraph',
- } );
- await expect( paragraphBlock ).toHaveText( 'Server Source' );
- } );
-
- test( 'should lock the appropriate controls with a registered source', async ( {
- editor,
- page,
- } ) => {
- await editor.insertBlock( {
- name: 'core/paragraph',
- attributes: {
- content: 'paragraph default content',
- metadata: {
- bindings: {
- content: {
- source: 'core/post-meta',
- args: { key: 'text_custom_field' },
- },
- },
- },
- },
- } );
- const paragraphBlock = editor.canvas.getByRole( 'document', {
- name: 'Block: Paragraph',
- } );
- await paragraphBlock.click();
-
- // Alignment controls exist.
- await expect(
- page
- .getByRole( 'toolbar', { name: 'Block tools' } )
- .getByRole( 'button', { name: 'Align text' } )
- ).toBeVisible();
-
- // Format controls don't exist.
- await expect(
- page
- .getByRole( 'toolbar', { name: 'Block tools' } )
- .getByRole( 'button', {
- name: 'Bold',
- } )
- ).toBeHidden();
-
- // Paragraph is not editable.
- await expect( paragraphBlock ).toHaveAttribute(
- 'contenteditable',
- 'false'
- );
- } );
-
- test( 'should lock the appropriate controls when source is not defined', async ( {
- editor,
- page,
- } ) => {
- await editor.insertBlock( {
- name: 'core/paragraph',
- attributes: {
- content: 'paragraph default content',
- metadata: {
- bindings: {
- content: {
- source: 'plugin/undefined-source',
- args: { key: 'text_custom_field' },
- },
- },
- },
- },
- } );
- const paragraphBlock = editor.canvas.getByRole( 'document', {
- name: 'Block: Paragraph',
- } );
- await paragraphBlock.click();
-
- // Alignment controls exist.
- await expect(
- page
- .getByRole( 'toolbar', { name: 'Block tools' } )
- .getByRole( 'button', { name: 'Align text' } )
- ).toBeVisible();
-
- // Format controls don't exist.
- await expect(
- page
- .getByRole( 'toolbar', { name: 'Block tools' } )
- .getByRole( 'button', {
- name: 'Bold',
- } )
- ).toBeHidden();
-
- // Paragraph is not editable.
- await expect( paragraphBlock ).toHaveAttribute(
- 'contenteditable',
- 'false'
- );
- } );
- } );
-
- test.describe( 'Heading', () => {
- test( 'should show the key of the custom field', async ( {
- editor,
- } ) => {
- await editor.insertBlock( {
- name: 'core/heading',
- attributes: {
- content: 'heading default content',
- metadata: {
- bindings: {
- content: {
- source: 'core/post-meta',
- args: { key: 'text_custom_field' },
- },
- },
- },
- },
- } );
- const headingBlock = editor.canvas.getByRole( 'document', {
- name: 'Block: Heading',
- } );
- await expect( headingBlock ).toHaveText( 'text_custom_field' );
- } );
-
- test( 'should lock the appropriate controls with a registered source', async ( {
- editor,
- page,
- } ) => {
- await editor.insertBlock( {
- name: 'core/heading',
- attributes: {
- content: 'heading default content',
- metadata: {
- bindings: {
- content: {
- source: 'core/post-meta',
- args: { key: 'text_custom_field' },
- },
- },
- },
- },
- } );
- const headingBlock = editor.canvas.getByRole( 'document', {
- name: 'Block: Heading',
- } );
- await headingBlock.click();
-
- // Alignment controls exist.
- await expect(
- page
- .getByRole( 'toolbar', { name: 'Block tools' } )
- .getByRole( 'button', { name: 'Align text' } )
- ).toBeVisible();
-
- // Format controls don't exist.
- await expect(
- page
- .getByRole( 'toolbar', { name: 'Block tools' } )
- .getByRole( 'button', {
- name: 'Bold',
- } )
- ).toBeHidden();
-
- // Heading is not editable.
- await expect( headingBlock ).toHaveAttribute(
- 'contenteditable',
- 'false'
- );
- } );
-
- test( 'should lock the appropriate controls when source is not defined', async ( {
- editor,
- page,
- } ) => {
- await editor.insertBlock( {
- name: 'core/heading',
- attributes: {
- content: 'heading default content',
- metadata: {
- bindings: {
- content: {
- source: 'plugin/undefined-source',
- args: { key: 'text_custom_field' },
- },
- },
- },
- },
- } );
- const headingBlock = editor.canvas.getByRole( 'document', {
- name: 'Block: Heading',
- } );
- await headingBlock.click();
-
- // Alignment controls exist.
- await expect(
- page
- .getByRole( 'toolbar', { name: 'Block tools' } )
- .getByRole( 'button', { name: 'Align text' } )
- ).toBeVisible();
-
- // Format controls don't exist.
- await expect(
- page
- .getByRole( 'toolbar', { name: 'Block tools' } )
- .getByRole( 'button', {
- name: 'Bold',
- } )
- ).toBeHidden();
-
- // Heading is not editable.
- await expect( headingBlock ).toHaveAttribute(
- 'contenteditable',
- 'false'
- );
- } );
- } );
-
- test.describe( 'Button', () => {
- test( 'should show the key of the custom field when text is bound', async ( {
- editor,
- } ) => {
- await editor.insertBlock( {
- name: 'core/buttons',
- innerBlocks: [
- {
- name: 'core/button',
- attributes: {
- text: 'button default text',
- url: '#default-url',
- metadata: {
- bindings: {
- text: {
- source: 'core/post-meta',
- args: { key: 'text_custom_field' },
- },
- },
- },
- },
- },
- ],
- } );
- const buttonBlock = editor.canvas.getByRole( 'document', {
- name: 'Block: Button',
- exact: true,
- } );
- await expect( buttonBlock ).toHaveText( 'text_custom_field' );
- } );
-
- test( 'should lock text controls when text is bound to a registered source', async ( {
- editor,
- page,
- } ) => {
- await editor.insertBlock( {
- name: 'core/buttons',
- innerBlocks: [
- {
- name: 'core/button',
- attributes: {
- text: 'button default text',
- url: '#default-url',
- metadata: {
- bindings: {
- text: {
- source: 'core/post-meta',
- args: { key: 'text_custom_field' },
- },
- },
- },
- },
- },
- ],
- } );
- const buttonBlock = editor.canvas
- .getByRole( 'document', {
- name: 'Block: Button',
- exact: true,
- } )
- .getByRole( 'textbox' );
- await buttonBlock.click();
-
- // Alignment controls exist.
- await expect(
- page
- .getByRole( 'toolbar', { name: 'Block tools' } )
- .getByRole( 'button', { name: 'Align text' } )
- ).toBeVisible();
-
- // Format controls don't exist.
- await expect(
- page
- .getByRole( 'toolbar', { name: 'Block tools' } )
- .getByRole( 'button', {
- name: 'Bold',
- } )
- ).toBeHidden();
-
- // Button is not editable.
- await expect( buttonBlock ).toHaveAttribute(
- 'contenteditable',
- 'false'
- );
-
- // Link controls exist.
- await expect(
- page
- .getByRole( 'toolbar', { name: 'Block tools' } )
- .getByRole( 'button', { name: 'Unlink' } )
- ).toBeVisible();
- } );
-
- test( 'should lock text controls when text is bound to an undefined source', async ( {
- editor,
- page,
- } ) => {
- await editor.insertBlock( {
- name: 'core/buttons',
- innerBlocks: [
- {
- name: 'core/button',
- attributes: {
- text: 'button default text',
- url: '#default-url',
- metadata: {
- bindings: {
- text: {
- source: 'plugin/undefined-source',
- args: { key: 'text_custom_field' },
- },
- },
- },
- },
- },
- ],
- } );
- const buttonBlock = editor.canvas
- .getByRole( 'document', {
- name: 'Block: Button',
- exact: true,
- } )
- .getByRole( 'textbox' );
- await buttonBlock.click();
-
- // Alignment controls exist.
- await expect(
- page
- .getByRole( 'toolbar', { name: 'Block tools' } )
- .getByRole( 'button', { name: 'Align text' } )
- ).toBeVisible();
-
- // Format controls don't exist.
- await expect(
- page
- .getByRole( 'toolbar', { name: 'Block tools' } )
- .getByRole( 'button', {
- name: 'Bold',
- } )
- ).toBeHidden();
-
- // Button is not editable.
- await expect( buttonBlock ).toHaveAttribute(
- 'contenteditable',
- 'false'
- );
-
- // Link controls exist.
- await expect(
- page
- .getByRole( 'toolbar', { name: 'Block tools' } )
- .getByRole( 'button', { name: 'Unlink' } )
- ).toBeVisible();
- } );
-
- test( 'should lock url controls when url is bound to a registered source', async ( {
- editor,
- page,
- } ) => {
- await editor.insertBlock( {
- name: 'core/buttons',
- innerBlocks: [
- {
- name: 'core/button',
- attributes: {
- text: 'button default text',
- url: '#default-url',
- metadata: {
- bindings: {
- url: {
- source: 'core/post-meta',
- args: { key: 'url_custom_field' },
- },
- },
- },
- },
- },
- ],
- } );
- const buttonBlock = editor.canvas
- .getByRole( 'document', {
- name: 'Block: Button',
- exact: true,
- } )
- .getByRole( 'textbox' );
- await buttonBlock.click();
-
- // Format controls exist.
- await expect(
- page
- .getByRole( 'toolbar', { name: 'Block tools' } )
- .getByRole( 'button', {
- name: 'Bold',
- } )
- ).toBeVisible();
-
- // Button is editable.
- await expect( buttonBlock ).toHaveAttribute(
- 'contenteditable',
- 'true'
- );
-
- // Link controls don't exist.
- await expect(
- page
- .getByRole( 'toolbar', { name: 'Block tools' } )
- .getByRole( 'button', { name: 'Link' } )
- ).toBeHidden();
- await expect(
- page
- .getByRole( 'toolbar', { name: 'Block tools' } )
- .getByRole( 'button', { name: 'Unlink' } )
- ).toBeHidden();
- } );
-
- test( 'should lock url controls when url is bound to an undefined source', async ( {
- editor,
- page,
- } ) => {
- await editor.insertBlock( {
- name: 'core/buttons',
- innerBlocks: [
- {
- name: 'core/button',
- attributes: {
- text: 'button default text',
- url: '#default-url',
- metadata: {
- bindings: {
- url: {
- source: 'plugin/undefined-source',
- args: { key: 'url_custom_field' },
- },
- },
- },
- },
- },
- ],
- } );
- const buttonBlock = editor.canvas
- .getByRole( 'document', {
- name: 'Block: Button',
- exact: true,
- } )
- .getByRole( 'textbox' );
- await buttonBlock.click();
-
- // Format controls exist.
- await expect(
- page
- .getByRole( 'toolbar', { name: 'Block tools' } )
- .getByRole( 'button', {
- name: 'Bold',
- } )
- ).toBeVisible();
-
- // Button is editable.
- await expect( buttonBlock ).toHaveAttribute(
- 'contenteditable',
- 'true'
- );
-
- // Link controls don't exist.
- await expect(
- page
- .getByRole( 'toolbar', { name: 'Block tools' } )
- .getByRole( 'button', { name: 'Link' } )
- ).toBeHidden();
- await expect(
- page
- .getByRole( 'toolbar', { name: 'Block tools' } )
- .getByRole( 'button', { name: 'Unlink' } )
- ).toBeHidden();
- } );
-
- test( 'should lock url and text controls when both are bound', async ( {
- editor,
- page,
- } ) => {
- await editor.insertBlock( {
- name: 'core/buttons',
- innerBlocks: [
- {
- name: 'core/button',
- attributes: {
- text: 'button default text',
- url: '#default-url',
- metadata: {
- bindings: {
- text: {
- source: 'core/post-meta',
- args: { key: 'text_custom_field' },
- },
- url: {
- source: 'core/post-meta',
- args: { key: 'url_custom_field' },
- },
- },
- },
- },
- },
- ],
- } );
- const buttonBlock = editor.canvas
- .getByRole( 'document', {
- name: 'Block: Button',
- exact: true,
- } )
- .getByRole( 'textbox' );
- await buttonBlock.click();
-
- // Alignment controls are visible.
- await expect(
- page
- .getByRole( 'toolbar', { name: 'Block tools' } )
- .getByRole( 'button', { name: 'Align text' } )
- ).toBeVisible();
-
- // Format controls don't exist.
- await expect(
- page
- .getByRole( 'toolbar', { name: 'Block tools' } )
- .getByRole( 'button', {
- name: 'Bold',
- } )
- ).toBeHidden();
-
- // Button is not editable.
- await expect( buttonBlock ).toHaveAttribute(
- 'contenteditable',
- 'false'
- );
-
- // Link controls don't exist.
- await expect(
- page
- .getByRole( 'toolbar', { name: 'Block tools' } )
- .getByRole( 'button', { name: 'Link' } )
- ).toBeHidden();
- await expect(
- page
- .getByRole( 'toolbar', { name: 'Block tools' } )
- .getByRole( 'button', { name: 'Unlink' } )
- ).toBeHidden();
- } );
- } );
-
- test.describe( 'Image', () => {
- test( 'should show the upload form when url is not bound', async ( {
- editor,
- } ) => {
- await editor.insertBlock( { name: 'core/image' } );
- const imageBlock = editor.canvas.getByRole( 'document', {
- name: 'Block: Image',
- } );
- await imageBlock.click();
- await expect(
- imageBlock.getByRole( 'button', { name: 'Upload' } )
- ).toBeVisible();
- } );
-
- test( 'should NOT show the upload form when url is bound to a registered source', async ( {
- editor,
- } ) => {
- await editor.insertBlock( {
- name: 'core/image',
- attributes: {
- url: imagePlaceholderSrc,
- alt: 'default alt value',
- title: 'default title value',
- metadata: {
- bindings: {
- url: {
- source: 'core/post-meta',
- args: { key: 'url_custom_field' },
- },
- },
- },
- },
- } );
- const imageBlock = editor.canvas.getByRole( 'document', {
- name: 'Block: Image',
- } );
- await imageBlock.click();
- await expect(
- imageBlock.getByRole( 'button', { name: 'Upload' } )
- ).toBeHidden();
- } );
-
- test( 'should NOT show the upload form when url is bound to an undefined source', async ( {
- editor,
- } ) => {
- await editor.insertBlock( {
- name: 'core/image',
- attributes: {
- url: imagePlaceholderSrc,
- alt: 'default alt value',
- title: 'default title value',
- metadata: {
- bindings: {
- url: {
- source: 'plugin/undefined-source',
- args: { key: 'url_custom_field' },
- },
- },
- },
- },
- } );
- const imageBlock = editor.canvas.getByRole( 'document', {
- name: 'Block: Image',
- } );
- await imageBlock.click();
- await expect(
- imageBlock.getByRole( 'button', { name: 'Upload' } )
- ).toBeHidden();
- } );
-
- test( 'should lock url controls when url is bound to a registered source', async ( {
- editor,
- page,
- } ) => {
- await editor.insertBlock( {
- name: 'core/image',
- attributes: {
- url: imagePlaceholderSrc,
- alt: 'default alt value',
- title: 'default title value',
- metadata: {
- bindings: {
- url: {
- source: 'core/post-meta',
- args: { key: 'url_custom_field' },
- },
- },
- },
- },
- } );
- const imageBlock = editor.canvas.getByRole( 'document', {
- name: 'Block: Image',
- } );
- await imageBlock.click();
-
- // Replace controls don't exist.
- await expect(
- page
- .getByRole( 'toolbar', { name: 'Block tools' } )
- .getByRole( 'button', {
- name: 'Replace',
- } )
- ).toBeHidden();
-
- // Image placeholder doesn't show the upload button.
- await expect(
- imageBlock.getByRole( 'button', { name: 'Upload' } )
- ).toBeHidden();
-
- // Alt textarea is enabled and with the original value.
- await expect(
- page
- .getByRole( 'tabpanel', { name: 'Settings' } )
- .getByLabel( 'Alternative text' )
- ).toBeEnabled();
- const altValue = await page
- .getByRole( 'tabpanel', { name: 'Settings' } )
- .getByLabel( 'Alternative text' )
- .inputValue();
- expect( altValue ).toBe( 'default alt value' );
-
- // Title input is enabled and with the original value.
- await page
- .getByRole( 'tabpanel', { name: 'Settings' } )
- .getByRole( 'button', { name: 'Advanced' } )
- .click();
-
- await expect(
- page
- .getByRole( 'tabpanel', { name: 'Settings' } )
- .getByLabel( 'Title attribute' )
- ).toBeEnabled();
- const titleValue = await page
- .getByRole( 'tabpanel', { name: 'Settings' } )
- .getByLabel( 'Title attribute' )
- .inputValue();
- expect( titleValue ).toBe( 'default title value' );
- } );
-
- test( 'should lock url controls when url is bound to an undefined source', async ( {
- editor,
- page,
- } ) => {
- await editor.insertBlock( {
- name: 'core/image',
- attributes: {
- url: imagePlaceholderSrc,
- alt: 'default alt value',
- title: 'default title value',
- metadata: {
- bindings: {
- url: {
- source: 'plugin/undefined-source',
- args: { key: 'url_custom_field' },
- },
- },
- },
- },
- } );
- const imageBlock = editor.canvas.getByRole( 'document', {
- name: 'Block: Image',
- } );
- await imageBlock.click();
-
- // Replace controls don't exist.
- await expect(
- page
- .getByRole( 'toolbar', { name: 'Block tools' } )
- .getByRole( 'button', {
- name: 'Replace',
- } )
- ).toBeHidden();
-
- // Image placeholder doesn't show the upload button.
- await expect(
- imageBlock.getByRole( 'button', { name: 'Upload' } )
- ).toBeHidden();
-
- // Alt textarea is enabled and with the original value.
- await expect(
- page
- .getByRole( 'tabpanel', { name: 'Settings' } )
- .getByLabel( 'Alternative text' )
- ).toBeEnabled();
- const altValue = await page
- .getByRole( 'tabpanel', { name: 'Settings' } )
- .getByLabel( 'Alternative text' )
- .inputValue();
- expect( altValue ).toBe( 'default alt value' );
-
- // Title input is enabled and with the original value.
- await page
- .getByRole( 'tabpanel', { name: 'Settings' } )
- .getByRole( 'button', { name: 'Advanced' } )
- .click();
-
- await expect(
- page
- .getByRole( 'tabpanel', { name: 'Settings' } )
- .getByLabel( 'Title attribute' )
- ).toBeEnabled();
- const titleValue = await page
- .getByRole( 'tabpanel', { name: 'Settings' } )
- .getByLabel( 'Title attribute' )
- .inputValue();
- expect( titleValue ).toBe( 'default title value' );
- } );
-
- test( 'should disable alt textarea when alt is bound to a registered source', async ( {
- editor,
- page,
- } ) => {
- await editor.insertBlock( {
- name: 'core/image',
- attributes: {
- url: imagePlaceholderSrc,
- alt: 'default alt value',
- title: 'default title value',
- metadata: {
- bindings: {
- alt: {
- source: 'core/post-meta',
- args: { key: 'text_custom_field' },
- },
- },
- },
- },
- } );
- const imageBlock = editor.canvas.getByRole( 'document', {
- name: 'Block: Image',
- } );
- await imageBlock.click();
-
- // Replace controls exist.
- await expect(
- page
- .getByRole( 'toolbar', { name: 'Block tools' } )
- .getByRole( 'button', {
- name: 'Replace',
- } )
- ).toBeVisible();
-
- // Alt textarea is disabled and with the custom field value.
- await expect(
- page
- .getByRole( 'tabpanel', { name: 'Settings' } )
- .getByLabel( 'Alternative text' )
- ).toHaveAttribute( 'readonly' );
- const altValue = await page
- .getByRole( 'tabpanel', { name: 'Settings' } )
- .getByLabel( 'Alternative text' )
- .inputValue();
- expect( altValue ).toBe( 'text_custom_field' );
-
- // Title input is enabled and with the original value.
- await page
- .getByRole( 'tabpanel', { name: 'Settings' } )
- .getByRole( 'button', { name: 'Advanced' } )
- .click();
- await expect(
- page
- .getByRole( 'tabpanel', { name: 'Settings' } )
- .getByLabel( 'Title attribute' )
- ).toBeEnabled();
- const titleValue = await page
- .getByRole( 'tabpanel', { name: 'Settings' } )
- .getByLabel( 'Title attribute' )
- .inputValue();
- expect( titleValue ).toBe( 'default title value' );
- } );
-
- test( 'should disable alt textarea when alt is bound to an undefined source', async ( {
- editor,
- page,
- } ) => {
- await editor.insertBlock( {
- name: 'core/image',
- attributes: {
- url: imagePlaceholderSrc,
- alt: 'default alt value',
- title: 'default title value',
- metadata: {
- bindings: {
- alt: {
- source: 'plguin/undefined-source',
- args: { key: 'text_custom_field' },
- },
- },
- },
- },
- } );
- const imageBlock = editor.canvas.getByRole( 'document', {
- name: 'Block: Image',
- } );
- await imageBlock.click();
-
- // Replace controls exist.
- await expect(
- page
- .getByRole( 'toolbar', { name: 'Block tools' } )
- .getByRole( 'button', {
- name: 'Replace',
- } )
- ).toBeVisible();
-
- // Alt textarea is disabled and with the custom field value.
- await expect(
- page
- .getByRole( 'tabpanel', { name: 'Settings' } )
- .getByLabel( 'Alternative text' )
- ).toHaveAttribute( 'readonly' );
- const altValue = await page
- .getByRole( 'tabpanel', { name: 'Settings' } )
- .getByLabel( 'Alternative text' )
- .inputValue();
- expect( altValue ).toBe( 'default alt value' );
-
- // Title input is enabled and with the original value.
- await page
- .getByRole( 'tabpanel', { name: 'Settings' } )
- .getByRole( 'button', { name: 'Advanced' } )
- .click();
- await expect(
- page
- .getByRole( 'tabpanel', { name: 'Settings' } )
- .getByLabel( 'Title attribute' )
- ).toBeEnabled();
- const titleValue = await page
- .getByRole( 'tabpanel', { name: 'Settings' } )
- .getByLabel( 'Title attribute' )
- .inputValue();
- expect( titleValue ).toBe( 'default title value' );
- } );
-
- test( 'should disable title input when title is bound to a registered source', async ( {
- editor,
- page,
- } ) => {
- await editor.insertBlock( {
- name: 'core/image',
- attributes: {
- url: imagePlaceholderSrc,
- alt: 'default alt value',
- title: 'default title value',
- metadata: {
- bindings: {
- title: {
- source: 'core/post-meta',
- args: { key: 'text_custom_field' },
- },
- },
- },
- },
- } );
- const imageBlock = editor.canvas.getByRole( 'document', {
- name: 'Block: Image',
- } );
- await imageBlock.click();
-
- // Replace controls exist.
- await expect(
- page
- .getByRole( 'toolbar', { name: 'Block tools' } )
- .getByRole( 'button', {
- name: 'Replace',
- } )
- ).toBeVisible();
-
- // Alt textarea is enabled and with the original value.
- await expect(
- page
- .getByRole( 'tabpanel', { name: 'Settings' } )
- .getByLabel( 'Alternative text' )
- ).toBeEnabled();
- const altValue = await page
- .getByRole( 'tabpanel', { name: 'Settings' } )
- .getByLabel( 'Alternative text' )
- .inputValue();
- expect( altValue ).toBe( 'default alt value' );
-
- // Title input is disabled and with the custom field value.
- await page
- .getByRole( 'tabpanel', { name: 'Settings' } )
- .getByRole( 'button', { name: 'Advanced' } )
- .click();
- await expect(
- page
- .getByRole( 'tabpanel', { name: 'Settings' } )
- .getByLabel( 'Title attribute' )
- ).toHaveAttribute( 'readonly' );
- const titleValue = await page
- .getByRole( 'tabpanel', { name: 'Settings' } )
- .getByLabel( 'Title attribute' )
- .inputValue();
- expect( titleValue ).toBe( 'text_custom_field' );
- } );
-
- test( 'should disable title input when title is bound to an undefined source', async ( {
- editor,
- page,
- } ) => {
- await editor.insertBlock( {
- name: 'core/image',
- attributes: {
- url: imagePlaceholderSrc,
- alt: 'default alt value',
- title: 'default title value',
- metadata: {
- bindings: {
- title: {
- source: 'plugin/undefined-source',
- args: { key: 'text_custom_field' },
- },
- },
- },
- },
- } );
- const imageBlock = editor.canvas.getByRole( 'document', {
- name: 'Block: Image',
- } );
- await imageBlock.click();
-
- // Replace controls exist.
- await expect(
- page
- .getByRole( 'toolbar', { name: 'Block tools' } )
- .getByRole( 'button', {
- name: 'Replace',
- } )
- ).toBeVisible();
-
- // Alt textarea is enabled and with the original value.
- await expect(
- page
- .getByRole( 'tabpanel', { name: 'Settings' } )
- .getByLabel( 'Alternative text' )
- ).toBeEnabled();
- const altValue = await page
- .getByRole( 'tabpanel', { name: 'Settings' } )
- .getByLabel( 'Alternative text' )
- .inputValue();
- expect( altValue ).toBe( 'default alt value' );
-
- // Title input is disabled and with the custom field value.
- await page
- .getByRole( 'tabpanel', { name: 'Settings' } )
- .getByRole( 'button', { name: 'Advanced' } )
- .click();
- await expect(
- page
- .getByRole( 'tabpanel', { name: 'Settings' } )
- .getByLabel( 'Title attribute' )
- ).toHaveAttribute( 'readonly' );
- const titleValue = await page
- .getByRole( 'tabpanel', { name: 'Settings' } )
- .getByLabel( 'Title attribute' )
- .inputValue();
- expect( titleValue ).toBe( 'default title value' );
- } );
-
- test( 'Multiple bindings should lock the appropriate controls', async ( {
- editor,
- page,
- } ) => {
- await editor.insertBlock( {
- name: 'core/image',
- attributes: {
- url: imagePlaceholderSrc,
- alt: 'default alt value',
- title: 'default title value',
- metadata: {
- bindings: {
- url: {
- source: 'core/post-meta',
- args: { key: 'url_custom_field' },
- },
- alt: {
- source: 'core/post-meta',
- args: { key: 'text_custom_field' },
- },
- },
- },
- },
- } );
- const imageBlock = editor.canvas.getByRole( 'document', {
- name: 'Block: Image',
- } );
- await imageBlock.click();
-
- // Replace controls don't exist.
- await expect(
- page
- .getByRole( 'toolbar', { name: 'Block tools' } )
- .getByRole( 'button', {
- name: 'Replace',
- } )
- ).toBeHidden();
-
- // Image placeholder doesn't show the upload button.
- await expect(
- imageBlock.getByRole( 'button', { name: 'Upload' } )
- ).toBeHidden();
-
- // Alt textarea is disabled and with the custom field value.
- await expect(
- page
- .getByRole( 'tabpanel', { name: 'Settings' } )
- .getByLabel( 'Alternative text' )
- ).toHaveAttribute( 'readonly' );
- const altValue = await page
- .getByRole( 'tabpanel', { name: 'Settings' } )
- .getByLabel( 'Alternative text' )
- .inputValue();
- expect( altValue ).toBe( 'text_custom_field' );
-
- // Title input is enabled and with the original value.
- await page
- .getByRole( 'tabpanel', { name: 'Settings' } )
- .getByRole( 'button', { name: 'Advanced' } )
- .click();
- await expect(
- page
- .getByRole( 'tabpanel', { name: 'Settings' } )
- .getByLabel( 'Title attribute' )
- ).toBeEnabled();
- const titleValue = await page
- .getByRole( 'tabpanel', { name: 'Settings' } )
- .getByLabel( 'Title attribute' )
- .inputValue();
- expect( titleValue ).toBe( 'default title value' );
- } );
- } );
- } );
-
- test.describe( 'Post/page context', () => {
- test.beforeEach( async ( { admin } ) => {
- await admin.createNewPost( { title: 'Test bindings' } );
- } );
- test.describe( 'Paragraph', () => {
- test( 'should show the value of the custom field when exists', async ( {
- editor,
- } ) => {
- await editor.insertBlock( {
- name: 'core/paragraph',
- attributes: {
- anchor: 'paragraph-binding',
- content: 'paragraph default content',
- metadata: {
- bindings: {
- content: {
- source: 'core/post-meta',
- args: { key: 'text_custom_field' },
- },
- },
- },
- },
- } );
- const paragraphBlock = editor.canvas.getByRole( 'document', {
- name: 'Block: Paragraph',
- } );
- await expect( paragraphBlock ).toHaveText(
- 'Value of the text custom field'
- );
-
- // Check the frontend shows the value of the custom field.
- const previewPage = await editor.openPreviewPage();
- await expect(
- previewPage.locator( '#paragraph-binding' )
- ).toHaveText( 'Value of the text custom field' );
- } );
-
- test( "should show the value of the key when custom field doesn't exist", async ( {
- editor,
- } ) => {
- await editor.insertBlock( {
- name: 'core/paragraph',
- attributes: {
- anchor: 'paragraph-binding',
- content: 'fallback value',
- metadata: {
- bindings: {
- content: {
- source: 'core/post-meta',
- args: { key: 'non_existing_custom_field' },
- },
- },
- },
- },
- } );
- const paragraphBlock = editor.canvas.getByRole( 'document', {
- name: 'Block: Paragraph',
- } );
- await expect( paragraphBlock ).toHaveText(
- 'non_existing_custom_field'
- );
- // Paragraph is not editable.
- await expect( paragraphBlock ).toHaveAttribute(
- 'contenteditable',
- 'false'
- );
-
- // Check the frontend doesn't show the content.
- const previewPage = await editor.openPreviewPage();
- await expect(
- previewPage.locator( '#paragraph-binding' )
- ).toHaveText( 'fallback value' );
- } );
-
- test( 'should show the prompt placeholder in field with empty value', async ( {
- editor,
- } ) => {
- await editor.insertBlock( {
- name: 'core/paragraph',
- attributes: {
- content: 'paragraph default content',
- metadata: {
- bindings: {
- content: {
- source: 'core/post-meta',
- args: { key: 'empty_field' },
- },
- },
- },
- },
- } );
-
- const paragraphBlock = editor.canvas.getByRole( 'document', {
- // Aria-label is changed for empty paragraphs.
- name: 'Add empty_field',
- } );
-
- await expect( paragraphBlock ).toBeEmpty();
-
- const placeholder = paragraphBlock.locator( 'span' );
- await expect( placeholder ).toHaveAttribute(
- 'data-rich-text-placeholder',
- 'Add empty_field'
- );
- } );
-
- test( 'should not show the value of a protected meta field', async ( {
- editor,
- } ) => {
- await editor.insertBlock( {
- name: 'core/paragraph',
- attributes: {
- anchor: 'paragraph-binding',
- content: 'fallback value',
- metadata: {
- bindings: {
- content: {
- source: 'core/post-meta',
- args: { key: '_protected_field' },
- },
- },
- },
- },
- } );
- const paragraphBlock = editor.canvas.getByRole( 'document', {
- name: 'Block: Paragraph',
- } );
- await expect( paragraphBlock ).toHaveText( '_protected_field' );
- // Check the frontend doesn't show the content.
- const previewPage = await editor.openPreviewPage();
- await expect(
- previewPage.locator( '#paragraph-binding' )
- ).toHaveText( 'fallback value' );
- } );
-
- test( 'should not show the value of a meta field with `show_in_rest` false', async ( {
- editor,
- } ) => {
- await editor.insertBlock( {
- name: 'core/paragraph',
- attributes: {
- anchor: 'paragraph-binding',
- content: 'fallback value',
- metadata: {
- bindings: {
- content: {
- source: 'core/post-meta',
- args: { key: 'show_in_rest_false_field' },
- },
- },
- },
- },
- } );
- const paragraphBlock = editor.canvas.getByRole( 'document', {
- name: 'Block: Paragraph',
- } );
- await expect( paragraphBlock ).toHaveText(
- 'show_in_rest_false_field'
- );
- // Check the frontend doesn't show the content.
- const previewPage = await editor.openPreviewPage();
- await expect(
- previewPage.locator( '#paragraph-binding' )
- ).toHaveText( 'fallback value' );
- } );
-
- test( 'should add empty paragraph block when pressing enter', async ( {
- editor,
- page,
- } ) => {
- await editor.insertBlock( {
- name: 'core/paragraph',
- attributes: {
- content: 'paragraph default content',
- metadata: {
- bindings: {
- content: {
- source: 'core/post-meta',
- args: { key: 'text_custom_field' },
- },
- },
- },
- },
- } );
- // Select the paragraph and press Enter at the end of it.
- const paragraph = editor.canvas.getByRole( 'document', {
- name: 'Block: Paragraph',
- } );
- await editor.selectBlocks( paragraph );
- await page.keyboard.press( 'End' );
- await page.keyboard.press( 'Enter' );
- const [ initialParagraph, newEmptyParagraph ] =
- await editor.canvas
- .locator( '[data-type="core/paragraph"]' )
- .all();
- await expect( initialParagraph ).toHaveText(
- 'Value of the text custom field'
- );
- await expect( newEmptyParagraph ).toHaveText( '' );
- await expect( newEmptyParagraph ).toBeEditable();
- } );
-
- test( 'should NOT be possible to edit the value of the custom field when it is protected', async ( {
- editor,
- } ) => {
- await editor.insertBlock( {
- name: 'core/paragraph',
- attributes: {
- anchor: 'protected-field-binding',
- content: 'fallback value',
- metadata: {
- bindings: {
- content: {
- source: 'core/post-meta',
- args: { key: '_protected_field' },
- },
- },
- },
- },
- } );
-
- const protectedFieldBlock = editor.canvas.getByRole(
- 'document',
- {
- name: 'Block: Paragraph',
- }
- );
-
- await expect( protectedFieldBlock ).toHaveAttribute(
- 'contenteditable',
- 'false'
- );
- } );
-
- test( 'should NOT be possible to edit the value of the custom field when it is not shown in the REST API', async ( {
- editor,
- } ) => {
- await editor.insertBlock( {
- name: 'core/paragraph',
- attributes: {
- anchor: 'show-in-rest-false-binding',
- content: 'fallback value',
- metadata: {
- bindings: {
- content: {
- source: 'core/post-meta',
- args: { key: 'show_in_rest_false_field' },
- },
- },
- },
- },
- } );
-
- const showInRestFalseBlock = editor.canvas.getByRole(
- 'document',
- {
- name: 'Block: Paragraph',
- }
- );
-
- await expect( showInRestFalseBlock ).toHaveAttribute(
- 'contenteditable',
- 'false'
- );
- } );
- test( 'should show a selector for content', async ( {
- editor,
- page,
- } ) => {
- await editor.insertBlock( {
- name: 'core/paragraph',
- } );
- await page.getByLabel( 'Attributes options' ).click();
- const contentAttribute = page.getByRole( 'menuitemcheckbox', {
- name: 'Show content',
- } );
- await expect( contentAttribute ).toBeVisible();
- } );
- test( 'should use a selector to update the content', async ( {
- editor,
- page,
- } ) => {
- await editor.insertBlock( {
- name: 'core/paragraph',
- attributes: {
- content: 'fallback value',
- metadata: {
- bindings: {
- content: {
- source: 'core/post-meta',
- args: { key: 'undefined_field' },
- },
- },
- },
- },
- } );
- await page.getByRole( 'button', { name: 'content' } ).click();
-
- await page
- .getByRole( 'menuitemradio' )
- .filter( { hasText: 'text_custom_field' } )
- .click();
- const paragraphBlock = editor.canvas.getByRole( 'document', {
- name: 'Block: Paragraph',
- } );
- await expect( paragraphBlock ).toHaveText(
- 'Value of the text custom field'
- );
- } );
- } );
-
- test.describe( 'Heading', () => {
- test( 'should show the value of the custom field', async ( {
- editor,
- } ) => {
- await editor.insertBlock( {
- name: 'core/heading',
- attributes: {
- anchor: 'heading-binding',
- content: 'heading default content',
- metadata: {
- bindings: {
- content: {
- source: 'core/post-meta',
- args: { key: 'text_custom_field' },
- },
- },
- },
- },
- } );
- const headingBlock = editor.canvas.getByRole( 'document', {
- name: 'Block: Heading',
- } );
- await expect( headingBlock ).toHaveText(
- 'Value of the text custom field'
- );
-
- // Check the frontend shows the value of the custom field.
- const previewPage = await editor.openPreviewPage();
- await expect(
- previewPage.locator( '#heading-binding' )
- ).toHaveText( 'Value of the text custom field' );
- } );
-
- test( 'should add empty paragraph block when pressing enter', async ( {
- editor,
- page,
- } ) => {
- await editor.insertBlock( {
- name: 'core/heading',
- attributes: {
- anchor: 'heading-binding',
- content: 'heading default content',
- metadata: {
- bindings: {
- content: {
- source: 'core/post-meta',
- args: { key: 'text_custom_field' },
- },
- },
- },
- },
- } );
-
- // Select the heading and press Enter at the end of it.
- const heading = editor.canvas.getByRole( 'document', {
- name: 'Block: Heading',
- } );
- await editor.selectBlocks( heading );
- await page.keyboard.press( 'End' );
- await page.keyboard.press( 'Enter' );
- // Can't use `editor.getBlocks` because it doesn't return the meta value shown in the editor.
- const [ initialHeading, newEmptyParagraph ] =
- await editor.canvas.locator( '[data-block]' ).all();
- // First block should be the original block.
- await expect( initialHeading ).toHaveAttribute(
- 'data-type',
- 'core/heading'
- );
- await expect( initialHeading ).toHaveText(
- 'Value of the text custom field'
- );
- // Second block should be an empty paragraph block.
- await expect( newEmptyParagraph ).toHaveAttribute(
- 'data-type',
- 'core/paragraph'
- );
- await expect( newEmptyParagraph ).toHaveText( '' );
- await expect( newEmptyParagraph ).toBeEditable();
- } );
- test( 'should show a selector for content', async ( {
- editor,
- page,
- } ) => {
- await editor.insertBlock( {
- name: 'core/heading',
- } );
- await page.getByLabel( 'Attributes options' ).click();
- const contentAttribute = page.getByRole( 'menuitemcheckbox', {
- name: 'Show content',
- } );
- await expect( contentAttribute ).toBeVisible();
- } );
- } );
-
- test.describe( 'Button', () => {
- test( 'should show the value of the custom field when text is bound', async ( {
- editor,
- } ) => {
- await editor.insertBlock( {
- name: 'core/buttons',
- innerBlocks: [
- {
- name: 'core/button',
- attributes: {
- anchor: 'button-text-binding',
- text: 'button default text',
- url: '#default-url',
- metadata: {
- bindings: {
- text: {
- source: 'core/post-meta',
- args: { key: 'text_custom_field' },
- },
- },
- },
- },
- },
- ],
- } );
- const buttonBlock = editor.canvas
- .getByRole( 'document', {
- name: 'Block: Button',
- exact: true,
- } )
- .getByRole( 'textbox' );
- await buttonBlock.click();
- await expect( buttonBlock ).toHaveText(
- 'Value of the text custom field'
- );
-
- // Check the frontend shows the value of the custom field.
- const previewPage = await editor.openPreviewPage();
- const buttonDom = previewPage.locator(
- '#button-text-binding a'
- );
- await expect( buttonDom ).toHaveText(
- 'Value of the text custom field'
- );
- await expect( buttonDom ).toHaveAttribute(
- 'href',
- '#default-url'
- );
- } );
-
- test( 'should use the value of the custom field when url is bound', async ( {
- editor,
- } ) => {
- await editor.insertBlock( {
- name: 'core/buttons',
- innerBlocks: [
- {
- name: 'core/button',
- attributes: {
- anchor: 'button-url-binding',
- text: 'button default text',
- url: '#default-url',
- metadata: {
- bindings: {
- url: {
- source: 'core/post-meta',
- args: { key: 'url_custom_field' },
- },
- },
- },
- },
- },
- ],
- } );
-
- // Check the frontend shows the original value of the custom field.
- const previewPage = await editor.openPreviewPage();
- const buttonDom = previewPage.locator(
- '#button-url-binding a'
- );
- await expect( buttonDom ).toHaveText( 'button default text' );
- await expect( buttonDom ).toHaveAttribute(
- 'href',
- '#url-custom-field'
- );
- } );
-
- test( 'should use the values of the custom fields when text and url are bound', async ( {
- editor,
- } ) => {
- await editor.insertBlock( {
- name: 'core/buttons',
- innerBlocks: [
- {
- name: 'core/button',
- attributes: {
- anchor: 'button-multiple-bindings',
- text: 'button default text',
- url: '#default-url',
- metadata: {
- bindings: {
- text: {
- source: 'core/post-meta',
- args: { key: 'text_custom_field' },
- },
- url: {
- source: 'core/post-meta',
- args: { key: 'url_custom_field' },
- },
- },
- },
- },
- },
- ],
- } );
-
- // Check the frontend uses the values of the custom fields.
- const previewPage = await editor.openPreviewPage();
- const buttonDom = previewPage.locator(
- '#button-multiple-bindings a'
- );
- await expect( buttonDom ).toHaveText(
- 'Value of the text custom field'
- );
- await expect( buttonDom ).toHaveAttribute(
- 'href',
- '#url-custom-field'
- );
- } );
-
- test( 'should add empty button block when pressing enter', async ( {
- editor,
- page,
- } ) => {
- await editor.insertBlock( {
- name: 'core/buttons',
- innerBlocks: [
- {
- name: 'core/button',
- attributes: {
- anchor: 'button-text-binding',
- text: 'button default text',
- url: '#default-url',
- metadata: {
- bindings: {
- text: {
- source: 'core/post-meta',
- args: { key: 'text_custom_field' },
- },
- },
- },
- },
- },
- ],
- } );
- await editor.canvas
- .getByRole( 'document', {
- name: 'Block: Button',
- exact: true,
- } )
- .getByRole( 'textbox' )
- .click();
- await page.keyboard.press( 'End' );
- await page.keyboard.press( 'Enter' );
- const [ initialButton, newEmptyButton ] = await editor.canvas
- .locator( '[data-type="core/button"]' )
- .all();
- // First block should be the original block.
- await expect( initialButton ).toHaveText(
- 'Value of the text custom field'
- );
- // Second block should be an empty paragraph block.
- await expect( newEmptyButton ).toHaveText( '' );
- await expect( newEmptyButton ).toBeEditable();
- } );
- test( 'should show a selector for url, text, linkTarget and rel', async ( {
- editor,
- page,
- } ) => {
- await editor.insertBlock( {
- name: 'core/buttons',
- innerBlocks: [
- {
- name: 'core/button',
- },
- ],
- } );
- await editor.canvas
- .getByRole( 'document', {
- name: 'Block: Button',
- exact: true,
- } )
- .getByRole( 'textbox' )
- .click();
- await page
- .getByRole( 'tabpanel', {
- name: 'Settings',
- } )
- .getByLabel( 'Attributes options' )
- .click();
- const urlAttribute = page.getByRole( 'menuitemcheckbox', {
- name: 'Show url',
- } );
- await expect( urlAttribute ).toBeVisible();
- const textAttribute = page.getByRole( 'menuitemcheckbox', {
- name: 'Show text',
- } );
- await expect( textAttribute ).toBeVisible();
- const linkTargetAttribute = page.getByRole(
- 'menuitemcheckbox',
- {
- name: 'Show linkTarget',
- }
- );
- await expect( linkTargetAttribute ).toBeVisible();
- const relAttribute = page.getByRole( 'menuitemcheckbox', {
- name: 'Show rel',
- } );
- await expect( relAttribute ).toBeVisible();
- } );
- } );
-
- test.describe( 'Image', () => {
- test.beforeAll( async ( { requestUtils } ) => {
- const customFieldMedia = await requestUtils.uploadMedia(
- path.join(
- './test/e2e/assets',
- '1024x768_e2e_test_image_size.jpeg'
- )
- );
- imageCustomFieldSrc = customFieldMedia.source_url;
- } );
-
- test.beforeEach( async ( { editor, page, requestUtils } ) => {
- const postId = await editor.publishPost();
- await requestUtils.rest( {
- method: 'POST',
- path: '/wp/v2/posts/' + postId,
- data: {
- meta: {
- url_custom_field: imageCustomFieldSrc,
- },
- },
- } );
- await page.reload();
- } );
- test( 'should show the value of the custom field when url is bound', async ( {
- editor,
- } ) => {
- await editor.insertBlock( {
- name: 'core/image',
- attributes: {
- anchor: 'image-url-binding',
- url: imagePlaceholderSrc,
- alt: 'default alt value',
- title: 'default title value',
- metadata: {
- bindings: {
- url: {
- source: 'core/post-meta',
- args: { key: 'url_custom_field' },
- },
- },
- },
- },
- } );
- const imageBlockImg = editor.canvas
- .getByRole( 'document', {
- name: 'Block: Image',
- } )
- .locator( 'img' );
- await expect( imageBlockImg ).toHaveAttribute(
- 'src',
- imageCustomFieldSrc
- );
-
- // Check the frontend uses the value of the custom field.
- const previewPage = await editor.openPreviewPage();
- const imageDom = previewPage.locator(
- '#image-url-binding img'
- );
- await expect( imageDom ).toHaveAttribute(
- 'src',
- imageCustomFieldSrc
- );
- await expect( imageDom ).toHaveAttribute(
- 'alt',
- 'default alt value'
- );
- await expect( imageDom ).toHaveAttribute(
- 'title',
- 'default title value'
- );
- } );
-
- test( 'should show value of the custom field in the alt textarea when alt is bound', async ( {
- editor,
- page,
- } ) => {
- await editor.insertBlock( {
- name: 'core/image',
- attributes: {
- anchor: 'image-alt-binding',
- url: imagePlaceholderSrc,
- alt: 'default alt value',
- title: 'default title value',
- metadata: {
- bindings: {
- alt: {
- source: 'core/post-meta',
- args: { key: 'text_custom_field' },
- },
- },
- },
- },
- } );
- const imageBlockImg = editor.canvas
- .getByRole( 'document', {
- name: 'Block: Image',
- } )
- .locator( 'img' );
- await imageBlockImg.click();
-
- // Image src is the placeholder.
- await expect( imageBlockImg ).toHaveAttribute(
- 'src',
- imagePlaceholderSrc
- );
-
- // Alt textarea should have the custom field value.
- const altValue = await page
- .getByRole( 'tabpanel', { name: 'Settings' } )
- .getByLabel( 'Alternative text' )
- .inputValue();
- expect( altValue ).toBe( 'Value of the text custom field' );
-
- // Check the frontend uses the value of the custom field.
- const previewPage = await editor.openPreviewPage();
- const imageDom = previewPage.locator(
- '#image-alt-binding img'
- );
- await expect( imageDom ).toHaveAttribute(
- 'src',
- imagePlaceholderSrc
- );
- await expect( imageDom ).toHaveAttribute(
- 'alt',
- 'Value of the text custom field'
- );
- await expect( imageDom ).toHaveAttribute(
- 'title',
- 'default title value'
- );
- } );
-
- test( 'should show value of the custom field in the title input when title is bound', async ( {
- editor,
- page,
- } ) => {
- await editor.insertBlock( {
- name: 'core/image',
- attributes: {
- anchor: 'image-title-binding',
- url: imagePlaceholderSrc,
- alt: 'default alt value',
- title: 'default title value',
- metadata: {
- bindings: {
- title: {
- source: 'core/post-meta',
- args: { key: 'text_custom_field' },
- },
- },
- },
- },
- } );
- const imageBlockImg = editor.canvas
- .getByRole( 'document', {
- name: 'Block: Image',
- } )
- .locator( 'img' );
- await imageBlockImg.click();
-
- // Image src is the placeholder.
- await expect( imageBlockImg ).toHaveAttribute(
- 'src',
- imagePlaceholderSrc
- );
-
- // Title input should have the custom field value.
- const advancedButton = page
- .getByRole( 'tabpanel', { name: 'Settings' } )
- .getByRole( 'button', {
- name: 'Advanced',
- } );
- const isAdvancedPanelOpen =
- await advancedButton.getAttribute( 'aria-expanded' );
- if ( isAdvancedPanelOpen === 'false' ) {
- await advancedButton.click();
- }
- const titleValue = await page
- .getByRole( 'tabpanel', { name: 'Settings' } )
- .getByLabel( 'Title attribute' )
- .inputValue();
- expect( titleValue ).toBe( 'Value of the text custom field' );
-
- // Check the frontend uses the value of the custom field.
- const previewPage = await editor.openPreviewPage();
- const imageDom = previewPage.locator(
- '#image-title-binding img'
- );
- await expect( imageDom ).toHaveAttribute(
- 'src',
- imagePlaceholderSrc
- );
- await expect( imageDom ).toHaveAttribute(
- 'alt',
- 'default alt value'
- );
- await expect( imageDom ).toHaveAttribute(
- 'title',
- 'Value of the text custom field'
- );
- } );
-
- test( 'Multiple bindings should show the value of the custom fields', async ( {
- editor,
- page,
- } ) => {
- await editor.insertBlock( {
- name: 'core/image',
- attributes: {
- anchor: 'image-multiple-bindings',
- url: imagePlaceholderSrc,
- alt: 'default alt value',
- title: 'default title value',
- metadata: {
- bindings: {
- url: {
- source: 'core/post-meta',
- args: { key: 'url_custom_field' },
- },
- alt: {
- source: 'core/post-meta',
- args: { key: 'text_custom_field' },
- },
- },
- },
- },
- } );
- const imageBlockImg = editor.canvas
- .getByRole( 'document', {
- name: 'Block: Image',
- } )
- .locator( 'img' );
- await imageBlockImg.click();
-
- // Image src is the custom field value.
- await expect( imageBlockImg ).toHaveAttribute(
- 'src',
- imageCustomFieldSrc
- );
-
- // Alt textarea should have the custom field value.
- const altValue = await page
- .getByRole( 'tabpanel', { name: 'Settings' } )
- .getByLabel( 'Alternative text' )
- .inputValue();
- expect( altValue ).toBe( 'Value of the text custom field' );
-
- // Title input should have the original value.
- const advancedButton = page
- .getByRole( 'tabpanel', { name: 'Settings' } )
- .getByRole( 'button', {
- name: 'Advanced',
- } );
- const isAdvancedPanelOpen =
- await advancedButton.getAttribute( 'aria-expanded' );
- if ( isAdvancedPanelOpen === 'false' ) {
- await advancedButton.click();
- }
- const titleValue = await page
- .getByRole( 'tabpanel', { name: 'Settings' } )
- .getByLabel( 'Title attribute' )
- .inputValue();
- expect( titleValue ).toBe( 'default title value' );
-
- // Check the frontend uses the values of the custom fields.
- const previewPage = await editor.openPreviewPage();
- const imageDom = previewPage.locator(
- '#image-multiple-bindings img'
- );
- await expect( imageDom ).toHaveAttribute(
- 'src',
- imageCustomFieldSrc
- );
- await expect( imageDom ).toHaveAttribute(
- 'alt',
- 'Value of the text custom field'
- );
- await expect( imageDom ).toHaveAttribute(
- 'title',
- 'default title value'
- );
- } );
- test( 'should show a selector for url, id, title and alt', async ( {
- editor,
- page,
- } ) => {
- await editor.insertBlock( {
- name: 'core/image',
- } );
- await page
- .getByRole( 'tabpanel', {
- name: 'Settings',
- } )
- .getByLabel( 'Attributes options' )
- .click();
- const urlAttribute = page.getByRole( 'menuitemcheckbox', {
- name: 'Show url',
- } );
- await expect( urlAttribute ).toBeVisible();
- const idAttribute = page.getByRole( 'menuitemcheckbox', {
- name: 'Show id',
- } );
- await expect( idAttribute ).toBeVisible();
- const titleAttribute = page.getByRole( 'menuitemcheckbox', {
- name: 'Show title',
- } );
- await expect( titleAttribute ).toBeVisible();
- const altAttribute = page.getByRole( 'menuitemcheckbox', {
- name: 'Show alt',
- } );
- await expect( altAttribute ).toBeVisible();
- } );
- } );
-
- test.describe( 'Edit custom fields', () => {
- test( 'should be possible to edit the value of the custom field from the paragraph', async ( {
- editor,
- } ) => {
- await editor.insertBlock( {
- name: 'core/paragraph',
- attributes: {
- anchor: 'paragraph-binding',
- content: 'paragraph default content',
- metadata: {
- bindings: {
- content: {
- source: 'core/post-meta',
- args: { key: 'text_custom_field' },
- },
- },
- },
- },
- } );
- const paragraphBlock = editor.canvas.getByRole( 'document', {
- name: 'Block: Paragraph',
- } );
-
- await expect( paragraphBlock ).toHaveAttribute(
- 'contenteditable',
- 'true'
- );
- await paragraphBlock.fill( 'new value' );
- // Check that the paragraph content attribute didn't change.
- const [ paragraphBlockObject ] = await editor.getBlocks();
- expect( paragraphBlockObject.attributes.content ).toBe(
- 'paragraph default content'
- );
- // Check the value of the custom field is being updated by visiting the frontend.
- const previewPage = await editor.openPreviewPage();
- await expect(
- previewPage.locator( '#paragraph-binding' )
- ).toHaveText( 'new value' );
- } );
-
- // Related issue: https://github.com/WordPress/gutenberg/issues/62347
- test( 'should be possible to use symbols and numbers as the custom field value', async ( {
- editor,
- } ) => {
- await editor.insertBlock( {
- name: 'core/paragraph',
- attributes: {
- anchor: 'paragraph-binding',
- content: 'paragraph default content',
- metadata: {
- bindings: {
- content: {
- source: 'core/post-meta',
- args: { key: 'text_custom_field' },
- },
- },
- },
- },
- } );
- const paragraphBlock = editor.canvas.getByRole( 'document', {
- name: 'Block: Paragraph',
- } );
-
- await expect( paragraphBlock ).toHaveAttribute(
- 'contenteditable',
- 'true'
- );
- await paragraphBlock.fill( '$10.00' );
- // Check the value of the custom field is being updated by visiting the frontend.
- const previewPage = await editor.openPreviewPage();
- await expect(
- previewPage.locator( '#paragraph-binding' )
- ).toHaveText( '$10.00' );
- } );
-
- test( 'should be possible to edit the value of the url custom field from the button', async ( {
- editor,
- page,
- pageUtils,
- } ) => {
- await editor.insertBlock( {
- name: 'core/buttons',
- innerBlocks: [
- {
- name: 'core/button',
- attributes: {
- anchor: 'button-url-binding',
- text: 'button default text',
- url: '#default-url',
- metadata: {
- bindings: {
- url: {
- source: 'core/post-meta',
- args: { key: 'url_custom_field' },
- },
- },
- },
- },
- },
- ],
- } );
-
- // Edit the url.
- const buttonBlock = editor.canvas
- .getByRole( 'document', {
- name: 'Block: Button',
- exact: true,
- } )
- .getByRole( 'textbox' );
- await buttonBlock.click();
- await page
- .getByRole( 'button', { name: 'Edit link', exact: true } )
- .click();
- await page
- .getByPlaceholder( 'Search or type URL' )
- .fill( '#url-custom-field-modified' );
- await pageUtils.pressKeys( 'Enter' );
-
- // Check that the button url attribute didn't change.
- const [ buttonsObject ] = await editor.getBlocks();
- expect( buttonsObject.innerBlocks[ 0 ].attributes.url ).toBe(
- '#default-url'
- );
- // Check the value of the custom field is being updated by visiting the frontend.
- const previewPage = await editor.openPreviewPage();
- await expect(
- previewPage.locator( '#button-url-binding a' )
- ).toHaveAttribute( 'href', '#url-custom-field-modified' );
- } );
-
- test( 'should be possible to edit the value of the url custom field from the image', async ( {
- editor,
- page,
- pageUtils,
- requestUtils,
- } ) => {
- const customFieldMedia = await requestUtils.uploadMedia(
- path.join(
- './test/e2e/assets',
- '1024x768_e2e_test_image_size.jpeg'
- )
- );
- imageCustomFieldSrc = customFieldMedia.source_url;
-
- await editor.insertBlock( {
- name: 'core/image',
- attributes: {
- anchor: 'image-url-binding',
- url: imagePlaceholderSrc,
- alt: 'default alt value',
- title: 'default title value',
- metadata: {
- bindings: {
- url: {
- source: 'core/post-meta',
- args: { key: 'url_custom_field' },
- },
- },
- },
- },
- } );
-
- // Edit image url.
- await page
- .getByRole( 'toolbar', { name: 'Block tools' } )
- .getByRole( 'button', {
- name: 'Replace',
- } )
- .click();
- await page
- .getByRole( 'button', { name: 'Edit link', exact: true } )
- .click();
- await page
- .getByPlaceholder( 'Search or type URL' )
- .fill( imageCustomFieldSrc );
- await pageUtils.pressKeys( 'Enter' );
-
- // Check that the image url attribute didn't change and still uses the placeholder.
- const [ imageBlockObject ] = await editor.getBlocks();
- expect( imageBlockObject.attributes.url ).toBe(
- imagePlaceholderSrc
- );
-
- // Check the value of the custom field is being updated by visiting the frontend.
- const previewPage = await editor.openPreviewPage();
- await expect(
- previewPage.locator( '#image-url-binding img' )
- ).toHaveAttribute( 'src', imageCustomFieldSrc );
- } );
-
- test( 'should be possible to edit the value of the text custom field from the image alt', async ( {
- editor,
- page,
- } ) => {
- await editor.insertBlock( {
- name: 'core/image',
- attributes: {
- anchor: 'image-alt-binding',
- url: imagePlaceholderSrc,
- alt: 'default alt value',
- metadata: {
- bindings: {
- alt: {
- source: 'core/post-meta',
- args: { key: 'text_custom_field' },
- },
- },
- },
- },
- } );
- const imageBlockImg = editor.canvas
- .getByRole( 'document', {
- name: 'Block: Image',
- } )
- .locator( 'img' );
- await imageBlockImg.click();
-
- // Edit the custom field value in the alt textarea.
- const altInputArea = page
- .getByRole( 'tabpanel', { name: 'Settings' } )
- .getByLabel( 'Alternative text' );
- await expect( altInputArea ).not.toHaveAttribute( 'readonly' );
- await altInputArea.fill( 'new value' );
-
- // Check that the image alt attribute didn't change.
- const [ imageBlockObject ] = await editor.getBlocks();
- expect( imageBlockObject.attributes.alt ).toBe(
- 'default alt value'
- );
- // Check the value of the custom field is being updated by visiting the frontend.
- const previewPage = await editor.openPreviewPage();
- await expect(
- previewPage.locator( '#image-alt-binding img' )
- ).toHaveAttribute( 'alt', 'new value' );
- } );
- } );
- } );
-
- test.describe( 'Sources registration', () => {
- test.beforeEach( async ( { admin } ) => {
- await admin.createNewPost( { title: 'Test bindings' } );
- } );
-
- test( 'should show the label of a source only registered in the server', async ( {
- editor,
- page,
- } ) => {
- await editor.insertBlock( {
- name: 'core/paragraph',
- attributes: {
- metadata: {
- bindings: {
- content: {
- source: 'core/server-source',
- },
- },
- },
- },
- } );
-
- const bindingsPanel = page.locator(
- '.block-editor-bindings__panel'
- );
- await expect( bindingsPanel ).toContainText( 'Server Source' );
- } );
- } );
-} );
diff --git a/test/e2e/specs/editor/various/block-bindings/custom-sources.spec.js b/test/e2e/specs/editor/various/block-bindings/custom-sources.spec.js
new file mode 100644
index 00000000000000..d6563ce9cb5f5f
--- /dev/null
+++ b/test/e2e/specs/editor/various/block-bindings/custom-sources.spec.js
@@ -0,0 +1,1204 @@
+/**
+ * External dependencies
+ */
+const path = require( 'path' );
+/**
+ * WordPress dependencies
+ */
+const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' );
+
+test.describe( 'Registered sources', () => {
+ let imagePlaceholderSrc;
+ let testingImgSrc;
+ test.beforeAll( async ( { requestUtils } ) => {
+ await requestUtils.activateTheme(
+ 'gutenberg-test-themes/block-bindings'
+ );
+ await requestUtils.activatePlugin( 'gutenberg-test-block-bindings' );
+ await requestUtils.deleteAllMedia();
+ const placeholderMedia = await requestUtils.uploadMedia(
+ path.join( './test/e2e/assets', '10x10_e2e_test_image_z9T8jK.png' )
+ );
+ imagePlaceholderSrc = placeholderMedia.source_url;
+
+ const testingImgMedia = await requestUtils.uploadMedia(
+ path.join(
+ './test/e2e/assets',
+ '1024x768_e2e_test_image_size.jpeg'
+ )
+ );
+ testingImgSrc = testingImgMedia.source_url;
+ } );
+
+ test.beforeEach( async ( { admin } ) => {
+ await admin.createNewPost( { title: 'Test bindings' } );
+ } );
+
+ test.afterEach( async ( { requestUtils } ) => {
+ await requestUtils.deleteAllPosts();
+ } );
+
+ test.afterAll( async ( { requestUtils } ) => {
+ await requestUtils.deleteAllMedia();
+ await requestUtils.activateTheme( 'twentytwentyone' );
+ await requestUtils.deactivatePlugin( 'gutenberg-test-block-bindings' );
+ } );
+
+ test.describe( 'getValues', () => {
+ test( 'should show the returned value in paragraph content', async ( {
+ editor,
+ } ) => {
+ await editor.insertBlock( {
+ name: 'core/paragraph',
+ attributes: {
+ anchor: 'connected-paragraph',
+ content: 'paragraph default content',
+ metadata: {
+ bindings: {
+ content: {
+ source: 'testing/complete-source',
+ args: { key: 'text_field' },
+ },
+ },
+ },
+ },
+ } );
+ const paragraphBlock = editor.canvas.getByRole( 'document', {
+ name: 'Block: Paragraph',
+ } );
+ await expect( paragraphBlock ).toHaveText( 'Text Field Value' );
+
+ // Check the frontend shows the value of the custom field.
+ const previewPage = await editor.openPreviewPage();
+ await expect(
+ previewPage.locator( '#connected-paragraph' )
+ ).toHaveText( 'Text Field Value' );
+ } );
+ test( 'should show the returned value in heading content', async ( {
+ editor,
+ } ) => {
+ await editor.insertBlock( {
+ name: 'core/heading',
+ attributes: {
+ anchor: 'connected-heading',
+ content: 'heading default content',
+ metadata: {
+ bindings: {
+ content: {
+ source: 'testing/complete-source',
+ args: { key: 'text_field' },
+ },
+ },
+ },
+ },
+ } );
+ const headingBlock = editor.canvas.getByRole( 'document', {
+ name: 'Block: Heading',
+ } );
+ await expect( headingBlock ).toHaveText( 'Text Field Value' );
+
+ // Check the frontend shows the value of the custom field.
+ const previewPage = await editor.openPreviewPage();
+ await expect(
+ previewPage.locator( '#connected-heading' )
+ ).toHaveText( 'Text Field Value' );
+ } );
+ test( 'should show the returned values in button attributes', async ( {
+ editor,
+ } ) => {
+ await editor.insertBlock( {
+ name: 'core/buttons',
+ innerBlocks: [
+ {
+ name: 'core/button',
+ attributes: {
+ anchor: 'connected-button',
+ text: 'button default text',
+ url: '#default-url',
+ metadata: {
+ bindings: {
+ text: {
+ source: 'testing/complete-source',
+ args: { key: 'text_field' },
+ },
+ url: {
+ source: 'testing/complete-source',
+ args: { key: 'url_field' },
+ },
+ },
+ },
+ },
+ },
+ ],
+ } );
+
+ // Check the frontend uses the values of the custom fields.
+ const previewPage = await editor.openPreviewPage();
+ const buttonDom = previewPage.locator( '#connected-button a' );
+ await expect( buttonDom ).toHaveText( 'Text Field Value' );
+ await expect( buttonDom ).toHaveAttribute( 'href', testingImgSrc );
+ } );
+ test( 'should show the returned values in image attributes', async ( {
+ editor,
+ page,
+ } ) => {
+ await editor.insertBlock( {
+ name: 'core/image',
+ attributes: {
+ anchor: 'connected-image',
+ url: imagePlaceholderSrc,
+ alt: 'default alt value',
+ title: 'default title value',
+ metadata: {
+ bindings: {
+ url: {
+ source: 'testing/complete-source',
+ args: { key: 'url_field' },
+ },
+ alt: {
+ source: 'testing/complete-source',
+ args: { key: 'text_field' },
+ },
+ },
+ },
+ },
+ } );
+ const imageBlockImg = editor.canvas
+ .getByRole( 'document', {
+ name: 'Block: Image',
+ } )
+ .locator( 'img' );
+ await imageBlockImg.click();
+
+ // Image src is the custom field value.
+ await expect( imageBlockImg ).toHaveAttribute(
+ 'src',
+ testingImgSrc
+ );
+
+ // Alt textarea should have the custom field value.
+ const altValue = await page
+ .getByRole( 'tabpanel', { name: 'Settings' } )
+ .getByLabel( 'Alternative text' )
+ .inputValue();
+ expect( altValue ).toBe( 'Text Field Value' );
+
+ // Title input should have the original value.
+ const advancedButton = page
+ .getByRole( 'tabpanel', { name: 'Settings' } )
+ .getByRole( 'button', {
+ name: 'Advanced',
+ } );
+ const isAdvancedPanelOpen =
+ await advancedButton.getAttribute( 'aria-expanded' );
+ if ( isAdvancedPanelOpen === 'false' ) {
+ await advancedButton.click();
+ }
+ const titleValue = await page
+ .getByRole( 'tabpanel', { name: 'Settings' } )
+ .getByLabel( 'Title attribute' )
+ .inputValue();
+ expect( titleValue ).toBe( 'default title value' );
+
+ // Check the frontend uses the values of the custom fields.
+ const previewPage = await editor.openPreviewPage();
+ const imageDom = previewPage.locator( '#connected-image img' );
+ await expect( imageDom ).toHaveAttribute( 'src', testingImgSrc );
+ await expect( imageDom ).toHaveAttribute(
+ 'alt',
+ 'Text Field Value'
+ );
+ await expect( imageDom ).toHaveAttribute(
+ 'title',
+ 'default title value'
+ );
+ } );
+ test( 'should fall back to source label when `getValues` is undefined', async ( {
+ editor,
+ } ) => {
+ await editor.insertBlock( {
+ name: 'core/paragraph',
+ attributes: {
+ content: 'paragraph default content',
+ metadata: {
+ bindings: {
+ content: {
+ source: 'testing/server-only-source',
+ args: { key: 'text_field' },
+ },
+ },
+ },
+ },
+ } );
+ const paragraphBlock = editor.canvas.getByRole( 'document', {
+ name: 'Block: Paragraph',
+ } );
+ await expect( paragraphBlock ).toHaveText( 'Server Source' );
+ } );
+ test( 'should fall back to null when `getValues` is undefined in URL attributes', async ( {
+ editor,
+ } ) => {
+ await editor.insertBlock( {
+ name: 'core/image',
+ attributes: {
+ metadata: {
+ bindings: {
+ url: {
+ source: 'testing/server-only-source',
+ args: { key: 'url_field' },
+ },
+ },
+ },
+ },
+ } );
+ const imageBlock = editor.canvas.getByRole( 'document', {
+ name: 'Block: Image',
+ } );
+ await expect(
+ imageBlock.locator( '.components-placeholder__fieldset' )
+ ).toHaveText( 'Connected to Server Source' );
+ } );
+ } );
+
+ test.describe( 'should lock editing', () => {
+ // Logic reused accross all the tests that check paragraph editing is locked.
+ async function testParagraphControlsAreLocked( {
+ source,
+ editor,
+ page,
+ } ) {
+ await editor.insertBlock( {
+ name: 'core/paragraph',
+ attributes: {
+ content: 'paragraph default content',
+ metadata: {
+ bindings: {
+ content: {
+ source,
+ args: { key: 'text_field' },
+ },
+ },
+ },
+ },
+ } );
+ const paragraphBlock = editor.canvas.getByRole( 'document', {
+ name: 'Block: Paragraph',
+ } );
+ await paragraphBlock.click();
+
+ // Alignment controls exist.
+ await expect(
+ page
+ .getByRole( 'toolbar', { name: 'Block tools' } )
+ .getByRole( 'button', { name: 'Align text' } )
+ ).toBeVisible();
+
+ // Format controls don't exist.
+ await expect(
+ page
+ .getByRole( 'toolbar', { name: 'Block tools' } )
+ .getByRole( 'button', {
+ name: 'Bold',
+ } )
+ ).toBeHidden();
+
+ // Paragraph is not editable.
+ await expect( paragraphBlock ).toHaveAttribute(
+ 'contenteditable',
+ 'false'
+ );
+ }
+ test.describe( 'canUserEditValue returns false', () => {
+ test( 'paragraph', async ( { editor, page } ) => {
+ await testParagraphControlsAreLocked( {
+ source: 'testing/can-user-edit-false',
+ editor,
+ page,
+ } );
+ } );
+ test( 'heading', async ( { editor, page } ) => {
+ await editor.insertBlock( {
+ name: 'core/heading',
+ attributes: {
+ content: 'heading default content',
+ metadata: {
+ bindings: {
+ content: {
+ source: 'testing/can-user-edit-false',
+ args: { key: 'text_field' },
+ },
+ },
+ },
+ },
+ } );
+ const headingBlock = editor.canvas.getByRole( 'document', {
+ name: 'Block: Heading',
+ } );
+ await headingBlock.click();
+
+ // Alignment controls exist.
+ await expect(
+ page
+ .getByRole( 'toolbar', { name: 'Block tools' } )
+ .getByRole( 'button', { name: 'Align text' } )
+ ).toBeVisible();
+
+ // Format controls don't exist.
+ await expect(
+ page
+ .getByRole( 'toolbar', { name: 'Block tools' } )
+ .getByRole( 'button', {
+ name: 'Bold',
+ } )
+ ).toBeHidden();
+
+ // Heading is not editable.
+ await expect( headingBlock ).toHaveAttribute(
+ 'contenteditable',
+ 'false'
+ );
+ } );
+ test( 'button', async ( { editor, page } ) => {
+ await editor.insertBlock( {
+ name: 'core/buttons',
+ innerBlocks: [
+ {
+ name: 'core/button',
+ attributes: {
+ text: 'button default text',
+ url: '#default-url',
+ metadata: {
+ bindings: {
+ text: {
+ source: 'testing/can-user-edit-false',
+ args: { key: 'text_field' },
+ },
+ url: {
+ source: 'testing/can-user-edit-false',
+ args: { key: 'url_field' },
+ },
+ },
+ },
+ },
+ },
+ ],
+ } );
+ const buttonBlock = editor.canvas
+ .getByRole( 'document', {
+ name: 'Block: Button',
+ exact: true,
+ } )
+ .getByRole( 'textbox' );
+ await buttonBlock.click();
+
+ // Alignment controls are visible.
+ await expect(
+ page
+ .getByRole( 'toolbar', { name: 'Block tools' } )
+ .getByRole( 'button', { name: 'Align text' } )
+ ).toBeVisible();
+
+ // Format controls don't exist.
+ await expect(
+ page
+ .getByRole( 'toolbar', { name: 'Block tools' } )
+ .getByRole( 'button', {
+ name: 'Bold',
+ } )
+ ).toBeHidden();
+
+ // Button is not editable.
+ await expect( buttonBlock ).toHaveAttribute(
+ 'contenteditable',
+ 'false'
+ );
+
+ // Link controls don't exist.
+ await expect(
+ page
+ .getByRole( 'toolbar', { name: 'Block tools' } )
+ .getByRole( 'button', { name: 'Link' } )
+ ).toBeHidden();
+ await expect(
+ page
+ .getByRole( 'toolbar', { name: 'Block tools' } )
+ .getByRole( 'button', { name: 'Unlink' } )
+ ).toBeHidden();
+ } );
+ test( 'image', async ( { editor, page } ) => {
+ await editor.insertBlock( {
+ name: 'core/image',
+ attributes: {
+ url: imagePlaceholderSrc,
+ alt: 'default alt value',
+ title: 'default title value',
+ metadata: {
+ bindings: {
+ url: {
+ source: 'testing/can-user-edit-false',
+ args: { key: 'url_field' },
+ },
+ alt: {
+ source: 'testing/can-user-edit-false',
+ args: { key: 'text_field' },
+ },
+ title: {
+ source: 'testing/can-user-edit-false',
+ args: { key: 'text_field' },
+ },
+ },
+ },
+ },
+ } );
+ const imageBlock = editor.canvas.getByRole( 'document', {
+ name: 'Block: Image',
+ } );
+ await imageBlock.click();
+
+ // Replace controls don't exist.
+ await expect(
+ page
+ .getByRole( 'toolbar', { name: 'Block tools' } )
+ .getByRole( 'button', {
+ name: 'Replace',
+ } )
+ ).toBeHidden();
+
+ // Image placeholder doesn't show the upload button.
+ await expect(
+ imageBlock.getByRole( 'button', { name: 'Upload' } )
+ ).toBeHidden();
+
+ // Alt textarea is disabled and with the custom field value.
+ await expect(
+ page
+ .getByRole( 'tabpanel', { name: 'Settings' } )
+ .getByLabel( 'Alternative text' )
+ ).toHaveAttribute( 'readonly' );
+ const altValue = await page
+ .getByRole( 'tabpanel', { name: 'Settings' } )
+ .getByLabel( 'Alternative text' )
+ .inputValue();
+ expect( altValue ).toBe( 'Text Field Value' );
+
+ // Title input is enabled and with the original value.
+ await page
+ .getByRole( 'tabpanel', { name: 'Settings' } )
+ .getByRole( 'button', { name: 'Advanced' } )
+ .click();
+ await expect(
+ page
+ .getByRole( 'tabpanel', { name: 'Settings' } )
+ .getByLabel( 'Title attribute' )
+ ).toHaveAttribute( 'readonly' );
+ const titleValue = await page
+ .getByRole( 'tabpanel', { name: 'Settings' } )
+ .getByLabel( 'Title attribute' )
+ .inputValue();
+ expect( titleValue ).toBe( 'Text Field Value' );
+ } );
+ } );
+ // The following tests just check the paragraph and assume is the case for the rest of the blocks.
+ test( 'canUserEditValue is not defined', async ( { editor, page } ) => {
+ await testParagraphControlsAreLocked( {
+ source: 'testing/can-user-edit-undefined',
+ editor,
+ page,
+ } );
+ } );
+ test( 'setValues is not defined', async ( { editor, page } ) => {
+ await testParagraphControlsAreLocked( {
+ source: 'testing/complete-source-undefined',
+ editor,
+ page,
+ } );
+ } );
+ test( 'source is not defined', async ( { editor, page } ) => {
+ await testParagraphControlsAreLocked( {
+ source: 'testing/undefined-source',
+ editor,
+ page,
+ } );
+ } );
+ } );
+
+ // Use `core/post-meta` source to test editing to avoid overcomplicating custom sources.
+ // It needs a source that can be consumed and edited from the server and the editor.
+ test.describe( 'setValues', () => {
+ test( 'should be possible to edit the value from paragraph content', async ( {
+ editor,
+ } ) => {
+ await editor.insertBlock( {
+ name: 'core/paragraph',
+ attributes: {
+ anchor: 'connected-paragraph',
+ content: 'paragraph default content',
+ metadata: {
+ bindings: {
+ content: {
+ source: 'core/post-meta',
+ args: { key: 'text_custom_field' },
+ },
+ },
+ },
+ },
+ } );
+ const paragraphBlock = editor.canvas.getByRole( 'document', {
+ name: 'Block: Paragraph',
+ } );
+
+ await expect( paragraphBlock ).toHaveAttribute(
+ 'contenteditable',
+ 'true'
+ );
+ await paragraphBlock.fill( 'new value' );
+ // Check that the paragraph content attribute didn't change.
+ const [ paragraphBlockObject ] = await editor.getBlocks();
+ expect( paragraphBlockObject.attributes.content ).toBe(
+ 'paragraph default content'
+ );
+ // Check the value of the custom field is being updated by visiting the frontend.
+ const previewPage = await editor.openPreviewPage();
+ await expect(
+ previewPage.locator( '#connected-paragraph' )
+ ).toHaveText( 'new value' );
+ } );
+ // Related issue: https://github.com/WordPress/gutenberg/issues/62347
+ test( 'should be possible to use symbols and numbers as the custom field value', async ( {
+ editor,
+ } ) => {
+ await editor.insertBlock( {
+ name: 'core/paragraph',
+ attributes: {
+ anchor: 'paragraph-binding',
+ content: 'paragraph default content',
+ metadata: {
+ bindings: {
+ content: {
+ source: 'core/post-meta',
+ args: { key: 'text_custom_field' },
+ },
+ },
+ },
+ },
+ } );
+ const paragraphBlock = editor.canvas.getByRole( 'document', {
+ name: 'Block: Paragraph',
+ } );
+
+ await expect( paragraphBlock ).toHaveAttribute(
+ 'contenteditable',
+ 'true'
+ );
+ await paragraphBlock.fill( '$10.00' );
+ // Check the value of the custom field is being updated by visiting the frontend.
+ const previewPage = await editor.openPreviewPage();
+ await expect(
+ previewPage.locator( '#paragraph-binding' )
+ ).toHaveText( '$10.00' );
+ } );
+ test( 'should be possible to edit the value of the url custom field from the button', async ( {
+ editor,
+ page,
+ pageUtils,
+ } ) => {
+ await editor.insertBlock( {
+ name: 'core/buttons',
+ innerBlocks: [
+ {
+ name: 'core/button',
+ attributes: {
+ anchor: 'button-url-binding',
+ text: 'button default text',
+ url: '#default-url',
+ metadata: {
+ bindings: {
+ url: {
+ source: 'core/post-meta',
+ args: { key: 'url_custom_field' },
+ },
+ },
+ },
+ },
+ },
+ ],
+ } );
+
+ // Edit the url.
+ const buttonBlock = editor.canvas
+ .getByRole( 'document', {
+ name: 'Block: Button',
+ exact: true,
+ } )
+ .getByRole( 'textbox' );
+ await buttonBlock.click();
+ await page
+ .getByRole( 'button', { name: 'Edit link', exact: true } )
+ .click();
+ await page
+ .getByPlaceholder( 'Search or type URL' )
+ .fill( '#url-custom-field-modified' );
+ await pageUtils.pressKeys( 'Enter' );
+
+ // Check that the button url attribute didn't change.
+ const [ buttonsObject ] = await editor.getBlocks();
+ expect( buttonsObject.innerBlocks[ 0 ].attributes.url ).toBe(
+ '#default-url'
+ );
+ // Check the value of the custom field is being updated by visiting the frontend.
+ const previewPage = await editor.openPreviewPage();
+ await expect(
+ previewPage.locator( '#button-url-binding a' )
+ ).toHaveAttribute( 'href', '#url-custom-field-modified' );
+ } );
+ test( 'should be possible to edit the value of the url custom field from the image', async ( {
+ editor,
+ page,
+ pageUtils,
+ requestUtils,
+ } ) => {
+ const customFieldMedia = await requestUtils.uploadMedia(
+ path.join(
+ './test/e2e/assets',
+ '1024x768_e2e_test_image_size.jpeg'
+ )
+ );
+ testingImgSrc = customFieldMedia.source_url;
+
+ await editor.insertBlock( {
+ name: 'core/image',
+ attributes: {
+ anchor: 'image-url-binding',
+ url: imagePlaceholderSrc,
+ alt: 'default alt value',
+ title: 'default title value',
+ metadata: {
+ bindings: {
+ url: {
+ source: 'core/post-meta',
+ args: { key: 'url_custom_field' },
+ },
+ },
+ },
+ },
+ } );
+
+ // Edit image url.
+ await page
+ .getByRole( 'toolbar', { name: 'Block tools' } )
+ .getByRole( 'button', {
+ name: 'Replace',
+ } )
+ .click();
+ await page
+ .getByRole( 'button', { name: 'Edit link', exact: true } )
+ .click();
+ await page
+ .getByPlaceholder( 'Search or type URL' )
+ .fill( testingImgSrc );
+ await pageUtils.pressKeys( 'Enter' );
+
+ // Check that the image url attribute didn't change and still uses the placeholder.
+ const [ imageBlockObject ] = await editor.getBlocks();
+ expect( imageBlockObject.attributes.url ).toBe(
+ imagePlaceholderSrc
+ );
+
+ // Check the value of the custom field is being updated by visiting the frontend.
+ const previewPage = await editor.openPreviewPage();
+ await expect(
+ previewPage.locator( '#image-url-binding img' )
+ ).toHaveAttribute( 'src', testingImgSrc );
+ } );
+ test( 'should be possible to edit the value of the text custom field from the image alt', async ( {
+ editor,
+ page,
+ } ) => {
+ await editor.insertBlock( {
+ name: 'core/image',
+ attributes: {
+ anchor: 'image-alt-binding',
+ url: imagePlaceholderSrc,
+ alt: 'default alt value',
+ metadata: {
+ bindings: {
+ alt: {
+ source: 'core/post-meta',
+ args: { key: 'text_custom_field' },
+ },
+ },
+ },
+ },
+ } );
+ const imageBlockImg = editor.canvas
+ .getByRole( 'document', {
+ name: 'Block: Image',
+ } )
+ .locator( 'img' );
+ await imageBlockImg.click();
+
+ // Edit the custom field value in the alt textarea.
+ const altInputArea = page
+ .getByRole( 'tabpanel', { name: 'Settings' } )
+ .getByLabel( 'Alternative text' );
+ await expect( altInputArea ).not.toHaveAttribute( 'readonly' );
+ await altInputArea.fill( 'new value' );
+
+ // Check that the image alt attribute didn't change.
+ const [ imageBlockObject ] = await editor.getBlocks();
+ expect( imageBlockObject.attributes.alt ).toBe(
+ 'default alt value'
+ );
+ // Check the value of the custom field is being updated by visiting the frontend.
+ const previewPage = await editor.openPreviewPage();
+ await expect(
+ previewPage.locator( '#image-alt-binding img' )
+ ).toHaveAttribute( 'alt', 'new value' );
+ } );
+ } );
+
+ test.describe( 'getFieldsList', () => {
+ test( 'should be possible to update attribute value through bindings UI', async ( {
+ editor,
+ page,
+ } ) => {
+ await editor.insertBlock( {
+ name: 'core/paragraph',
+ } );
+ await page.getByLabel( 'Attributes options' ).click();
+ await page
+ .getByRole( 'menuitemcheckbox', {
+ name: 'Show content',
+ } )
+ .click();
+ await page.getByRole( 'button', { name: 'content' } ).click();
+ await page
+ .getByRole( 'menuitemradio' )
+ .filter( { hasText: 'Text Field Label' } )
+ .click();
+ const paragraphBlock = editor.canvas.getByRole( 'document', {
+ name: 'Block: Paragraph',
+ } );
+ await expect( paragraphBlock ).toHaveText( 'Text Field Value' );
+ } );
+ test( 'should be possible to connect the paragraph content', async ( {
+ editor,
+ page,
+ } ) => {
+ await editor.insertBlock( {
+ name: 'core/paragraph',
+ } );
+ await page.getByLabel( 'Attributes options' ).click();
+ const contentAttribute = page.getByRole( 'menuitemcheckbox', {
+ name: 'Show content',
+ } );
+ await expect( contentAttribute ).toBeVisible();
+ } );
+ test( 'should be possible to connect the heading content', async ( {
+ editor,
+ page,
+ } ) => {
+ await editor.insertBlock( {
+ name: 'core/heading',
+ } );
+ await page.getByLabel( 'Attributes options' ).click();
+ const contentAttribute = page.getByRole( 'menuitemcheckbox', {
+ name: 'Show content',
+ } );
+ await expect( contentAttribute ).toBeVisible();
+ } );
+ test( 'should be possible to connect the button supported attributes', async ( {
+ editor,
+ page,
+ } ) => {
+ await editor.insertBlock( {
+ name: 'core/buttons',
+ innerBlocks: [
+ {
+ name: 'core/button',
+ },
+ ],
+ } );
+ await editor.canvas
+ .getByRole( 'document', {
+ name: 'Block: Button',
+ exact: true,
+ } )
+ .getByRole( 'textbox' )
+ .click();
+ await page
+ .getByRole( 'tabpanel', {
+ name: 'Settings',
+ } )
+ .getByLabel( 'Attributes options' )
+ .click();
+ const urlAttribute = page.getByRole( 'menuitemcheckbox', {
+ name: 'Show url',
+ } );
+ await expect( urlAttribute ).toBeVisible();
+ const textAttribute = page.getByRole( 'menuitemcheckbox', {
+ name: 'Show text',
+ } );
+ await expect( textAttribute ).toBeVisible();
+ const linkTargetAttribute = page.getByRole( 'menuitemcheckbox', {
+ name: 'Show linkTarget',
+ } );
+ await expect( linkTargetAttribute ).toBeVisible();
+ const relAttribute = page.getByRole( 'menuitemcheckbox', {
+ name: 'Show rel',
+ } );
+ await expect( relAttribute ).toBeVisible();
+ // Check not supported attributes are not included.
+ const tagNameAttribute = page.getByRole( 'menuitemcheckbox', {
+ name: 'Show tagName',
+ } );
+ await expect( tagNameAttribute ).toBeHidden();
+ } );
+ test( 'should be possible to connect the image supported attributes', async ( {
+ editor,
+ page,
+ } ) => {
+ await editor.insertBlock( {
+ name: 'core/image',
+ } );
+ await page
+ .getByRole( 'tabpanel', {
+ name: 'Settings',
+ } )
+ .getByLabel( 'Attributes options' )
+ .click();
+ const urlAttribute = page.getByRole( 'menuitemcheckbox', {
+ name: 'Show url',
+ } );
+ await expect( urlAttribute ).toBeVisible();
+ const idAttribute = page.getByRole( 'menuitemcheckbox', {
+ name: 'Show id',
+ } );
+ await expect( idAttribute ).toBeVisible();
+ const titleAttribute = page.getByRole( 'menuitemcheckbox', {
+ name: 'Show title',
+ } );
+ await expect( titleAttribute ).toBeVisible();
+ const altAttribute = page.getByRole( 'menuitemcheckbox', {
+ name: 'Show alt',
+ } );
+ await expect( altAttribute ).toBeVisible();
+ // Check not supported attributes are not included.
+ const linkClassAttribute = page.getByRole( 'menuitemcheckbox', {
+ name: 'Show linkClass',
+ } );
+ await expect( linkClassAttribute ).toBeHidden();
+ } );
+ test( 'should show all the available fields in the dropdown UI', async ( {
+ editor,
+ page,
+ } ) => {
+ await editor.insertBlock( {
+ name: 'core/paragraph',
+ attributes: {
+ content: 'default value',
+ metadata: {
+ bindings: {
+ content: {
+ source: 'testing/complete-source',
+ args: { key: 'text_field' },
+ },
+ },
+ },
+ },
+ } );
+ await page.getByRole( 'button', { name: 'content' } ).click();
+ const textField = page
+ .getByRole( 'menuitemradio' )
+ .filter( { hasText: 'Text Field Label' } );
+ await expect( textField ).toBeVisible();
+ await expect( textField ).toBeChecked();
+ const urlField = page
+ .getByRole( 'menuitemradio' )
+ .filter( { hasText: 'URL Field Label' } );
+ await expect( urlField ).toBeVisible();
+ await expect( urlField ).not.toBeChecked();
+ } );
+ test( 'should show the connected fields in the attributes panel', async ( {
+ editor,
+ page,
+ } ) => {
+ await editor.insertBlock( {
+ name: 'core/paragraph',
+ attributes: {
+ content: 'default value',
+ metadata: {
+ bindings: {
+ content: {
+ source: 'testing/complete-source',
+ args: { key: 'text_field' },
+ },
+ },
+ },
+ },
+ } );
+ const contentButton = page.getByRole( 'button', {
+ name: 'content',
+ } );
+ await expect( contentButton ).toContainText( 'Text Field Label' );
+ } );
+ } );
+
+ test.describe( 'RichText workflows', () => {
+ test( 'should add empty paragraph block when pressing enter in paragraph', async ( {
+ editor,
+ page,
+ } ) => {
+ await editor.insertBlock( {
+ name: 'core/paragraph',
+ attributes: {
+ content: 'paragraph default content',
+ metadata: {
+ bindings: {
+ content: {
+ source: 'testing/complete-source',
+ args: { key: 'text_field' },
+ },
+ },
+ },
+ },
+ } );
+ // Select the paragraph and press Enter at the end of it.
+ const paragraph = editor.canvas.getByRole( 'document', {
+ name: 'Block: Paragraph',
+ } );
+ await editor.selectBlocks( paragraph );
+ await page.keyboard.press( 'End' );
+ await page.keyboard.press( 'Enter' );
+ const [ initialParagraph, newEmptyParagraph ] = await editor.canvas
+ .locator( '[data-type="core/paragraph"]' )
+ .all();
+ await expect( initialParagraph ).toHaveText( 'Text Field Value' );
+ await expect( newEmptyParagraph ).toHaveText( '' );
+ await expect( newEmptyParagraph ).toBeEditable();
+ } );
+ test( 'should add empty paragraph block when pressing enter in heading', async ( {
+ editor,
+ page,
+ } ) => {
+ await editor.insertBlock( {
+ name: 'core/heading',
+ attributes: {
+ anchor: 'heading-binding',
+ content: 'heading default content',
+ metadata: {
+ bindings: {
+ content: {
+ source: 'testing/complete-source',
+ args: { key: 'text_field' },
+ },
+ },
+ },
+ },
+ } );
+
+ // Select the heading and press Enter at the end of it.
+ const heading = editor.canvas.getByRole( 'document', {
+ name: 'Block: Heading',
+ } );
+ await editor.selectBlocks( heading );
+ await page.keyboard.press( 'End' );
+ await page.keyboard.press( 'Enter' );
+ // Can't use `editor.getBlocks` because it doesn't return the meta value shown in the editor.
+ const [ initialHeading, newEmptyParagraph ] = await editor.canvas
+ .locator( '[data-block]' )
+ .all();
+ // First block should be the original block.
+ await expect( initialHeading ).toHaveAttribute(
+ 'data-type',
+ 'core/heading'
+ );
+ await expect( initialHeading ).toHaveText( 'Text Field Value' );
+ // Second block should be an empty paragraph block.
+ await expect( newEmptyParagraph ).toHaveAttribute(
+ 'data-type',
+ 'core/paragraph'
+ );
+ await expect( newEmptyParagraph ).toHaveText( '' );
+ await expect( newEmptyParagraph ).toBeEditable();
+ } );
+ test( 'should add empty button block when pressing enter in button', async ( {
+ editor,
+ page,
+ } ) => {
+ await editor.insertBlock( {
+ name: 'core/buttons',
+ innerBlocks: [
+ {
+ name: 'core/button',
+ attributes: {
+ anchor: 'button-text-binding',
+ text: 'button default text',
+ url: '#default-url',
+ metadata: {
+ bindings: {
+ text: {
+ source: 'testing/complete-source',
+ args: { key: 'text_field' },
+ },
+ },
+ },
+ },
+ },
+ ],
+ } );
+ await editor.canvas
+ .getByRole( 'document', {
+ name: 'Block: Button',
+ exact: true,
+ } )
+ .getByRole( 'textbox' )
+ .click();
+ await page.keyboard.press( 'End' );
+ await page.keyboard.press( 'Enter' );
+ const [ initialButton, newEmptyButton ] = await editor.canvas
+ .locator( '[data-type="core/button"]' )
+ .all();
+ // First block should be the original block.
+ await expect( initialButton ).toHaveText( 'Text Field Value' );
+ // Second block should be an empty paragraph block.
+ await expect( newEmptyButton ).toHaveText( '' );
+ await expect( newEmptyButton ).toBeEditable();
+ } );
+ test( 'should show placeholder prompt when value is empty and can edit', async ( {
+ editor,
+ } ) => {
+ await editor.insertBlock( {
+ name: 'core/paragraph',
+ attributes: {
+ content: 'paragraph default content',
+ metadata: {
+ bindings: {
+ content: {
+ source: 'testing/complete-source',
+ args: { key: 'empty_field' },
+ },
+ },
+ },
+ },
+ } );
+
+ const paragraphBlock = editor.canvas.getByRole( 'document', {
+ // Aria-label is changed for empty paragraphs.
+ name: 'Empty empty_field; start writing to edit its value',
+ } );
+
+ await expect( paragraphBlock ).toBeEmpty();
+
+ const placeholder = paragraphBlock.locator( 'span' );
+ await expect( placeholder ).toHaveAttribute(
+ 'data-rich-text-placeholder',
+ 'Add Empty Field Label'
+ );
+ } );
+ test( 'should show source label when value is empty, cannot edit, and `getFieldsList` is undefined', async ( {
+ editor,
+ } ) => {
+ await editor.insertBlock( {
+ name: 'core/paragraph',
+ attributes: {
+ content: 'paragraph default content',
+ metadata: {
+ bindings: {
+ content: {
+ source: 'testing/can-user-edit-false',
+ args: { key: 'empty_field' },
+ },
+ },
+ },
+ },
+ } );
+ const paragraphBlock = editor.canvas.getByRole( 'document', {
+ // Aria-label is changed for empty paragraphs.
+ name: 'empty_field',
+ } );
+ await expect( paragraphBlock ).toBeEmpty();
+ const placeholder = paragraphBlock.locator( 'span' );
+ await expect( placeholder ).toHaveAttribute(
+ 'data-rich-text-placeholder',
+ 'Can User Edit: False'
+ );
+ } );
+ test( 'should show placeholder attribute over bindings placeholder', async ( {
+ editor,
+ } ) => {
+ await editor.insertBlock( {
+ name: 'core/paragraph',
+ attributes: {
+ placeholder: 'My custom placeholder',
+ content: 'paragraph default content',
+ metadata: {
+ bindings: {
+ content: {
+ source: 'testing/complete-source',
+ args: { key: 'empty_field' },
+ },
+ },
+ },
+ },
+ } );
+ const paragraphBlock = editor.canvas.getByRole( 'document', {
+ // Aria-label is changed for empty paragraphs.
+ name: 'empty_field',
+ } );
+
+ await expect( paragraphBlock ).toBeEmpty();
+
+ const placeholder = paragraphBlock.locator( 'span' );
+ await expect( placeholder ).toHaveAttribute(
+ 'data-rich-text-placeholder',
+ 'My custom placeholder'
+ );
+ } );
+ } );
+
+ test( 'should show the label of a source only registered in the server in blocks connected', async ( {
+ editor,
+ page,
+ } ) => {
+ await editor.insertBlock( {
+ name: 'core/paragraph',
+ attributes: {
+ metadata: {
+ bindings: {
+ content: {
+ source: 'testing/server-only-source',
+ },
+ },
+ },
+ },
+ } );
+
+ const contentButton = page.getByRole( 'button', {
+ name: 'content',
+ } );
+ await expect( contentButton ).toContainText( 'Server Source' );
+ } );
+ test( 'should show an "Invalid source" warning for not registered sources', async ( {
+ editor,
+ page,
+ } ) => {
+ await editor.insertBlock( {
+ name: 'core/paragraph',
+ attributes: {
+ metadata: {
+ bindings: {
+ content: {
+ source: 'testing/undefined-source',
+ },
+ },
+ },
+ },
+ } );
+
+ const contentButton = page.getByRole( 'button', {
+ name: 'content',
+ } );
+ await expect( contentButton ).toContainText( 'Invalid source' );
+ } );
+} );
diff --git a/test/e2e/specs/editor/various/block-bindings/post-meta.spec.js b/test/e2e/specs/editor/various/block-bindings/post-meta.spec.js
new file mode 100644
index 00000000000000..32334bfc777f2a
--- /dev/null
+++ b/test/e2e/specs/editor/various/block-bindings/post-meta.spec.js
@@ -0,0 +1,600 @@
+/**
+ * WordPress dependencies
+ */
+const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' );
+
+test.describe( 'Post Meta source', () => {
+ test.beforeAll( async ( { requestUtils } ) => {
+ await requestUtils.activateTheme(
+ 'gutenberg-test-themes/block-bindings'
+ );
+ await requestUtils.activatePlugin( 'gutenberg-test-block-bindings' );
+ } );
+
+ test.afterEach( async ( { requestUtils } ) => {
+ await requestUtils.deleteAllPosts();
+ } );
+
+ test.afterAll( async ( { requestUtils } ) => {
+ await requestUtils.deleteAllMedia();
+ await requestUtils.activateTheme( 'twentytwentyone' );
+ await requestUtils.deactivatePlugin( 'gutenberg-test-block-bindings' );
+ } );
+
+ test.describe( 'Movie CPT template', () => {
+ test.beforeEach( async ( { admin, editor } ) => {
+ await admin.visitSiteEditor( {
+ postId: 'gutenberg-test-themes/block-bindings//single-movie',
+ postType: 'wp_template',
+ canvas: 'edit',
+ } );
+ await editor.openDocumentSettingsSidebar();
+ } );
+
+ test.describe( 'Block attributes values', () => {
+ test( 'should not be possible to edit connected blocks', async ( {
+ editor,
+ } ) => {
+ await editor.insertBlock( {
+ name: 'core/paragraph',
+ attributes: {
+ content: 'fallback content',
+ metadata: {
+ bindings: {
+ content: {
+ source: 'core/post-meta',
+ args: {
+ key: 'movie_field',
+ },
+ },
+ },
+ },
+ },
+ } );
+ const paragraphBlock = editor.canvas.getByRole( 'document', {
+ name: 'Block: Paragraph',
+ } );
+ await expect( paragraphBlock ).toHaveAttribute(
+ 'contenteditable',
+ 'false'
+ );
+ } );
+ test( 'should show the default value if it is defined', async ( {
+ editor,
+ } ) => {
+ await editor.insertBlock( {
+ name: 'core/paragraph',
+ attributes: {
+ content: 'fallback content',
+ metadata: {
+ bindings: {
+ content: {
+ source: 'core/post-meta',
+ args: {
+ key: 'movie_field',
+ },
+ },
+ },
+ },
+ },
+ } );
+ const paragraphBlock = editor.canvas.getByRole( 'document', {
+ name: 'Block: Paragraph',
+ } );
+ await expect( paragraphBlock ).toHaveText(
+ 'Movie field default value'
+ );
+ } );
+ test( 'should fall back to the field label if the default value is not defined', async ( {
+ editor,
+ } ) => {
+ await editor.insertBlock( {
+ name: 'core/paragraph',
+ attributes: {
+ content: 'fallback content',
+ metadata: {
+ bindings: {
+ content: {
+ source: 'core/post-meta',
+ args: {
+ key: 'field_with_only_label',
+ },
+ },
+ },
+ },
+ },
+ } );
+ const paragraphBlock = editor.canvas.getByRole( 'document', {
+ name: 'Block: Paragraph',
+ } );
+ await expect( paragraphBlock ).toHaveText(
+ 'Field with only label'
+ );
+ } );
+ test( 'should fall back to the field key if the field label is not defined', async ( {
+ editor,
+ } ) => {
+ await editor.insertBlock( {
+ name: 'core/paragraph',
+ attributes: {
+ content: 'fallback content',
+ metadata: {
+ bindings: {
+ content: {
+ source: 'core/post-meta',
+ args: {
+ key: 'field_without_label_or_default',
+ },
+ },
+ },
+ },
+ },
+ } );
+ const paragraphBlock = editor.canvas.getByRole( 'document', {
+ name: 'Block: Paragraph',
+ } );
+ await expect( paragraphBlock ).toHaveText(
+ 'field_without_label_or_default'
+ );
+ } );
+ } );
+
+ test.describe( 'Attributes panel', () => {
+ test( 'should show the field label if it is defined', async ( {
+ editor,
+ page,
+ } ) => {
+ /**
+ * Create connection manually until this issue is solved:
+ * https://github.com/WordPress/gutenberg/pull/65604
+ *
+ * Once solved, block with the binding can be directly inserted.
+ */
+ await editor.insertBlock( {
+ name: 'core/paragraph',
+ } );
+ await page.getByLabel( 'Attributes options' ).click();
+ await page
+ .getByRole( 'menuitemcheckbox', {
+ name: 'Show content',
+ } )
+ .click();
+ const contentBinding = page.getByRole( 'button', {
+ name: 'content',
+ } );
+ await contentBinding.click();
+ await page
+ .getByRole( 'menuitemradio' )
+ .filter( { hasText: 'Movie field label' } )
+ .click();
+ await expect( contentBinding ).toContainText(
+ 'Movie field label'
+ );
+ } );
+ test( 'should fall back to the field key if the field label is not defined', async ( {
+ editor,
+ page,
+ } ) => {
+ /**
+ * Create connection manually until this issue is solved:
+ * https://github.com/WordPress/gutenberg/pull/65604
+ *
+ * Once solved, block with the binding can be directly inserted.
+ */
+ await editor.insertBlock( {
+ name: 'core/paragraph',
+ } );
+ await page.getByLabel( 'Attributes options' ).click();
+ await page
+ .getByRole( 'menuitemcheckbox', {
+ name: 'Show content',
+ } )
+ .click();
+ const contentBinding = page.getByRole( 'button', {
+ name: 'content',
+ } );
+ await contentBinding.click();
+ await page
+ .getByRole( 'menuitemradio' )
+ .filter( { hasText: 'field_without_label_or_default' } )
+ .click();
+ await expect( contentBinding ).toContainText(
+ 'field_without_label_or_default'
+ );
+ } );
+ } );
+
+ test.describe( 'Fields list dropdown', () => {
+ // Insert block and open the dropdown for every test.
+ test.beforeEach( async ( { editor, page } ) => {
+ await editor.insertBlock( {
+ name: 'core/paragraph',
+ } );
+ await page.getByLabel( 'Attributes options' ).click();
+ await page
+ .getByRole( 'menuitemcheckbox', {
+ name: 'Show content',
+ } )
+ .click();
+ await page
+ .getByRole( 'button', {
+ name: 'content',
+ } )
+ .click();
+ } );
+
+ test( 'should include movie fields in UI to connect attributes', async ( {
+ page,
+ } ) => {
+ const movieField = page
+ .getByRole( 'menuitemradio' )
+ .filter( { hasText: 'Movie field label' } );
+ await expect( movieField ).toBeVisible();
+ } );
+ test( 'should include global fields in UI to connect attributes', async ( {
+ page,
+ } ) => {
+ const globalField = page
+ .getByRole( 'menuitemradio' )
+ .filter( { hasText: 'text_custom_field' } );
+ await expect( globalField ).toBeVisible();
+ } );
+ test( 'should not include protected fields', async ( { page } ) => {
+ // Ensure the fields have loaded by checking the field is visible.
+ const globalField = page
+ .getByRole( 'menuitemradio' )
+ .filter( { hasText: 'text_custom_field' } );
+ await expect( globalField ).toBeVisible();
+ // Check the protected fields are not visible.
+ const protectedField = page
+ .getByRole( 'menuitemradio' )
+ .filter( { hasText: '_protected_field' } );
+ await expect( protectedField ).toBeHidden();
+ const showInRestFalseField = page
+ .getByRole( 'menuitemradio' )
+ .filter( { hasText: 'show_in_rest_false_field' } );
+ await expect( showInRestFalseField ).toBeHidden();
+ } );
+ test( 'should show the default value if it is defined', async ( {
+ page,
+ } ) => {
+ const fieldButton = page
+ .getByRole( 'menuitemradio' )
+ .filter( { hasText: 'Movie field label' } );
+ await expect( fieldButton ).toContainText(
+ 'Movie field default value'
+ );
+ } );
+ test( 'should not show anything if the default value is not defined', async ( {
+ page,
+ } ) => {
+ const fieldButton = page
+ .getByRole( 'menuitemradio' )
+ .filter( { hasText: 'Field with only label' } );
+ // Check it only contains the field label.
+ await expect( fieldButton ).toHaveText(
+ 'Field with only label'
+ );
+ } );
+ } );
+ } );
+
+ test.describe( 'Custom template', () => {
+ test.beforeEach( async ( { admin, editor } ) => {
+ await admin.visitSiteEditor( {
+ postId: 'gutenberg-test-themes/block-bindings//custom-template',
+ postType: 'wp_template',
+ canvas: 'edit',
+ } );
+ await editor.openDocumentSettingsSidebar();
+ } );
+
+ test( 'should not include post meta fields in UI to connect attributes', async ( {
+ editor,
+ page,
+ } ) => {
+ await editor.insertBlock( {
+ name: 'core/paragraph',
+ attributes: {
+ content: 'fallback content',
+ metadata: {
+ bindings: {
+ content: {
+ source: 'core/post-meta',
+ args: {
+ key: 'text_custom_field',
+ },
+ },
+ },
+ },
+ },
+ } );
+ await page
+ .getByRole( 'button', {
+ name: 'content',
+ } )
+ .click();
+ // Check the fields registered by other sources are there.
+ const customSourceField = page
+ .getByRole( 'menuitemradio' )
+ .filter( { hasText: 'Text Field Label' } );
+ await expect( customSourceField ).toBeVisible();
+ // Check the post meta fields are not visible.
+ const globalField = page
+ .getByRole( 'menuitemradio' )
+ .filter( { hasText: 'text_custom_field' } );
+ await expect( globalField ).toBeHidden();
+ const movieField = page
+ .getByRole( 'menuitemradio' )
+ .filter( { hasText: 'Movie field label' } );
+ await expect( movieField ).toBeHidden();
+ } );
+ test( 'should show the key in attributes connected to post meta', async ( {
+ editor,
+ } ) => {
+ await editor.insertBlock( {
+ name: 'core/paragraph',
+ attributes: {
+ content: 'fallback content',
+ metadata: {
+ bindings: {
+ content: {
+ source: 'core/post-meta',
+ args: {
+ key: 'text_custom_field',
+ },
+ },
+ },
+ },
+ },
+ } );
+ const paragraphBlock = editor.canvas.getByRole( 'document', {
+ name: 'Block: Paragraph',
+ } );
+ await expect( paragraphBlock ).toHaveText( 'text_custom_field' );
+ } );
+ } );
+
+ test.describe( 'Movie CPT post', () => {
+ test.beforeEach( async ( { admin } ) => {
+ // CHECK HOW TO CREATE A MOVIE.
+ await admin.createNewPost( {
+ postType: 'movie',
+ title: 'Test bindings',
+ } );
+ } );
+
+ test( 'should show the custom field value of that specific post', async ( {
+ editor,
+ } ) => {
+ await editor.insertBlock( {
+ name: 'core/paragraph',
+ attributes: {
+ anchor: 'connected-paragraph',
+ content: 'fallback content',
+ metadata: {
+ bindings: {
+ content: {
+ source: 'core/post-meta',
+ args: {
+ key: 'movie_field',
+ },
+ },
+ },
+ },
+ },
+ } );
+ const paragraphBlock = editor.canvas.getByRole( 'document', {
+ name: 'Block: Paragraph',
+ } );
+ await expect( paragraphBlock ).toHaveText(
+ 'Movie field default value'
+ );
+ // Check the frontend shows the value of the custom field.
+ const previewPage = await editor.openPreviewPage();
+ await expect(
+ previewPage.locator( '#connected-paragraph' )
+ ).toHaveText( 'Movie field default value' );
+ } );
+ test( 'should fall back to the key when custom field is not accessible', async ( {
+ editor,
+ } ) => {
+ await editor.insertBlock( {
+ name: 'core/paragraph',
+ attributes: {
+ content: 'fallback content',
+ metadata: {
+ bindings: {
+ content: {
+ source: 'core/post-meta',
+ args: {
+ key: 'unaccessible_field',
+ },
+ },
+ },
+ },
+ },
+ } );
+ const paragraphBlock = editor.canvas.getByRole( 'document', {
+ name: 'Block: Paragraph',
+ } );
+ await expect( paragraphBlock ).toHaveText( 'unaccessible_field' );
+ await expect( paragraphBlock ).toHaveAttribute(
+ 'contenteditable',
+ 'false'
+ );
+ } );
+ test( 'should not show or edit the value of a protected field', async ( {
+ editor,
+ } ) => {
+ await editor.insertBlock( {
+ name: 'core/paragraph',
+ attributes: {
+ content: 'fallback content',
+ metadata: {
+ bindings: {
+ content: {
+ source: 'core/post-meta',
+ args: {
+ key: '_protected_field',
+ },
+ },
+ },
+ },
+ },
+ } );
+ const paragraphBlock = editor.canvas.getByRole( 'document', {
+ name: 'Block: Paragraph',
+ } );
+ await expect( paragraphBlock ).toHaveText( '_protected_field' );
+ await expect( paragraphBlock ).toHaveAttribute(
+ 'contenteditable',
+ 'false'
+ );
+ } );
+ test( 'should not show or edit the value of a field with `show_in_rest` set to false', async ( {
+ editor,
+ } ) => {
+ await editor.insertBlock( {
+ name: 'core/paragraph',
+ attributes: {
+ content: 'fallback content',
+ metadata: {
+ bindings: {
+ content: {
+ source: 'core/post-meta',
+ args: {
+ key: 'show_in_rest_false_field',
+ },
+ },
+ },
+ },
+ },
+ } );
+ const paragraphBlock = editor.canvas.getByRole( 'document', {
+ name: 'Block: Paragraph',
+ } );
+ await expect( paragraphBlock ).toHaveText(
+ 'show_in_rest_false_field'
+ );
+ await expect( paragraphBlock ).toHaveAttribute(
+ 'contenteditable',
+ 'false'
+ );
+ } );
+ test( 'should be possible to edit the value of the connected custom fields', async ( {
+ editor,
+ } ) => {
+ await editor.insertBlock( {
+ name: 'core/paragraph',
+ attributes: {
+ anchor: 'connected-paragraph',
+ content: 'fallback content',
+ metadata: {
+ bindings: {
+ content: {
+ source: 'core/post-meta',
+ args: {
+ key: 'movie_field',
+ },
+ },
+ },
+ },
+ },
+ } );
+ const paragraphBlock = editor.canvas.getByRole( 'document', {
+ name: 'Block: Paragraph',
+ } );
+ await expect( paragraphBlock ).toHaveText(
+ 'Movie field default value'
+ );
+ await expect( paragraphBlock ).toHaveAttribute(
+ 'contenteditable',
+ 'true'
+ );
+ await paragraphBlock.fill( 'new value' );
+ // Check that the paragraph content attribute didn't change.
+ const [ paragraphBlockObject ] = await editor.getBlocks();
+ expect( paragraphBlockObject.attributes.content ).toBe(
+ 'fallback content'
+ );
+ // Check the value of the custom field is being updated by visiting the frontend.
+ const previewPage = await editor.openPreviewPage();
+ await expect(
+ previewPage.locator( '#connected-paragraph' )
+ ).toHaveText( 'new value' );
+ } );
+ test( 'should be possible to connect movie fields through the attributes panel', async ( {
+ editor,
+ page,
+ } ) => {
+ await editor.insertBlock( {
+ name: 'core/paragraph',
+ } );
+ await page.getByLabel( 'Attributes options' ).click();
+ await page
+ .getByRole( 'menuitemcheckbox', {
+ name: 'Show content',
+ } )
+ .click();
+ await page
+ .getByRole( 'button', {
+ name: 'content',
+ } )
+ .click();
+ const movieField = page
+ .getByRole( 'menuitemradio' )
+ .filter( { hasText: 'Movie field label' } );
+ await expect( movieField ).toBeVisible();
+ } );
+ test( 'should not be possible to connect non-supported fields through the attributes panel', async ( {
+ editor,
+ page,
+ } ) => {
+ await editor.insertBlock( {
+ name: 'core/paragraph',
+ } );
+ await page.getByLabel( 'Attributes options' ).click();
+ await page
+ .getByRole( 'menuitemcheckbox', {
+ name: 'Show content',
+ } )
+ .click();
+ await page
+ .getByRole( 'button', {
+ name: 'content',
+ } )
+ .click();
+ await expect(
+ page.getByRole( 'menuitemradio', {
+ name: 'String custom field',
+ } )
+ ).toBeVisible();
+ await expect(
+ page.getByRole( 'menuitemradio', {
+ name: 'Number custom field',
+ } )
+ ).toBeHidden();
+ await expect(
+ page.getByRole( 'menuitemradio', {
+ name: 'Integer custom field',
+ } )
+ ).toBeHidden();
+ await expect(
+ page.getByRole( 'menuitemradio', {
+ name: 'Boolean custom field',
+ } )
+ ).toBeHidden();
+ await expect(
+ page.getByRole( 'menuitemradio', {
+ name: 'Object custom field',
+ } )
+ ).toBeHidden();
+ await expect(
+ page.getByRole( 'menuitemradio', {
+ name: 'Array custom field',
+ } )
+ ).toBeHidden();
+ } );
+ } );
+} );
diff --git a/test/e2e/specs/editor/various/block-deletion.spec.js b/test/e2e/specs/editor/various/block-deletion.spec.js
index 00b51a94668d50..9346412c46bcb2 100644
--- a/test/e2e/specs/editor/various/block-deletion.spec.js
+++ b/test/e2e/specs/editor/various/block-deletion.spec.js
@@ -287,16 +287,15 @@ test.describe( 'Block deletion', () => {
await expect.poll( editor.getBlocks ).toMatchObject( [
{ name: 'core/paragraph', attributes: { content: 'First' } },
{ name: 'core/paragraph', attributes: { content: 'Second' } },
+ { name: 'core/paragraph', attributes: { content: '' } },
] );
// Ensure that the newly created empty block is focused.
- await expect.poll( editor.getBlocks ).toHaveLength( 2 );
+ await expect.poll( editor.getBlocks ).toHaveLength( 3 );
await expect(
- editor.canvas
- .getByRole( 'document', {
- name: 'Block: Paragraph',
- } )
- .nth( 1 )
+ editor.canvas.getByRole( 'document', {
+ name: 'Empty block',
+ } )
).toBeFocused();
} );
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 33a65e6f5a1195..bca062b06416a1 100644
--- a/test/e2e/specs/editor/various/copy-cut-paste.spec.js
+++ b/test/e2e/specs/editor/various/copy-cut-paste.spec.js
@@ -565,6 +565,27 @@ test.describe( 'Copy/cut/paste', () => {
] );
} );
+ test( 'should paste link to formatted text', async ( {
+ page,
+ pageUtils,
+ editor,
+ } ) => {
+ await editor.insertBlock( {
+ name: 'core/paragraph',
+ attributes: { content: 'test' },
+ } );
+ await page.keyboard.press( 'ArrowRight' );
+ await page.keyboard.press( 'ArrowRight' );
+ await pageUtils.pressKeys( 'shift+ArrowRight' );
+ await pageUtils.pressKeys( 'shift+ArrowRight' );
+ pageUtils.setClipboardData( {
+ plainText: 'https://wordpress.org/gutenberg',
+ html: 'https://wordpress.org/gutenberg',
+ } );
+ await pageUtils.pressKeys( 'primary+v' );
+ expect( await editor.getEditedPostContent() ).toMatchSnapshot();
+ } );
+
test( 'should auto-link', async ( { pageUtils, editor } ) => {
await editor.insertBlock( {
name: 'core/paragraph',
diff --git a/test/e2e/specs/editor/various/inserting-blocks.spec.js b/test/e2e/specs/editor/various/inserting-blocks.spec.js
index b4f23c8b8e2bbf..066937f097ba91 100644
--- a/test/e2e/specs/editor/various/inserting-blocks.spec.js
+++ b/test/e2e/specs/editor/various/inserting-blocks.spec.js
@@ -45,6 +45,10 @@ test.describe( 'Inserting blocks (@firefox, @webkit)', () => {
{ name: 'core/image' },
{ name: 'core/paragraph' },
] );
+
+ await expect(
+ editor.canvas.locator( '[data-type="core/paragraph"]' )
+ ).toBeFocused();
} );
test( 'inserts blocks by dragging and dropping from the global inserter', async ( {
diff --git a/test/e2e/specs/editor/various/parsing-patterns.spec.js b/test/e2e/specs/editor/various/parsing-patterns.spec.js
index d8abe7a46fbc1b..3b801591aed5de 100644
--- a/test/e2e/specs/editor/various/parsing-patterns.spec.js
+++ b/test/e2e/specs/editor/various/parsing-patterns.spec.js
@@ -36,6 +36,14 @@ test.describe( 'Parsing patterns', () => {
],
} );
} );
+
+ // Exit zoom out mode and select the inner buttons block to ensure
+ // the correct insertion point is selected.
+ await page.getByRole( 'button', { name: 'Zoom Out' } ).click();
+ await editor.selectBlocks(
+ editor.canvas.locator( 'role=document[name="Block: Button"i]' )
+ );
+
await page.fill(
'role=region[name="Block Library"i] >> role=searchbox[name="Search for blocks and patterns"i]',
'whitespace'
@@ -43,7 +51,7 @@ test.describe( 'Parsing patterns', () => {
await page
.locator( 'role=option[name="Pattern with top-level whitespace"i]' )
.click();
- expect( await editor.getBlocks() ).toMatchObject( [
+ await expect.poll( editor.getBlocks ).toMatchObject( [
{
name: 'core/buttons',
innerBlocks: [
diff --git a/test/e2e/specs/editor/various/pattern-overrides.spec.js b/test/e2e/specs/editor/various/pattern-overrides.spec.js
index 83f2f880f3bf1b..5fbd0e66b5fd02 100644
--- a/test/e2e/specs/editor/various/pattern-overrides.spec.js
+++ b/test/e2e/specs/editor/various/pattern-overrides.spec.js
@@ -150,7 +150,7 @@ test.describe( 'Pattern Overrides', () => {
name: 'Block: Paragraph',
} );
// Ensure the first pattern is selected.
- await editor.selectBlocks( patternBlocks.first() );
+ await patternBlocks.first().selectText();
await expect( paragraphs.first() ).not.toHaveAttribute(
'inert',
'true'
@@ -168,7 +168,7 @@ test.describe( 'Pattern Overrides', () => {
await page.keyboard.type( 'I would word it this way' );
// Ensure the second pattern is selected.
- await editor.selectBlocks( patternBlocks.last() );
+ await patternBlocks.last().selectText();
await patternBlocks
.last()
.getByRole( 'document', {
diff --git a/test/e2e/specs/editor/various/publish-panel.spec.js b/test/e2e/specs/editor/various/publish-panel.spec.js
index 534fea5289c9e1..1fe94ff334f3b2 100644
--- a/test/e2e/specs/editor/various/publish-panel.spec.js
+++ b/test/e2e/specs/editor/various/publish-panel.spec.js
@@ -58,7 +58,7 @@ test.describe( 'Post publish panel', () => {
).toBeFocused();
} );
- test( 'should move focus to the publish button in the panel', async ( {
+ test( 'should move focus to the cancel button in the panel', async ( {
editor,
page,
} ) => {
@@ -74,7 +74,7 @@ test.describe( 'Post publish panel', () => {
page
.getByRole( 'region', { name: 'Editor publish' } )
.locator( ':focus' )
- ).toHaveText( 'Publish' );
+ ).toHaveText( 'Cancel' );
} );
test( 'should focus on the post list after publishing', async ( {
diff --git a/test/e2e/specs/editor/various/splitting-merging.spec.js b/test/e2e/specs/editor/various/splitting-merging.spec.js
index 29e7e5d64522c9..eba9f1d3163fd5 100644
--- a/test/e2e/specs/editor/various/splitting-merging.spec.js
+++ b/test/e2e/specs/editor/various/splitting-merging.spec.js
@@ -373,6 +373,103 @@ test.describe( 'splitting and merging blocks (@firefox, @webkit)', () => {
);
} );
+ // Fix for https://github.com/WordPress/gutenberg/issues/65174.
+ test( 'should handle unwrapping and merging blocks with empty contents', async ( {
+ editor,
+ page,
+ } ) => {
+ const emptyAlignedParagraph = {
+ name: 'core/paragraph',
+ attributes: { content: '', align: 'center', dropCap: false },
+ innerBlocks: [],
+ };
+ const emptyAlignedHeading = {
+ name: 'core/heading',
+ attributes: { content: '', textAlign: 'center', level: 2 },
+ innerBlocks: [],
+ };
+ const headingWithContent = {
+ name: 'core/heading',
+ attributes: { content: 'heading', level: 2 },
+ innerBlocks: [],
+ };
+ const placeholderBlock = { name: 'core/separator' };
+ await editor.insertBlock( {
+ name: 'core/group',
+ innerBlocks: [
+ emptyAlignedParagraph,
+ emptyAlignedHeading,
+ headingWithContent,
+ placeholderBlock,
+ ],
+ } );
+ await editor.canvas
+ .getByRole( 'document', { name: 'Empty block' } )
+ .focus();
+
+ await page.keyboard.press( 'Backspace' );
+ await expect
+ .poll( editor.getBlocks, 'Remove the default empty block' )
+ .toEqual( [
+ {
+ name: 'core/group',
+ attributes: { tagName: 'div' },
+ innerBlocks: [
+ emptyAlignedHeading,
+ headingWithContent,
+ expect.objectContaining( placeholderBlock ),
+ ],
+ },
+ ] );
+
+ // Move the caret to the beginning of the empty heading block.
+ await page.keyboard.press( 'ArrowDown' );
+ await page.keyboard.press( 'Backspace' );
+ await expect
+ .poll(
+ editor.getBlocks,
+ 'Convert the non-default empty block to a default block'
+ )
+ .toEqual( [
+ {
+ name: 'core/group',
+ attributes: { tagName: 'div' },
+ innerBlocks: [
+ emptyAlignedParagraph,
+ headingWithContent,
+ expect.objectContaining( placeholderBlock ),
+ ],
+ },
+ ] );
+ await page.keyboard.press( 'Backspace' );
+ await expect.poll( editor.getBlocks ).toEqual( [
+ {
+ name: 'core/group',
+ attributes: { tagName: 'div' },
+ innerBlocks: [
+ headingWithContent,
+ expect.objectContaining( placeholderBlock ),
+ ],
+ },
+ ] );
+
+ // Move the caret to the beginning of the "heading" heading block.
+ await page.keyboard.press( 'ArrowDown' );
+ await page.keyboard.press( 'Backspace' );
+ await expect
+ .poll( editor.getBlocks, 'Lift the non-empty non-default block' )
+ .toEqual( [
+ headingWithContent,
+ {
+ name: 'core/group',
+ attributes: { tagName: 'div' },
+ innerBlocks: [
+ expect.objectContaining( placeholderBlock ),
+ ],
+ },
+ ] );
+ } );
+
test.describe( 'test restore selection when merge produces more than one block', () => {
const snap1 = [
{
diff --git a/test/e2e/specs/interactivity/directive-class.spec.ts b/test/e2e/specs/interactivity/directive-class.spec.ts
index b7e085aba15143..96b725568767ae 100644
--- a/test/e2e/specs/interactivity/directive-class.spec.ts
+++ b/test/e2e/specs/interactivity/directive-class.spec.ts
@@ -108,4 +108,14 @@ test.describe( 'data-wp-class', () => {
const el = page.getByTestId( 'can use classes with several dashes' );
await expect( el ).toHaveClass( 'main-bg----color' );
} );
+
+ test( 'can use "default" as a class name', async ( { page } ) => {
+ const el = page.getByTestId( 'class name default' );
+ const btn = page.getByTestId( 'toggle class name default' );
+ await expect( el ).not.toHaveClass( 'default' );
+ await btn.click();
+ await expect( el ).toHaveClass( 'default' );
+ await btn.click();
+ await expect( el ).not.toHaveClass( 'default' );
+ } );
} );
diff --git a/test/e2e/specs/interactivity/get-sever-context.spec.ts b/test/e2e/specs/interactivity/get-sever-context.spec.ts
new file mode 100644
index 00000000000000..d7bc4075f97604
--- /dev/null
+++ b/test/e2e/specs/interactivity/get-sever-context.spec.ts
@@ -0,0 +1,166 @@
+/**
+ * Internal dependencies
+ */
+import { test, expect } from './fixtures';
+
+test.describe( 'getServerContext()', () => {
+ test.beforeAll( async ( { interactivityUtils: utils } ) => {
+ const parent = {
+ prop: 'parent',
+ nested: {
+ prop: 'parent',
+ },
+ inherited: {
+ prop: 'parent',
+ },
+ };
+
+ const parentModified = {
+ prop: 'parentModified',
+ nested: {
+ prop: 'parentModified',
+ },
+ inherited: {
+ prop: 'parentModified',
+ },
+ };
+
+ const parentNewProps = {
+ prop: 'parent',
+ newProp: 'parent',
+ nested: {
+ prop: 'parent',
+ newProp: 'parent',
+ },
+ inherited: {
+ prop: 'parent',
+ newProp: 'parent',
+ },
+ };
+
+ const child = {
+ prop: 'child',
+ nested: {
+ prop: 'child',
+ },
+ };
+
+ const childModified = {
+ prop: 'childModified',
+ nested: {
+ prop: 'childModified',
+ },
+ };
+
+ const childNewProps = {
+ prop: 'child',
+ newProp: 'child',
+ nested: {
+ prop: 'child',
+ newProp: 'child',
+ },
+ };
+
+ await utils.activatePlugins();
+ const link1 = await utils.addPostWithBlock( 'test/get-server-context', {
+ alias: 'getServerContext() - modified',
+ attributes: {
+ parentContext: parentModified,
+ childContext: childModified,
+ },
+ } );
+ const link2 = await utils.addPostWithBlock( 'test/get-server-context', {
+ alias: 'getServerContext() - new props',
+ attributes: {
+ parentContext: parentNewProps,
+ childContext: childNewProps,
+ },
+ } );
+ await utils.addPostWithBlock( 'test/get-server-context', {
+ alias: 'getServerContext() - main',
+ attributes: {
+ links: { modified: link1, newProps: link2 },
+ parentContext: parent,
+ childContext: child,
+ },
+ } );
+ } );
+
+ test.beforeEach( async ( { interactivityUtils: utils, page } ) => {
+ await page.goto( utils.getLink( 'getServerContext() - main' ) );
+ } );
+
+ test.afterAll( async ( { interactivityUtils: utils } ) => {
+ await utils.deactivatePlugins();
+ await utils.deleteAllPosts();
+ } );
+
+ test( 'should update modified props on navigation', async ( { page } ) => {
+ const prop = page.getByTestId( 'prop' );
+ const nestedProp = page.getByTestId( 'nested.prop' );
+ const inheritedProp = page.getByTestId( 'inherited.prop' );
+
+ await expect( prop ).toHaveText( 'child' );
+ await expect( nestedProp ).toHaveText( 'child' );
+ await expect( inheritedProp ).toHaveText( 'parent' );
+
+ await page.getByTestId( 'modified' ).click();
+
+ await expect( prop ).toHaveText( 'childModified' );
+ await expect( nestedProp ).toHaveText( 'childModified' );
+ await expect( inheritedProp ).toHaveText( 'parentModified' );
+
+ await page.goBack();
+
+ await expect( prop ).toHaveText( 'child' );
+ await expect( nestedProp ).toHaveText( 'child' );
+ await expect( inheritedProp ).toHaveText( 'parent' );
+ } );
+
+ test( 'should add new props on navigation', async ( { page } ) => {
+ const newProp = page.getByTestId( 'newProp' );
+ const nestedNewProp = page.getByTestId( 'nested.newProp' );
+ const inheritedNewProp = page.getByTestId( 'inherited.newProp' );
+
+ await expect( newProp ).toBeEmpty();
+ await expect( nestedNewProp ).toBeEmpty();
+ await expect( inheritedNewProp ).toBeEmpty();
+
+ await page.getByTestId( 'newProps' ).click();
+
+ await expect( newProp ).toHaveText( 'child' );
+ await expect( nestedNewProp ).toHaveText( 'child' );
+ await expect( inheritedNewProp ).toHaveText( 'parent' );
+ } );
+
+ test( 'should keep new props on navigation', async ( { page } ) => {
+ const newProp = page.getByTestId( 'newProp' );
+ const nestedNewProp = page.getByTestId( 'nested.newProp' );
+ const inheritedNewProp = page.getByTestId( 'inherited.newProp' );
+
+ await page.getByTestId( 'newProps' ).click();
+
+ await expect( newProp ).toHaveText( 'child' );
+ await expect( nestedNewProp ).toHaveText( 'child' );
+ await expect( inheritedNewProp ).toHaveText( 'parent' );
+
+ await page.goBack();
+
+ await expect( newProp ).toHaveText( 'child' );
+ await expect( nestedNewProp ).toHaveText( 'child' );
+ await expect( inheritedNewProp ).toHaveText( 'parent' );
+ } );
+
+ test( 'should prevent any manual modifications', async ( { page } ) => {
+ const prop = page.getByTestId( 'prop' );
+ const button = page.getByTestId( 'tryToModifyServerContext' );
+
+ await expect( prop ).toHaveText( 'child' );
+ await expect( button ).toHaveText( 'modify' );
+
+ await button.click();
+
+ await expect( prop ).toHaveText( 'child' );
+ await expect( button ).toHaveText( 'not modified ✅' );
+ } );
+} );
diff --git a/test/e2e/specs/interactivity/get-sever-state.spec.ts b/test/e2e/specs/interactivity/get-sever-state.spec.ts
new file mode 100644
index 00000000000000..16406c1d824463
--- /dev/null
+++ b/test/e2e/specs/interactivity/get-sever-state.spec.ts
@@ -0,0 +1,119 @@
+/**
+ * Internal dependencies
+ */
+import { test, expect } from './fixtures';
+
+test.describe( 'getServerState()', () => {
+ test.beforeAll( async ( { interactivityUtils: utils } ) => {
+ await utils.activatePlugins();
+ const link1 = await utils.addPostWithBlock( 'test/get-server-state', {
+ alias: 'getServerState() - link 1',
+ attributes: {
+ state: {
+ prop: 'link 1',
+ newProp: 'link 1',
+ nested: {
+ prop: 'link 1',
+ newProp: 'link 1',
+ },
+ },
+ },
+ } );
+ const link2 = await utils.addPostWithBlock( 'test/get-server-state', {
+ alias: 'getServerState() - link 2',
+ attributes: {
+ state: {
+ prop: 'link 2',
+ newProp: 'link 2',
+ nested: {
+ prop: 'link 2',
+ newProp: 'link 2',
+ },
+ },
+ },
+ } );
+ await utils.addPostWithBlock( 'test/get-server-state', {
+ alias: 'getServerState() - main',
+ attributes: {
+ title: 'Main',
+ links: [ link1, link2 ],
+ state: {
+ prop: 'main',
+ nested: {
+ prop: 'main',
+ },
+ },
+ },
+ } );
+ } );
+
+ test.beforeEach( async ( { interactivityUtils: utils, page } ) => {
+ await page.goto( utils.getLink( 'getServerState() - main' ) );
+ } );
+
+ test.afterAll( async ( { interactivityUtils: utils } ) => {
+ await utils.deactivatePlugins();
+ await utils.deleteAllPosts();
+ } );
+
+ test( 'should update existing state props on navigation', async ( {
+ page,
+ } ) => {
+ const prop = page.getByTestId( 'prop' );
+ const nestedProp = page.getByTestId( 'nested.prop' );
+
+ await expect( prop ).toHaveText( 'main' );
+ await expect( nestedProp ).toHaveText( 'main' );
+
+ await page.getByTestId( 'link 1' ).click();
+
+ await expect( prop ).toHaveText( 'link 1' );
+ await expect( nestedProp ).toHaveText( 'link 1' );
+
+ await page.goBack();
+ await expect( prop ).toHaveText( 'main' );
+ await expect( nestedProp ).toHaveText( 'main' );
+
+ await page.getByTestId( 'link 2' ).click();
+
+ await expect( prop ).toHaveText( 'link 2' );
+ await expect( nestedProp ).toHaveText( 'link 2' );
+ } );
+
+ test( 'should add new state props and keep them on navigation', async ( {
+ page,
+ } ) => {
+ const newProp = page.getByTestId( 'newProp' );
+ const nestedNewProp = page.getByTestId( 'nested.newProp' );
+
+ await expect( newProp ).toBeEmpty();
+ await expect( nestedNewProp ).toBeEmpty();
+
+ await page.getByTestId( 'link 1' ).click();
+
+ await expect( newProp ).toHaveText( 'link 1' );
+ await expect( nestedNewProp ).toHaveText( 'link 1' );
+
+ await page.goBack();
+ await expect( newProp ).toHaveText( 'link 1' );
+ await expect( nestedNewProp ).toHaveText( 'link 1' );
+
+ await page.getByTestId( 'link 2' ).click();
+
+ await expect( newProp ).toHaveText( 'link 2' );
+ await expect( nestedNewProp ).toHaveText( 'link 2' );
+ } );
+
+ test( 'should prevent any manual modifications', async ( { page } ) => {
+ const prop = page.getByTestId( 'prop' );
+ const button = page.getByTestId( 'tryToModifyServerState' );
+
+ await expect( prop ).toHaveText( 'main' );
+ await expect( button ).toHaveText( 'modify' );
+
+ await button.click();
+
+ await expect( prop ).toHaveText( 'main' );
+ await expect( button ).toHaveText( 'not modified ✅' );
+ } );
+} );
diff --git a/test/e2e/specs/site-editor/command-center.spec.js b/test/e2e/specs/site-editor/command-center.spec.js
index 5b049cda252a8b..19318081aa171b 100644
--- a/test/e2e/specs/site-editor/command-center.spec.js
+++ b/test/e2e/specs/site-editor/command-center.spec.js
@@ -28,10 +28,12 @@ test.describe( 'Site editor command palette', () => {
await page.keyboard.type( 'new page' );
await page.getByRole( 'option', { name: 'Add new page' } ).click();
await expect( page ).toHaveURL(
- '/wp-admin/post-new.php?post_type=page'
+ /\/wp-admin\/site-editor.php\?postId=(\d+)&postType=page&canvas=edit/
);
await expect(
- editor.canvas.getByRole( 'textbox', { name: 'Add title' } )
+ editor.canvas
+ .getByLabel( 'Block: Title' )
+ .locator( '[data-rich-text-placeholder="No title"]' )
).toBeVisible();
} );
diff --git a/test/e2e/specs/site-editor/font-library.spec.js b/test/e2e/specs/site-editor/font-library.spec.js
index 6d699f4b02a63e..7271768206d1b6 100644
--- a/test/e2e/specs/site-editor/font-library.spec.js
+++ b/test/e2e/specs/site-editor/font-library.spec.js
@@ -38,7 +38,7 @@ test.describe( 'Font Library', () => {
).toBeVisible();
} );
- test( 'should display the "Add fonts" button', async ( { page } ) => {
+ test( 'should display the "Manage fonts" icon', async ( { page } ) => {
await page
.getByRole( 'region', { name: 'Editor top bar' } )
.getByRole( 'button', { name: 'Styles' } )
@@ -46,10 +46,10 @@ test.describe( 'Font Library', () => {
await page
.getByRole( 'button', { name: 'Typography Styles' } )
.click();
- const addFontsButton = page.getByRole( 'button', {
- name: 'Add fonts',
+ const manageFontsIcon = page.getByRole( 'button', {
+ name: 'Manage fonts',
} );
- await expect( addFontsButton ).toBeVisible();
+ await expect( manageFontsIcon ).toBeVisible();
} );
} );
@@ -66,9 +66,7 @@ test.describe( 'Font Library', () => {
} );
} );
- test( 'should display the "Manage fonts" button', async ( {
- page,
- } ) => {
+ test( 'should display the "Manage fonts" icon', async ( { page } ) => {
await page
.getByRole( 'region', { name: 'Editor top bar' } )
.getByRole( 'button', { name: 'Styles' } )
@@ -76,13 +74,13 @@ test.describe( 'Font Library', () => {
await page
.getByRole( 'button', { name: 'Typography Styles' } )
.click();
- const manageFontsButton = page.getByRole( 'button', {
+ const manageFontsIcon = page.getByRole( 'button', {
name: 'Manage fonts',
} );
- await expect( manageFontsButton ).toBeVisible();
+ await expect( manageFontsIcon ).toBeVisible();
} );
- test( 'should open the "Manage fonts" modal when clicking the "Manage fonts" button', async ( {
+ test( 'should open the "Manage fonts" modal when clicking the "Manage fonts" icon', async ( {
page,
} ) => {
await page
diff --git a/test/gutenberg-test-themes/block-bindings/index.php b/test/gutenberg-test-themes/block-bindings/index.php
new file mode 100644
index 00000000000000..0c6530acc1aaff
--- /dev/null
+++ b/test/gutenberg-test-themes/block-bindings/index.php
@@ -0,0 +1,9 @@
+
+Custom template
+
diff --git a/test/gutenberg-test-themes/block-bindings/templates/index.html b/test/gutenberg-test-themes/block-bindings/templates/index.html
new file mode 100644
index 00000000000000..ab136ac8df9a7d
--- /dev/null
+++ b/test/gutenberg-test-themes/block-bindings/templates/index.html
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/test/gutenberg-test-themes/block-bindings/templates/single-movie.html b/test/gutenberg-test-themes/block-bindings/templates/single-movie.html
new file mode 100644
index 00000000000000..cd05d5fe917fea
--- /dev/null
+++ b/test/gutenberg-test-themes/block-bindings/templates/single-movie.html
@@ -0,0 +1,2 @@
+
+
diff --git a/test/gutenberg-test-themes/block-bindings/templates/single.html b/test/gutenberg-test-themes/block-bindings/templates/single.html
new file mode 100644
index 00000000000000..cd05d5fe917fea
--- /dev/null
+++ b/test/gutenberg-test-themes/block-bindings/templates/single.html
@@ -0,0 +1,2 @@
+
+
diff --git a/test/gutenberg-test-themes/block-bindings/theme.json b/test/gutenberg-test-themes/block-bindings/theme.json
new file mode 100644
index 00000000000000..c996b014328398
--- /dev/null
+++ b/test/gutenberg-test-themes/block-bindings/theme.json
@@ -0,0 +1,18 @@
+{
+ "$schema": "../../../schemas/json/theme.json",
+ "version": 3,
+ "settings": {
+ "appearanceTools": true,
+ "layout": {
+ "contentSize": "840px",
+ "wideSize": "1100px"
+ }
+ },
+ "customTemplates": [
+ {
+ "name": "custom-template",
+ "title": "Custom",
+ "postTypes": [ "post", "movie" ]
+ }
+ ]
+}
diff --git a/tools/webpack/script-modules.js b/tools/webpack/script-modules.js
index 18287c96d83c8a..021f11f5f5ed95 100644
--- a/tools/webpack/script-modules.js
+++ b/tools/webpack/script-modules.js
@@ -89,11 +89,11 @@ module.exports = {
},
output: {
devtoolNamespace: 'wp',
- filename: './build-module/[name].min.js',
+ filename: '[name].min.js',
library: {
type: 'module',
},
- path: join( __dirname, '..', '..' ),
+ path: join( __dirname, '..', '..', 'build-module' ),
environment: { module: true },
module: true,
chunkFormat: 'module',
@@ -102,7 +102,13 @@ module.exports = {
resolve: {
extensions: [ '.js', '.ts', '.tsx' ],
},
- plugins: [ ...plugins, new DependencyExtractionWebpackPlugin() ],
+ plugins: [
+ ...plugins,
+ new DependencyExtractionWebpackPlugin( {
+ combineAssets: true,
+ combinedOutputFile: `./assets.php`,
+ } ),
+ ],
watchOptions: {
ignored: [ '**/node_modules' ],
aggregateTimeout: 500,
diff --git a/tools/webpack/shared.js b/tools/webpack/shared.js
index c8c5b05c7d151b..f30d3a830f3eb1 100644
--- a/tools/webpack/shared.js
+++ b/tools/webpack/shared.js
@@ -25,7 +25,7 @@ const baseConfig = {
parallel: true,
terserOptions: {
output: {
- comments: /(translators:|wp:polyfill)/i,
+ comments: /translators:/i,
},
compress: {
passes: 2,