Skip to content
Merged
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
2 changes: 1 addition & 1 deletion build/gutenberg/index.asset.php

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

2 changes: 1 addition & 1 deletion build/gutenberg/index.js

Large diffs are not rendered by default.

24 changes: 20 additions & 4 deletions classes/class-preview.php
Original file line number Diff line number Diff line change
Expand Up @@ -336,12 +336,28 @@ public function print_template() {
)
);

// phpcs:disable WordPress.Security.NonceVerification.Missing
$post_data = isset( $_POST ) ? $_POST : array();

// The block editor posts its attributes as one JSON field, since a field per
// value runs past `max_input_vars` on a gallery of a few hundred images.
// Unpacked here into the fields it replaces, so everything below reads the same.
if ( isset( $post_data['vp_preview_attributes'] ) ) {
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- sanitized below with the rest of the post data.
$attributes = json_decode( wp_unslash( $post_data['vp_preview_attributes'] ), true );

unset( $post_data['vp_preview_attributes'] );

if ( is_array( $attributes ) ) {
$post_data = array_merge( $post_data, wp_slash( $attributes ) );
}
}

// Post data for script.
wp_localize_script(
'visual-portfolio-preview',
'vp_preview_post_data',
// phpcs:disable WordPress.Security.NonceVerification.Missing
isset( $_POST ) && ! empty( $_POST ) ? Visual_Portfolio_Security::sanitize_attributes( $_POST ) : array()
! empty( $post_data ) ? Visual_Portfolio_Security::sanitize_attributes( $post_data ) : array()
);

$class_name = 'vp-preview-wrapper';
Expand All @@ -355,8 +371,8 @@ public function print_template() {

// Prepare portfolio post options.
$options = array();
if ( isset( $_POST ) && ! empty( $_POST ) ) {
foreach ( $_POST as $name => $val ) {
if ( ! empty( $post_data ) ) {
foreach ( $post_data as $name => $val ) {
if ( strpos( $name, 'vp_' ) === 0 ) {
$options[ preg_replace( '/^vp_/', '', $name ) ] = $val;
}
Expand Down
98 changes: 46 additions & 52 deletions gutenberg/components/iframe-preview/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import './live-reload-conditions';

import { Spinner } from '@wordpress/components';
import { dispatch, select, subscribe, withSelect } from '@wordpress/data';
import { Component, createRef, Fragment } from '@wordpress/element';
import { Component, createRef } from '@wordpress/element';
import { applyFilters } from '@wordpress/hooks';
import classnames from 'classnames/dedupe';
import $ from 'jquery';
Expand All @@ -29,6 +29,40 @@ let uniqueIdCount = 1;
// easing before the next one starts and the movement reads as continuous rather than stepped.
const PREVIEW_RESIZE_INTERVAL = 400;

/**
* Value the way PHP reads a form field: scalars as strings, `true` / `false` as
* `1` / `0`, and an empty object left out, since it has no field to post.
*
* @param {Mixed} val - attribute value.
*
* @return {Mixed} string, object of them, or undefined for an empty object.
*/
function toPostValue(val) {
if (typeof val === 'boolean') {
return val ? '1' : '0';
}

if (typeof val === 'number') {
return `${val}`;
}

if (typeof val === 'object' && val !== null) {
const result = {};

Object.keys(val).forEach((key) => {
const item = toPostValue(val[key]);

if (typeof item !== 'undefined') {
result[key] = item;
}
});

return Object.keys(result).length ? result : undefined;
}

return val || '';
}

function getUpdatedKeys(oldData, newData) {
const keys = uniq([...Object.keys(oldData), ...Object.keys(newData)]);
const changedKeys = [];
Expand Down Expand Up @@ -87,7 +121,6 @@ class IframePreview extends Component {
this.maybeResizePreviews
);
this.updateIframeHeight = this.updateIframeHeight.bind(this);
this.printInput = this.printInput.bind(this);

this.trackBlockPosition = this.trackBlockPosition.bind(this);
}
Expand Down Expand Up @@ -286,12 +319,6 @@ class IframePreview extends Component {
});

if (!data.reload) {
// Update AJAX dynamic data.
if (data.frameWindow && data.frameWindow.vp_preview_post_data) {
data.frameWindow.vp_preview_post_data[data.name] =
data.value;
}

// Insert dynamic CSS.
if (this.previewFrame && newAttributes.block_id) {
this.previewFrame.sendMessage({
Expand Down Expand Up @@ -416,44 +443,6 @@ class IframePreview extends Component {
});
}

/**
* Prepare form input for POST variables.
*
* @param {string} name - option name.
* @param {Mixed} val - option value.
*
* @return {JSX} - form control.
*/
printInput(name, val) {
const params = {
type: 'text',
name,
value: val,
readOnly: true,
};

if (typeof val === 'number') {
params.type = 'number';
} else if (typeof val === 'boolean') {
params.type = 'number';
params.value = val ? 1 : 0;
} else if (typeof val === 'object' && val !== null) {
return (
<>
{Object.keys(val).map((i) => (
<Fragment key={`${name}[${i}]`}>
{this.printInput(`${name}[${i}]`, val[i])}
</Fragment>
))}
</>
);
} else {
params.value = params.value || '';
}

return <input {...params} />;
}

render() {
const { postType, postId } = this.props;

Expand All @@ -467,7 +456,7 @@ class IframePreview extends Component {

// Convert attributes for form submission.
Object.keys(attributes).forEach((key) => {
formData[`vp_${key}`] = attributes[key];
formData[`vp_${key}`] = toPostValue(attributes[key]);
});

return (
Expand Down Expand Up @@ -540,11 +529,16 @@ class IframePreview extends Component {
readOnly
/>
) : (
Object.entries(formData).map(([key, value]) => (
<Fragment key={key}>
{this.printInput(key, value)}
</Fragment>
))
// One field for all attributes. A field per value
// runs past PHP's `max_input_vars` (1000 by default)
// on a gallery of a few hundred images, and hosts
// that cap the field count reject the request.
<input
type="hidden"
name="vp_preview_attributes"
value={JSON.stringify(formData)}
readOnly
/>
)}
</form>
<iframe
Expand Down
110 changes: 110 additions & 0 deletions tests/e2e/specs/gallery-preview-many-images.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
/**
* Block editor preview of a Media source gallery with a few hundred images.
*
* Every image used to reach the preview as several POST fields, and past PHP's
* `max_input_vars` (1000 by default) the attributes after `images` were dropped.
*/
import { expect, test } from '@wordpress/e2e-test-utils-playwright';

import {
getEditorCanvas,
getPortfolioPreviewFrame,
} from '../utils/editor-canvas';
import { getPluginSlug } from '../utils/plugin-slug';
import { waitForPortfolioPreview } from '../utils/portfolio-preview';

/**
* A Media source block with more image fields than `max_input_vars` allows.
*
* @param {Object} attributes - attributes to add.
* @return {Object} block payload for `editor.insertBlock()`.
*/
function getManyImagesBlock(attributes = {}) {
const images = Array.from({ length: 400 }, (value, i) => ({
imgUrl: `https://example.com/image-${i}.png`,
imgThumbnailUrl: `https://example.com/image-${i}.png`,
title: `Image ${i}`,
}));

return {
name: 'visual-portfolio/block',
attributes: {
block_id: 'e2e-many-images',
content_source: 'images',
images,
...attributes,
},
};
}

test.describe('preview of a gallery with many images', () => {
test.beforeEach(async ({ requestUtils }) => {
await requestUtils.activatePlugin(getPluginSlug());
});

test.afterEach(async ({ requestUtils }) => {
await requestUtils.deleteAllPosts();
});

test('keeps the settings that follow the images', async ({
admin,
editor,
page,
}) => {
await admin.createNewPost();
await editor.insertBlock(getManyImagesBlock({ items_count: 3 }));

await waitForPortfolioPreview(page, { editor });

await expect(
getPortfolioPreviewFrame(page, editor).locator(
'.vp-portfolio__item-wrap'
)
).toHaveCount(3);
});

test('survives a preview request the server rejects', async ({
admin,
editor,
page,
}) => {
// A host that rejects the request serves its own error page, without the
// isolation header the editor's frame needs, so the editor cannot read it.
await page.route(/[?&]vp_preview=/, (route) =>
route.request().method() === 'POST'
? route.fulfill({
status: 403,
contentType: 'text/html',
body: '<h1>Forbidden</h1>',
})
: route.continue()
);

await admin.createNewPost();
await editor.insertBlock(getManyImagesBlock());

const canvas = getEditorCanvas(page, editor);

await expect(
canvas.locator('.visual-portfolio-gutenberg-preview-loading')
).toHaveCount(0);

// A setting applied without a reload, which used to read the frame's window.
await page.evaluate(() => {
const { select, dispatch } = window.wp.data;
const block = select('core/block-editor')
.getBlocks()
.find(({ name }) => name === 'visual-portfolio/block');

dispatch('core/block-editor').updateBlockAttributes(
block.clientId,
{ items_gap: 33 }
);
});

await expect(canvas.locator('[title="vp-preview"]')).toBeVisible();
await expect(
canvas.getByText('This block has encountered an error')
).toHaveCount(0);
});
});
Loading