Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Widgets: Fix creating and editing non-multi widgets #32978

Merged
merged 5 commits into from
Jun 28, 2021
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
51 changes: 51 additions & 0 deletions packages/e2e-tests/plugins/marquee-function-widget.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<?php
/**
* Plugin Name: Gutenberg Test Marquee Widget
* Plugin URI: https://github.com/WordPress/gutenberg
* Author: Gutenberg Team
*
* @package gutenberg-test-marquee-widget
*/

/**
* Add a non-WP_Widget marquee widget.
*/
function marquee_greeting_init() {
wp_register_sidebar_widget(
'marquee_greeting',
'Marquee Greeting',
function() {
$greeting = get_option( 'marquee_greeting', 'Hello!' );
printf( '<marquee>%s</marquee>', esc_html( $greeting ) );
}
);

wp_register_widget_control(
'marquee_greeting',
'Marquee Greeting',
function() {
if ( isset( $_POST['marquee-greeting'] ) ) {
update_option(
'marquee_greeting',
sanitize_text_field( $_POST['marquee-greeting'] )
);
}

$greeting = get_option( 'marquee_greeting' );
?>
<p>
<label for="marquee-greeting">Greeting:</label>
<input
id="marquee-greeting"
class="widefat"
name="marquee-greeting"
type="text"
value="<?php echo esc_attr( $greeting ); ?>"
placeholder="Hello!"
/>
</p>
<?php
}
);
}
add_action( 'init', 'marquee_greeting_init' );
109 changes: 108 additions & 1 deletion packages/e2e-tests/specs/widgets/editing-widgets.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {
* External dependencies
*/
// eslint-disable-next-line no-restricted-imports
import { find, findAll } from 'puppeteer-testing-library';
import { find, findAll, waitFor } from 'puppeteer-testing-library';
import { groupBy, mapValues } from 'lodash';

describe( 'Widgets screen', () => {
Expand Down Expand Up @@ -394,6 +394,111 @@ describe( 'Widgets screen', () => {
` );
} );

async function addMarquee() {
// There will be 2 matches here.
// One is the in-between inserter,
// and the other one is the button block appender.
const [ inlineInserterButton ] = await findAll( {
role: 'combobox',
name: 'Add block',
} );
await inlineInserterButton.click();

// TODO: Convert to find() API from puppeteer-testing-library.
const inserterSearchBox = await page.waitForSelector(
'aria/Search for blocks and patterns[role="searchbox"]'
);
await expect( inserterSearchBox ).toHaveFocus();

await page.keyboard.type( 'Marquee' );

const inlineQuickInserter = await find( {
role: 'listbox',
name: 'Blocks',
} );
const marqueeBlockOption = await find(
{
role: 'option',
},
{
root: inlineQuickInserter,
}
);
await marqueeBlockOption.click();
}

it( 'Should add and save the marquee widget', async () => {
Copy link
Contributor

Choose a reason for hiding this comment

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

Is this test about the implementation of the marquee widget specifically? I'm not sure it is.

Therefore, I wonder whether we ought to rename this test to be more descriptive about the feature actually under test here?

await activatePlugin( 'gutenberg-test-marquee-widget' );
await visitAdminPage( 'widgets.php' );

await addMarquee();

await find( {
selector: '[data-block][data-type="core/legacy-widget"]',
} );
Comment on lines +436 to +438
Copy link
Member

Choose a reason for hiding this comment

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

Why do we need this?


const greetingsInput = await find( {
selector: '#marquee-greeting',
} );
await greetingsInput.click();
await page.keyboard.type( 'Howdy' );

await saveWidgets();

let editedSerializedWidgetAreas = await getSerializedWidgetAreas();
await expect( editedSerializedWidgetAreas ).toMatchInlineSnapshot( `
Copy link
Member

Choose a reason for hiding this comment

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

The await here is unnecessary.

Suggested change
await expect( editedSerializedWidgetAreas ).toMatchInlineSnapshot( `
expect( editedSerializedWidgetAreas ).toMatchInlineSnapshot( `

Object {
"sidebar-1": "<marquee>Hello!</marquee>",
Copy link
Member

Choose a reason for hiding this comment

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

Is it intentional that we type Howdy above but showing Hello! here in the snapshot?

}
` );

await page.reload();

editedSerializedWidgetAreas = await getSerializedWidgetAreas();
await expect( editedSerializedWidgetAreas ).toMatchInlineSnapshot( `
Object {
"sidebar-1": "<marquee>Hello!</marquee>",
}
` );

// Add another marquee, it shouldn't be saved
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
// Add another marquee, it shouldn't be saved
// Add another marquee

Nit: this bit doesn't perform any assertions on whether it's saved. As a result, this comment confused me slightly.

await addMarquee();

// It takes a moment to load the form, let's wait for it.
await waitFor( async () => {
const marquees = await findAll( {
selector: '[id=marquee-greeting]',
} );
if ( marquees.length === 1 ) {
Copy link
Contributor

Choose a reason for hiding this comment

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

What are we throwing for here? It would be great if we could convert marquees.length into a well-named variable so the test code is self-descriptive.

Copy link
Member

Choose a reason for hiding this comment

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

waitFor expects the underlying async callback to throw if there are no results so that it could automatically retry. But as I said above, might be easier to use page.waitForFunction here.

throw new Error();
Copy link
Contributor

Choose a reason for hiding this comment

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

Do we want to provide an error message to help future debugging efforts?

}
} );
Comment on lines +467 to +475
Copy link
Member

Choose a reason for hiding this comment

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

I guess we can use class names to select the inputs rather than using duplicate ids, since that we control the test plugin.

waitFor is meant for more advanced usage at the time, I think we can simply use page.waitForFunction here.


const marquees = await findAll( {
selector: '[id=marquee-greeting]',
} );

expect( marquees ).toHaveLength( 2 );
await marquees[ 1 ].click();
await page.keyboard.type( 'Second howdy' );

await saveWidgets();
editedSerializedWidgetAreas = await getSerializedWidgetAreas();
await expect( editedSerializedWidgetAreas ).toMatchInlineSnapshot( `
Object {
"sidebar-1": "<marquee>Hello!</marquee>",
Copy link
Member

Choose a reason for hiding this comment

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

I guess we need more comments here as it's really counter-intuitive 😅 .

Copy link
Contributor

Choose a reason for hiding this comment

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

We just need a note to say that we're asserting that the second instance of a function based widget is not persisted. We should also note why that's important with references to documentation or technical details.

}
` );

await page.reload();
const marqueesAfter = await findAll( {
selector: '[id=marquee-greeting]',
} );
expect( marqueesAfter ).toHaveLength( 1 );
Comment on lines +493 to +497
Copy link
Contributor

Choose a reason for hiding this comment

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

This bit is asserting on the rendered output. So double checking that the 2nd instance wasn't saved or rendered back to the editor on page reload.


await deactivatePlugin( 'gutenberg-test-marquee-widget' );
} );

// Disable reason: We temporary skip this test until we can figure out why it fails sometimes.
// eslint-disable-next-line jest/no-disabled-tests
it.skip( 'Should duplicate the widgets', async () => {
Expand Down Expand Up @@ -423,6 +528,7 @@ describe( 'Widgets screen', () => {
"sidebar-1": "<div class=\\"widget widget_block widget_text\\"><div class=\\"widget-content\\">
<p>First Paragraph</p>
</div></div>",
"wp_inactive_widgets": "",
}
` );
const initialWidgets = await getWidgetAreaWidgets();
Expand Down Expand Up @@ -493,6 +599,7 @@ describe( 'Widgets screen', () => {
<div class=\\"widget widget_block widget_text\\"><div class=\\"widget-content\\">
<p>First Paragraph</p>
</div></div>",
"wp_inactive_widgets": "",
}
` );
const editedWidgets = await getWidgetAreaWidgets();
Expand Down
9 changes: 6 additions & 3 deletions packages/edit-widgets/src/store/actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -148,13 +148,15 @@ export function* saveWidgetArea( widgetAreaId ) {
const widgetId = getWidgetIdFromBlock( block );
const oldWidget = widgets[ widgetId ];
const widget = transformBlockToWidget( block, oldWidget );

// We'll replace the null widgetId after save, but we track it here
// since order is important.
sidebarWidgetsIds.push( widgetId );

// We need to check for the id in the widget object here, because a deleted
// and restored widget won't have this id.
if ( widget.id ) {
// Check oldWidget as widgetId might refer to an ID which has been
// deleted, e.g. if a deleted block is restored via undo after saving.
if ( oldWidget ) {
// Update an existing widget.
yield dispatch(
'core',
'editEntityRecord',
Expand Down Expand Up @@ -184,6 +186,7 @@ export function* saveWidgetArea( widgetAreaId ) {
saveEditedEntityRecord( 'root', 'widget', widgetId )
);
} else {
// Create a new widget.
batchTasks.push( ( { saveEntityRecord } ) =>
saveEntityRecord( 'root', 'widget', {
...widget,
Expand Down
17 changes: 16 additions & 1 deletion packages/widgets/src/blocks/legacy-widget/edit/control.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,10 @@ export default class Control {
// a fake but unique number.
this.number = ++lastNumber;

this.handleFormChange = debounce( this.saveForm.bind( this ), 200 );
this.handleFormChange = debounce(
this.handleFormChange.bind( this ),
200
);
this.handleFormSubmit = this.handleFormSubmit.bind( this );

this.initDOM();
Expand Down Expand Up @@ -214,6 +217,18 @@ export default class Control {
}
}

/**
* Perform a save when a multi widget's form is changed. Non-multi widgets
Copy link
Contributor

Choose a reason for hiding this comment

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

Is "multi" and "non-multi" a recognised technical term? If not then we should probably add additional context here.

* are saved manually.
*
* @access private
*/
handleFormChange() {
if ( this.idBase ) {
this.saveForm();
}
}

/**
* Perform a save when the control's form is manually submitted.
*
Expand Down
3 changes: 2 additions & 1 deletion packages/widgets/src/blocks/legacy-widget/edit/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,8 @@ function NotEmpty( {
);
}

const mode = isNavigationMode || ! isSelected ? 'preview' : 'edit';
const mode =
Copy link
Contributor

Choose a reason for hiding this comment

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

This feels like it needs a comment with context to explain why idBase is a determining factor in selecting the "mode".

idBase && ( isNavigationMode || ! isSelected ) ? 'preview' : 'edit';

return (
<>
Expand Down