Skip to content

Commit 35e74e3

Browse files
Merge branch 'development' into feat/content-ai
2 parents 13fe22d + 9b80e24 commit 35e74e3

19 files changed

Lines changed: 1079 additions & 10 deletions

bin/e2e-tests.sh

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
#!/bin/sh
22

33
# Set-up the `wp_env` environment.
4-
npm run wp-env run cli wp option set themeisle_open_ai_api_key "sk_XXXXXXXXXXXXXXXXXXXXXXXx" # Used by AI tools.
4+
npm run wp-env run cli wp option set themeisle_open_ai_api_key "sk_XXXXXXXXXXXXXXXXXXXXXXXx" # Used by AI tools.
5+
npm run wp-env run cli wp rewrite structure '/%postname%/' # Pretty permalinks: the e2e global-setup reads /wp-json/.

inc/class-registration.php

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,21 @@ class Registration {
8282
*/
8383
public static $is_fa_loaded = false;
8484

85+
/**
86+
* Get the decoded global defaults for the editor, always as an object.
87+
*
88+
* The option can exist as an empty string or corrupt JSON, which json_decode
89+
* turns into null — and a null localized value crashes every block's Edit
90+
* component when the editor reads per-block defaults from it.
91+
*
92+
* @return object
93+
*/
94+
public static function get_editor_global_defaults() {
95+
$defaults = json_decode( get_option( 'themeisle_blocks_settings_global_defaults', '{}' ) );
96+
97+
return is_object( $defaults ) ? $defaults : new \stdClass();
98+
}
99+
85100
/**
86101
* Initialize the class
87102
*/
@@ -290,7 +305,7 @@ public function enqueue_block_editor_assets() {
290305
'optionsPath' => admin_url( 'admin.php?page=otter' ),
291306
'mapsAPI' => $api,
292307
'hasStripeAPI' => Stripe_API::has_keys(),
293-
'globalDefaults' => json_decode( get_option( 'themeisle_blocks_settings_global_defaults', '{}' ) ),
308+
'globalDefaults' => self::get_editor_global_defaults(),
294309
'themeDefaults' => Main::get_global_defaults(),
295310
'imageSizes' => function_exists( 'is_wpcom_vip' ) ? array( 'thumbnail', 'medium', 'medium_large', 'large' ) : get_intermediate_image_sizes(), // phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.get_intermediate_image_sizes_get_intermediate_image_sizes
296311
'isWPVIP' => function_exists( 'is_wpcom_vip' ),

packages/e2e-tests/mu-plugins/otter-e2e-bootstrap.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,7 @@ function stub_wp_mail_for_e2e( $short_circuit, $atts = array() ) {
199199
$log[] = array(
200200
'to' => isset( $atts['to'] ) ? $atts['to'] : '',
201201
'subject' => isset( $atts['subject'] ) ? $atts['subject'] : '',
202+
'headers' => isset( $atts['headers'] ) ? $atts['headers'] : array(),
202203
);
203204
update_option( MAIL_LOG_OPTION, $log, false );
204205

src/blocks/helpers/block-utility.js

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,11 @@ window.themeisleGutenberg.blockIDs ??= [];
4444
export const addGlobalDefaults = ( attributes, setAttributes, name, defaultAttributes ) => {
4545

4646
// Check if the globals default are available and its values are different from the base values.
47-
if ( undefined !== window.themeisleGutenberg?.globalDefaults && ! isEqual( globalDefaultsBlocksAttrs[name], window.themeisleGutenberg.globalDefaults[name]) ) {
48-
const defaultGlobalAttrs = { ...window.themeisleGutenberg.globalDefaults[name] };
47+
// Optional chaining throughout: a corrupt/empty option localizes globalDefaults as null.
48+
const globalDefaults = window.themeisleGutenberg?.globalDefaults?.[ name ];
49+
50+
if ( globalDefaults && ! isEqual( globalDefaultsBlocksAttrs[name], globalDefaults ) ) {
51+
const defaultGlobalAttrs = { ...globalDefaults };
4952

5053
const attrs = Object.keys( defaultGlobalAttrs )
5154
.filter( attr => isEqual( attributes[ attr ], defaultAttributes[ attr ]?.default ) ) // Keep only the properties with the default value.
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/**
2+
* Internal dependencies
3+
*/
4+
import { test, expect } from '../fixtures';
5+
import { setAtomicWind } from '../helpers/design-library';
6+
7+
/**
8+
* List View labels for Atomic Wind blocks: generic wrappers show their semantic
9+
* tag, text blocks show their content, and a custom name from the Rename UI wins.
10+
*/
11+
test.describe( 'Atomic Wind List View labels', () => {
12+
test.beforeEach( async({ otterUtils, admin }) => {
13+
await setAtomicWind( otterUtils, true );
14+
await admin.createNewPost();
15+
});
16+
17+
test.afterAll( async({ otterUtils }) => {
18+
await setAtomicWind( otterUtils, false );
19+
});
20+
21+
test( 'shows semantic and content-based names instead of generic titles', async({ editor, page }) => {
22+
// Top-level blocks only: List View collapses inner blocks by default.
23+
await editor.insertBlock({
24+
name: 'atomic-wind/box',
25+
attributes: { tagName: 'section' }
26+
});
27+
28+
await editor.insertBlock({
29+
name: 'atomic-wind/text',
30+
attributes: { content: 'Hello labels' }
31+
});
32+
33+
await editor.insertBlock({
34+
name: 'atomic-wind/box',
35+
attributes: {
36+
tagName: 'header',
37+
metadata: { name: 'Hero Wrapper' }
38+
}
39+
});
40+
41+
await page.getByLabel( 'Document Overview' ).click();
42+
43+
// The box labels resolve from the semantic tag, the text label from its content.
44+
await expect( page.getByRole( 'link', { name: 'Section', exact: true }) ).toBeVisible();
45+
await expect( page.getByRole( 'link', { name: 'Hello labels' }) ).toBeVisible();
46+
47+
// A custom name set through the core Rename UI must win over the tag label.
48+
await expect( page.getByRole( 'link', { name: 'Hero Wrapper' }) ).toBeVisible();
49+
await expect( page.getByRole( 'link', { name: 'Header', exact: true }) ).toBeHidden();
50+
});
51+
});

src/blocks/test/e2e/blocks/block-conditions.spec.js

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,4 +52,63 @@ test.describe( 'Block Conditions', () => {
5252
await page.goto( `/?p=${postId}` );
5353
await expect( page.locator( 'main .wp-block-image img, .entry-content .wp-block-image img' ).first() ).toBeHidden();
5454
});
55+
56+
test( 'build visibility conditions through the inspector rule builder', async({ editor, page }) => {
57+
await waitForEditorReady( page );
58+
59+
await editor.insertBlock({
60+
name: 'core/paragraph',
61+
attributes: { content: 'Members only content' }
62+
});
63+
64+
await editor.openDocumentSettingsSidebar();
65+
66+
// The panel is collapsed by default.
67+
await page.getByRole( 'button', { name: 'Visibility Conditions' }).click();
68+
69+
// First rule group: pick a condition through the searchable picker.
70+
await page.getByRole( 'button', { name: 'Add Rule Group' }).click();
71+
await page.getByRole( 'button', { name: 'Add condition' }).click();
72+
73+
await page.getByPlaceholder( 'Search conditions…' ).fill( 'logged in' );
74+
await page.locator( '.o-conditions-picker__item' ).filter({ hasText: 'Logged In Users' }).click();
75+
76+
await expect( page.locator( '.o-conditions__row-title' ) ).toHaveText( 'Logged In Users' );
77+
78+
// A second condition in the same group is combined with AND.
79+
await page.getByRole( 'button', { name: 'Add condition' }).click();
80+
await page.locator( '.o-conditions-picker__item' ).filter({ hasText: 'Logged Out Users' }).click();
81+
82+
await expect( page.locator( '.o-conditions__separator-label' ).filter({ hasText: 'AND' }) ).toBeVisible();
83+
84+
// A second rule group is combined with OR.
85+
await page.getByRole( 'button', { name: 'Add Rule Group' }).click();
86+
await expect( page.locator( '.o-conditions__separator-label' ).filter({ hasText: 'OR' }) ).toBeVisible();
87+
88+
// Deleting the extra group and condition trims the attribute back down.
89+
await page.getByRole( 'button', { name: 'Delete rule group' }).nth( 1 ).click();
90+
await expect( page.locator( '.o-conditions__separator-label' ).filter({ hasText: 'OR' }) ).toBeHidden();
91+
92+
await page.getByRole( 'button', { name: 'Remove condition' }).nth( 1 ).click();
93+
await expect( page.locator( '.o-conditions__separator-label' ).filter({ hasText: 'AND' }) ).toBeHidden();
94+
95+
const conditions = await page.evaluate(
96+
() => window.wp.data.select( 'core/block-editor' ).getSelectedBlock()?.attributes?.otterConditions
97+
);
98+
99+
expect( conditions ).toHaveLength( 1 );
100+
expect( conditions[0]).toHaveLength( 1 );
101+
expect( conditions[0][0].type ).toBe( 'loggedInUser' );
102+
103+
// The built condition drives the frontend: visible logged in, hidden logged out.
104+
const postId = await publishAndViewPost({ editor, page });
105+
await expect( page.getByText( 'Members only content' ) ).toBeVisible();
106+
107+
await page.getByRole( 'menuitem', { name: 'Howdy, admin' }).hover();
108+
await page.waitForTimeout( 200 );
109+
await page.getByRole( 'menuitem', { name: 'Log Out' }).click();
110+
111+
await page.goto( `/?p=${postId}` );
112+
await expect( page.getByText( 'Members only content' ) ).toBeHidden();
113+
});
55114
});

src/blocks/test/e2e/blocks/button-group.spec.js

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,4 +45,104 @@ test.describe( 'Button Group', () => {
4545
await expect( btn ).toHaveCSS( 'font-style', attributes.fontStyle );
4646
await expect( btn ).toHaveCSS( 'line-height', `${parseInt( attributes.fontSize ) * parseFloat( attributes.lineHeight )}px` ); // Playwright use computed line-height based on font-size.
4747
});
48+
49+
test( 'global defaults typography saves the right attributes and new blocks inherit them', async({ admin, editor, page, requestUtils }) => {
50+
const getSavedDefaults = async() => {
51+
const settings = await requestUtils.rest({ path: '/wp/v2/settings' });
52+
return JSON.parse( settings.themeisle_blocks_settings_global_defaults || '{}' )['themeisle-blocks/button-group'] ?? {};
53+
};
54+
55+
// Build the defaults through the Otter Options global-defaults UI.
56+
await page.getByRole( 'button', { name: 'Otter Options' }).click();
57+
await page.getByRole( 'button', { name: 'Block Settings' }).click();
58+
59+
const row = page.locator( '.o-options-block-item' ).filter({
60+
has: page.getByText( 'Button Group', { exact: true })
61+
});
62+
await row.getByRole( 'button', { name: 'Open Settings' }).click();
63+
64+
// Appearance and Letter Case are hidden until enabled from the view-options menu.
65+
await page.getByRole( 'button', { name: 'View options' }).click();
66+
await page.getByRole( 'menuitemcheckbox', { name: 'Appearance' }).click();
67+
await page.getByRole( 'menuitemcheckbox', { name: 'Letter Case' }).click();
68+
await page.keyboard.press( 'Escape' );
69+
70+
await page.getByLabel( 'Appearance' ).selectOption( 'italic' );
71+
await page.getByRole( 'button', { name: 'Uppercase' }).click();
72+
73+
await page.getByRole( 'button', { name: 'Save', exact: true }).click();
74+
75+
// Regression: Appearance/Letter Case used to be written to the wrong keys
76+
// (fontVariant/fontStyle) instead of fontStyle/textTransform.
77+
await expect.poll( getSavedDefaults ).toMatchObject({
78+
fontStyle: 'italic',
79+
textTransform: 'uppercase'
80+
});
81+
expect( ( await getSavedDefaults() ).fontVariant ).toBeUndefined();
82+
83+
// A Button Group inserted on a fresh editor load inherits the defaults.
84+
await admin.createNewPost();
85+
await editor.insertBlock({
86+
name: 'themeisle-blocks/button-group',
87+
innerBlocks: [
88+
{
89+
name: 'themeisle-blocks/button',
90+
attributes: { text: 'Inherit me' }
91+
}
92+
]
93+
});
94+
95+
await expect.poll( async() => {
96+
const blocks = await editor.getBlocks();
97+
const group = blocks.find( block => 'themeisle-blocks/button-group' === block.name );
98+
return {
99+
fontStyle: group?.attributes?.fontStyle,
100+
textTransform: group?.attributes?.textTransform
101+
};
102+
}).toEqual({ fontStyle: 'italic', textTransform: 'uppercase' });
103+
104+
await publishAndViewPost({ editor, page });
105+
106+
const btn = page.locator( 'a' ).filter({ hasText: 'Inherit me' });
107+
await expect( btn ).toHaveCSS( 'font-style', 'italic' );
108+
await expect( btn ).toHaveCSS( 'text-transform', 'uppercase' );
109+
});
110+
111+
test( 'editor survives an empty global defaults option', async({ admin, editor, page, requestUtils }) => {
112+
// Regression: an empty-string option json_decode()s to null server-side and
113+
// used to crash every newly inserted Otter block's Edit component.
114+
await requestUtils.rest({
115+
method: 'POST',
116+
path: '/wp/v2/settings',
117+
data: { themeisle_blocks_settings_global_defaults: '' }
118+
});
119+
120+
// A fresh editor load so the poisoned option gets localized.
121+
await admin.createNewPost();
122+
123+
await editor.insertBlock({
124+
name: 'themeisle-blocks/button-group',
125+
innerBlocks: [
126+
{
127+
name: 'themeisle-blocks/button',
128+
attributes: { text: 'Still alive' }
129+
}
130+
]
131+
});
132+
133+
// The block renders instead of falling into the block crash boundary.
134+
await expect( editor.canvas.getByText( 'Still alive' ) ).toBeVisible();
135+
await expect( editor.canvas.locator( '.block-editor-warning' ) ).toHaveCount( 0 );
136+
});
137+
138+
// Global defaults persist site-wide; reset them so other specs are unaffected.
139+
// Must be '{}', not '': on unfixed builds the editor bootstrap json_decode()s
140+
// the raw option and a null result crashes every Otter block's Edit component.
141+
test.afterEach( async({ requestUtils }) => {
142+
await requestUtils.rest({
143+
method: 'POST',
144+
path: '/wp/v2/settings',
145+
data: { themeisle_blocks_settings_global_defaults: '{}' }
146+
});
147+
});
48148
});

src/blocks/test/e2e/blocks/form-retention.spec.js

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
*/
44
import { test, expect } from '../fixtures';
55
import { expectBlockByName, publishPostReliable } from '../helpers/editor';
6-
import { expectFormOptionSavedNotice, findSavedFormEmail, getEmailNotificationToggle, getSavedFormEmails, insertContactForm, prepareFormOptionsInspector } from '../helpers/forms';
6+
import { expectFormOptionSavedNotice, findSavedFormEmail, getEmailNotificationToggle, getSavedFormEmails, insertContactForm, prepareFormOptionsInspector, showFormOption } from '../helpers/forms';
77
import { expectSuccessMessage } from '../helpers/frontend';
88

99
/**
@@ -488,4 +488,55 @@ test.describe( 'Form submission retention', () => {
488488
// No field label (the required Name/Email included) may carry the required marker.
489489
records[0].inputs.forEach( input => expect( input.label ).not.toContain( '*' ) );
490490
});
491+
492+
test( 'notification email Reply-To defaults to the submitter and honors the Reply-To Email option', async({ admin, editor, page, otterUtils }) => {
493+
const joinHeaders = ( headers ) => [].concat( headers ?? [] ).join( '\n' );
494+
495+
await insertContactForm({ editor, page });
496+
const postId = await publishPostReliable( editor, page );
497+
await expectFormOptionSavedNotice( page );
498+
499+
// Clear the mail log of anything the publish flow may have attempted.
500+
await otterUtils.setMailMode( 'ok' );
501+
502+
await page.goto( `/?p=${postId}` );
503+
await fillAndSubmitContactForm( page );
504+
await expectSuccessMessage( page );
505+
506+
// Without an explicit option, replies go to the address the visitor entered.
507+
let mailLog = await otterUtils.getMailLog();
508+
expect( mailLog ).toHaveLength( 1 );
509+
expect( joinHeaders( mailLog[0].headers ) ).toContain( 'Reply-To: ada@example.com' );
510+
511+
// Set an explicit Reply-To through the inspector control.
512+
await admin.editPost( postId );
513+
514+
await page.waitForFunction( () => 0 < window.wp?.data?.select( 'core/block-editor' )?.getBlocks()?.length );
515+
await page.evaluate( () => {
516+
const blocks = window.wp.data.select( 'core/block-editor' ).getBlocks();
517+
const form = blocks.find( block => 'themeisle-blocks/form' === block.name );
518+
window.wp.data.dispatch( 'core/block-editor' ).selectBlock( form.clientId );
519+
});
520+
await editor.openDocumentSettingsSidebar();
521+
522+
// Wait until the Form Options panel is ready, then reveal the hidden-by-default control.
523+
await getEmailNotificationToggle( page );
524+
await showFormOption( page, 'Reply-To Email' );
525+
await page.getByLabel( 'Reply-To Email' ).fill( 'owner-replies@example.com' );
526+
527+
const saveBtn = page.locator( '.editor-post-publish-button__button' );
528+
await expect( saveBtn ).toBeEnabled({ timeout: 10_000 });
529+
await saveBtn.click();
530+
await expectFormOptionSavedNotice( page );
531+
532+
await otterUtils.setMailMode( 'ok' );
533+
534+
await page.goto( `/?p=${postId}` );
535+
await fillAndSubmitContactForm( page );
536+
await expectSuccessMessage( page );
537+
538+
mailLog = await otterUtils.getMailLog();
539+
expect( mailLog ).toHaveLength( 1 );
540+
expect( joinHeaders( mailLog[0].headers ) ).toContain( 'Reply-To: owner-replies@example.com' );
541+
});
491542
});

src/blocks/test/e2e/blocks/live-search.spec.js

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,4 +51,38 @@ test.describe( 'Live Search Block', () => {
5151
const width = await container.evaluate( node => node.offsetWidth );
5252
expect( width ).toBeGreaterThan( 0 );
5353
});
54+
55+
test( 'post-only search query without a category renders cleanly', async({ editor, page }) => {
56+
const errors = [];
57+
page.on( 'pageerror', error => errors.push( error.message ) );
58+
page.on( 'console', message => {
59+
if ( 'error' === message.type() ) {
60+
errors.push( message.text() );
61+
}
62+
});
63+
64+
// Regression: a post-only query with no 'cat' key used to raise an
65+
// undefined-array-key PHP warning while rendering the block.
66+
await editor.insertBlock({
67+
name: 'core/search',
68+
attributes: {
69+
otterIsLive: true,
70+
otterSearchQuery: {
71+
'post_type': [ 'post' ]
72+
}
73+
}
74+
});
75+
76+
await publishAndViewPost({ editor, page });
77+
78+
await expect( page.locator( 'body' ) ).not.toContainText( 'Undefined array key' );
79+
80+
const input = page.locator( '.o-live-search input[type="search"]' );
81+
await expect( input ).toBeVisible();
82+
83+
await input.fill( 'u' );
84+
await expect( page.locator( '.o-live-search .container-wrap' ) ).toBeVisible();
85+
86+
expect( errors ).toEqual([]);
87+
});
5488
});

src/blocks/test/e2e/fixtures.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { test as base, expect } from '@wordpress/e2e-test-utils-playwright';
66
export type MailLogEntry = {
77
to: string | string[];
88
subject: string;
9+
headers: string | string[];
910
};
1011

1112
export type FormRecord = {

0 commit comments

Comments
 (0)