Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .wp-env.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@
},
"mappings": {
"wp-content/mu-plugins": "./packages/e2e-tests/mu-plugins",
"wp-content/themes/raft": "https://downloads.wordpress.org/theme/raft.zip"
"wp-content/themes/raft": "https://downloads.wordpress.org/theme/raft.zip",
"wp-content/plugins/woocommerce": "./vendor/wp-content/plugins/woocommerce"
Comment thread
Soare-Robert-Daniel marked this conversation as resolved.
},
"lifecycleScripts": {
"afterStart": "bash bin/e2e-tests.sh"
Expand Down
8 changes: 4 additions & 4 deletions composer.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

105 changes: 105 additions & 0 deletions packages/e2e-tests/mu-plugins/otter-e2e-bootstrap.php
Original file line number Diff line number Diff line change
Expand Up @@ -1226,6 +1226,111 @@ function () {
)
);

register_rest_route(
REST_NAMESPACE,
'/woo/product',
array(
'methods' => \WP_REST_Server::CREATABLE,
'permission_callback' => __NAMESPACE__ . '\\require_admin',
'callback' => function ( \WP_REST_Request $request ) {
if ( ! class_exists( 'WC_Product_Simple' ) ) {
return new \WP_Error(
'otter_e2e_no_woocommerce',
'WooCommerce is not active in the environment.',
array( 'status' => 500 )
);
}

// The spec activates WooCommerce right before this call; drop
// the redirect it schedules so it cannot hijack admin visits.
delete_transient( '_wc_activation_redirect' );

$product = new \WC_Product_Simple();
$product->set_name( $request->get_param( 'title' ) ? sanitize_text_field( $request->get_param( 'title' ) ) : 'E2E Product' );
$product->set_regular_price( '49.99' );
$product->set_status( 'publish' );
$id = $product->save();

if ( rest_sanitize_boolean( $request->get_param( 'builder' ) ) ) {
update_post_meta( $id, '_themeisle_gutenberg_woo_builder', true );
}

return rest_ensure_response( array( 'id' => $id ) );
},
)
);

register_rest_route(
REST_NAMESPACE,
'/woo/product/delete',
array(
'methods' => \WP_REST_Server::CREATABLE,
'permission_callback' => __NAMESPACE__ . '\\require_admin',
'callback' => function ( \WP_REST_Request $request ) {
$deleted = array();

foreach ( (array) $request->get_param( 'ids' ) as $id ) {
$id = absint( $id );

// Only ever remove products, so a stale id from a spec
// cannot delete unrelated content in a reused env.
if ( 0 === $id || 'product' !== get_post_type( $id ) ) {
continue;
}

wp_delete_post( $id, true );
$deleted[] = $id;
}

return rest_ensure_response( array( 'deleted' => $deleted ) );
},
)
);

register_rest_route(
REST_NAMESPACE,
'/user/meta-box-order',
array(
'methods' => \WP_REST_Server::CREATABLE,
'permission_callback' => __NAMESPACE__ . '\\require_admin',
'callback' => function ( \WP_REST_Request $request ) {
$order = $request->get_param( 'order' );

if ( ! is_array( $order ) || empty( $order ) ) {
delete_user_meta( get_current_user_id(), 'meta-box-order_product' );
} else {
update_user_meta( get_current_user_id(), 'meta-box-order_product', array_map( 'sanitize_text_field', $order ) );
}

return rest_ensure_response( array( 'ok' => true ) );
},
)
);

register_rest_route(
REST_NAMESPACE,
'/user/meta-boxes-pane/reset',
array(
'methods' => \WP_REST_Server::CREATABLE,
'permission_callback' => __NAMESPACE__ . '\\require_admin',
'callback' => function () {
$user_id = get_current_user_id();
$meta_key = $GLOBALS['wpdb']->get_blog_prefix() . 'persisted_preferences';
$preferences = get_user_meta( $user_id, $meta_key, true );

if ( is_array( $preferences ) && isset( $preferences['core/edit-post'] ) ) {
unset(
$preferences['core/edit-post']['metaBoxesMainIsOpen'],
$preferences['core/edit-post']['metaBoxesMainOpenHeight']
);
update_user_meta( $user_id, $meta_key, $preferences );
}

return rest_ensure_response( array( 'ok' => true ) );
},
)
);

register_rest_route(
REST_NAMESPACE,
'/reset',
Expand Down
59 changes: 59 additions & 0 deletions plugins/otter-pro/inc/plugins/class-woocommerce-builder.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,65 @@ public function init() {
add_filter( 'wc_get_template_part', array( $this, 'wc_get_template_part' ), 1000, 3 );
add_action( 'otter_blocks_woocommerce_content', 'the_content' );
add_filter( 'body_class', array( $this, 'add_body_class' ), 1000, 1 );
add_action( 'enqueue_block_editor_assets', array( $this, 'show_meta_boxes_pane' ) );
add_filter( 'get_user_option_meta-box-order_product', array( $this, 'restore_product_data_location' ) );
}

/**
* Keep the Product data metabox out of the narrow side column.
*
* The metabox "Move up/down" arrows persist the order instantly and, at the
* edge of an area, relocate a box into the adjacent area. From the block
* editor (used by WooCommerce Builder products) one accidental click can
* move WooCommerce's Product data box into "side", where it renders inside
* the ~280px sidebar and its layout breaks. Correct it at read time; the
* stored user option is left untouched.
*
* @param mixed $order Saved metabox order for the product screen.
*
* @access public
* @return mixed
*/
public function restore_product_data_location( $order ) {
if ( ! boolval( get_post_meta( get_the_ID(), '_themeisle_gutenberg_woo_builder', true ) ) ) {
return $order;
}

if ( ! is_array( $order ) || ! isset( $order['side'] ) || false === strpos( $order['side'], 'woocommerce-product-data' ) ) {
Comment thread
Soare-Robert-Daniel marked this conversation as resolved.
return $order;
}

$side = array_diff( explode( ',', $order['side'] ), array( 'woocommerce-product-data' ) );
$normal = empty( $order['normal'] ) ? array() : explode( ',', $order['normal'] );
array_unshift( $normal, 'woocommerce-product-data' );

$order['side'] = implode( ',', $side );
$order['normal'] = implode( ',', array_unique( $normal ) );

return $order;
}

/**
* Keep the Meta Boxes pane open by default in the block editor.
*
* Since WP 6.7 the iframed post editor renders meta boxes inside a bottom
* drawer that is collapsed unless the user opened it before. On builder
* products that hides the WooCommerce Product data panel (price, inventory
* etc.), so default the drawer to open. An explicit user preference is not
* overridden, as setDefaults only applies to unset preferences.
*
* @access public
* @return void
*/
public function show_meta_boxes_pane() {
if ( 'product' !== get_post_type() || ! boolval( get_post_meta( get_the_ID(), '_themeisle_gutenberg_woo_builder', true ) ) ) {
Comment on lines +83 to +84

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in 3bc2a95tests/test-woocommerce-builder.php creates the post contexts and inspects the wp-edit-post inline data: the default is present for a builder-enabled product and absent for both a product without the builder and a regular post.

return;
}

wp_add_inline_script(
'wp-edit-post',
'window.wp && wp.data && wp.data.dispatch( "core/preferences" ).setDefaults( "core/edit-post", { metaBoxesMainIsOpen: true } );'
);
}

/**
Expand Down
145 changes: 145 additions & 0 deletions src/blocks/test/e2e/blocks/woocommerce-builder.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
/**
* Internal dependencies
*/
import { test, expect } from '../fixtures';

/**
* WooCommerce Builder forces the block editor for enabled products. Since
* WP 6.7 the iframed post editor renders meta boxes in a bottom drawer, so
* these tests pin the two guarantees that keep WooCommerce's Product data
* panel (price, inventory, …) reachable there:
*
* 1. the drawer defaults to open (an explicit user preference still wins);
* 2. a `meta-box-order_product` that strands the Product data box in the
* "side" area (one accidental click on the metabox move arrows persists
* that) is corrected back into the main area at render time.
*/

const PRODUCT_DATA = '#woocommerce-product-data';
const META_BOXES_DRAWER = '.edit-post-meta-boxes-main';

/**
* The Otter welcome tour pops on fresh profiles; close it if it appears.
*
* @param {import('@playwright/test').Page} page Playwright page.
*/
const dismissOtterTour = async( page ) => {
const guide = page.locator( '.components-guide' );
if ( await guide.isVisible({ timeout: 2000 }).catch( () => false ) ) {
await page.keyboard.press( 'Escape' );
await expect( guide ).toBeHidden();
}
};

test.describe( 'WooCommerce Builder product editor', () => {
// WooCommerce is mounted by wp-env but only activated around this spec —
// its editor integrations would change load behavior (and performance
// numbers) for every other suite. Serial project only.
test.beforeAll( async({ requestUtils }) => {
await requestUtils.activatePlugin( 'woocommerce' );
});

test.afterAll( async({ requestUtils }) => {
await requestUtils.deactivatePlugin( 'woocommerce' );
});

// A drawer preference or metabox order left by an earlier run would skew
// the first test as much as a later one, so reset on both sides.
const resetSharedUserState = async( otterUtils ) => {
await otterUtils.setProductMetaBoxOrder( null );
await otterUtils.resetMetaBoxesPane();
};

// wp-env is reused between runs, so every product a test publishes has to
// be removed again or they pile up and skew later product suites.
let createdProductIds = [];

const createProduct = async( otterUtils, args ) => {
const { id } = await otterUtils.createWooProduct( args );

createdProductIds.push( id );

return id;
};

test.beforeEach( async({ otterUtils }) => {
await resetSharedUserState( otterUtils );
});

// Runs while WooCommerce is still active (afterAll deactivates it), so the
// deletes also clear its product lookup tables.
test.afterEach( async({ otterUtils }) => {
await resetSharedUserState( otterUtils );

if ( createdProductIds.length ) {
await otterUtils.deleteWooProducts( createdProductIds );
createdProductIds = [];
}
});

test( 'builder products open in the block editor with the Product data panel visible', async({ admin, page, otterUtils }) => {
const id = await createProduct( otterUtils, { builder: true });

await admin.editPost( id );
await dismissOtterTour( page );

// Block editor is active for the builder product…
await expect( page.locator( 'body.block-editor-page' ) ).toHaveCount( 1 );

// …and the Product data metabox is visible without any interaction:
// the meta boxes drawer defaults to open on builder products.
await expect( page.locator( PRODUCT_DATA ) ).toBeVisible();
await expect( page.locator( `${ PRODUCT_DATA } input[name="_regular_price"]` ) ).toHaveValue( '49.99' );
});

test( 'Product data stranded in the side area is rescued into the drawer', async({ admin, page, otterUtils }) => {
const id = await createProduct( otterUtils, { builder: true });

// The corrupted layout one metabox arrow click can persist: Product
// data serialized into "side", which renders inside the ~280px
// sidebar where its layout breaks.
await otterUtils.setProductMetaBoxOrder({
normal: '',
advanced: 'commentsdiv,postexcerpt',
side: 'woocommerce-product-data,otter_woo_builder,woocommerce-product-images'
});

await admin.editPost( id );
await dismissOtterTour( page );

await expect( page.locator( `${ META_BOXES_DRAWER } ${ PRODUCT_DATA }` ) ).toBeVisible();
await expect( page.locator( `.interface-complementary-area ${ PRODUCT_DATA }` ) ).toHaveCount( 0 );
});

test( 'products without the builder keep the classic editor', async({ admin, page, otterUtils }) => {
const id = await createProduct( otterUtils, { builder: false });

await admin.visitAdminPage( 'post.php', `post=${ id }&action=edit` );

await expect( page.locator( 'body.block-editor-page' ) ).toHaveCount( 0 );
await expect( page.locator( PRODUCT_DATA ) ).toBeVisible();
});

test( 'an explicitly collapsed drawer stays collapsed (user preference wins)', async({ admin, page, otterUtils }) => {
const id = await createProduct( otterUtils, { builder: true });

await admin.editPost( id );
await dismissOtterTour( page );
await expect( page.locator( PRODUCT_DATA ) ).toBeVisible();

// Collapse the drawer — this persists the user preference, which
// setDefaults() must not override on the next load. Keyboard: the
// toggle's click point is covered by its drag-resize separator. The
// preference write is debounced, so hold for the REST flush before
// reloading.
await Promise.all([
page.waitForResponse( ( response ) => response.url().includes( '/wp/v2/users/me' ) ),
page.getByRole( 'button', { name: 'Meta Boxes' }).press( 'Enter' )
]);
await expect( page.locator( PRODUCT_DATA ) ).toBeHidden();

await admin.editPost( id );
await expect( page.locator( META_BOXES_DRAWER ) ).toBeVisible();
await expect( page.locator( PRODUCT_DATA ) ).toBeHidden();
});
});
18 changes: 17 additions & 1 deletion src/blocks/test/e2e/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,18 @@ export type OtterUtils = {

/** Hard-delete all Submission Records. */
cleanupFormRecords: () => Promise<unknown>;

/** Create a published simple WooCommerce product (price 49.99); `builder` also enables WooCommerce Builder on it. */
createWooProduct: ( args?: { title?: string; builder?: boolean } ) => Promise<{ id: number }>;

/** Hard-delete products created by a spec. Ids that are not products are ignored. */
deleteWooProducts: ( ids: number[] ) => Promise<unknown>;

/** Set the current user's product metabox order (`meta-box-order_product`); null/empty resets it. */
setProductMetaBoxOrder: ( order: Record<string, string> | null ) => Promise<unknown>;

/** Remove the current user's persisted meta-boxes-pane preferences (open state and height). */
resetMetaBoxesPane: () => Promise<unknown>;
};

export const test = base.extend<{ otterUtils: OtterUtils }>({
Expand Down Expand Up @@ -91,7 +103,11 @@ export const test = base.extend<{ otterUtils: OtterUtils }>({
return response.nonce;
},
getFormRecords: () => call( 'form/records' ) as Promise<FormRecord[]>,
cleanupFormRecords: () => call( 'form/records/cleanup' )
cleanupFormRecords: () => call( 'form/records/cleanup' ),
createWooProduct: ( args ) => call( 'woo/product', args ?? {}) as Promise<{ id: number }>,
deleteWooProducts: ( ids ) => call( 'woo/product/delete', { ids }),
setProductMetaBoxOrder: ( order ) => call( 'user/meta-box-order', { order }),
resetMetaBoxesPane: () => call( 'user/meta-boxes-pane/reset' )
});
}
});
Expand Down
Loading
Loading