Skip to content

Commit

Permalink
Try Legacy widget block (#13511)
Browse files Browse the repository at this point in the history
## Description
Implements: #4770

This PR is a **proof of concept** of a legacy widget block. A block that allows existing WordPress widgets to be added as Gutenberg blocks.


The design is similar to the one proposed by @melchoyce in #4770 (comment) (option 1). Although it seems option two was preferred, it would require to expose each widget as a block similar to what embeds do, and it would increase the technical complexity and make testing/debugging harder, so I preferred to use option 1 for now. I will gladly iterate on the UX after this proof of concept gets more stable.


## Some technical details

The available widgets are preloaded to Gutenberg similar to what happens with page templates.

### REST-API user story
I want to able to pass a widget identifier to an endpoint, pass the existing widget attributes and the changes the user is making if any, and receive from the rest API, the sanitized new widget attributes ready to save and an HTML form that allows the user to edit the widget in the new state.

###  REST-API endpoint
A very simple REST-API endpoint was implemented.  The endpoint receives the previous instance of a widget (previous attributes) the changed instance of a widget (changed attributes if any) and returns the new instance of the widget and the HTML form that allows editing this widget.
There are two ajax-admin endpoints save-widget and update-widget. It looks like each one has specificities that make using it here complex. The ajax admin code would probably require some changes to be used here. Our use case is straightforward from the backend perspective as the widget does not need to be saved anywhere, and the widget is not associated with any widget area. The most straightforward approach seemed to be using a very simple endpoint. If I missed something and adapting existing endpoints is simpler feel free to comment, and I will have a look.


### Block Architecture

The edit component of the block handles the start placeholder that allows selecting a widget, and the tab mechanism that allows switching between edit and preview.
The preview is done using the ServerSideRender component.
The edit is done using two components:
**WidgetEditHandler:** Is responsible for server communication using the endpoint we created, and for keeping the required local state for the edition. Renders an update button, when pressed we retrieve from the dom the changed fields (using a method provided by WidgetEditDomManager) issues a request to the server and updates the legacy widget instance attribute with the server answer.
**WidgetEditDomManager:** Component responsible for rendering the starting dom for a widget. After the first render React never rerenders the component again, the content rendered by this component is then managed by the scripts the widgets may implement. When a new instance of the form HTML is received we manually update the dom changing .widget-content (like the customizer and the widget screen) do. This component provides a method that returns an object with the widget changed attributes. When this component is mounted it triggers a widget-added event when a new update happens and the dom is changed by the component widget-update jQuery event is triggered.

On front end widget are rendered with a simple call to [the_widget](https://codex.wordpress.org/Function_Reference/the_widget).

 

## Screenshots <!-- if applicable -->

![jan-25-2019 19-37-44](https://user-images.githubusercontent.com/11271197/51768637-c3b5b100-20d8-11e9-941f-00adb4c7b0a1.gif)
![jan-25-2019 18-58-49](https://user-images.githubusercontent.com/11271197/51768645-cb755580-20d8-11e9-89e7-1aa9ba7256c3.gif)

## Known problems
- The block is not aware of any change inside the widget until the update button is pressed. This replicates the save button on the widgets screen. But it is annoying if we change something on the widget and go to the widget preview right away our changes are not reflected in the preview. Having an explicit update, makes testing and debugging easier, we may than explore other approaches e.g.: also save when preview happens, save on blur events, etc.

- The text widget that contains TinyMCE crashes and fails to init. It calls wp.editor.initialize to reference TinyMCE and on Gutenberg, wp.editor is our editor module. This problem may have happened with meta boxes if it was solved probably the same approach may be applied.

- The widget design may be affected by CSS that exists in Gutenberg, so the design of the widgets does not look the same. Ideally, Gutenberg CSS would not affect the widgets but as they are on the same page that's not the case.

- Some third-party widgets using don't initialize correctly. That happens because the dom of the editor is not equal to the dom of the customizer and/or the widget screen. Some JS widgets use click events on the widgets screen to initialize while on Gutenberg these events don't happen, some check if they are on the customizer page (body contains customizer classes) before handling widget-updated events. Normally adapting a widget that does not initialize correctly is a matter of changing a very simple condition on the plugin.
  • Loading branch information
jorgefilipecosta authored and youknowriad committed Mar 20, 2019
1 parent 8a571b2 commit 493c779
Show file tree
Hide file tree
Showing 21 changed files with 986 additions and 47 deletions.
26 changes: 26 additions & 0 deletions gutenberg.php
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,21 @@ function the_gutenberg_project() {
<div id="metaboxes" class="hidden">
<?php the_block_editor_meta_boxes(); ?>
</div>
<?php
/**
* Start: Include for phase 2
*/
/** This action is documented in wp-admin/admin-footer.php */
// phpcs:ignore WordPress.NamingConventions.ValidHookName.UseUnderscores
do_action( 'admin_print_footer_scripts-widgets.php' );

/** This action is documented in wp-admin/admin-footer.php */
// phpcs:ignore WordPress.NamingConventions.ValidHookName.UseUnderscores
do_action( 'admin_footer-widgets.php' );
/**
* End: Include for phase 2
*/
?>
</div>
<?php
}
Expand Down Expand Up @@ -223,6 +238,17 @@ function gutenberg_init( $return, $post ) {
*/
remove_action( 'admin_print_scripts', 'print_emoji_detection_script' );

/**
* Start: Include for phase 2
*/
// phpcs:ignore WordPress.NamingConventions.ValidHookName.UseUnderscores
do_action( 'admin_print_styles-widgets.php' );
// phpcs:ignore WordPress.NamingConventions.ValidHookName.UseUnderscores
do_action( 'admin_print_scripts-widgets.php' );
/**
* End: Include for phase 2
*/

/*
* Ensure meta box functions are available to third-party code;
* includes/meta-boxes is typically loaded from edit-form-advanced.php.
Expand Down
186 changes: 186 additions & 0 deletions lib/class-wp-rest-widget-updater-controller.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
<?php
/**
* Start: Include for phase 2
* Widget Updater REST API: WP_REST_Widget_Updater_Controller class
*
* @package gutenberg
* @since 5.2.0
*/

/**
* Controller which provides REST endpoint for updating a widget.
*
* @since 5.2.0
*
* @see WP_REST_Controller
*/
class WP_REST_Widget_Updater_Controller extends WP_REST_Controller {

/**
* Constructs the controller.
*
* @access public
*/
public function __construct() {
$this->namespace = 'wp/v2';
$this->rest_base = 'widgets';
}

/**
* Registers the necessary REST API route.
*
* @access public
*/
public function register_routes() {
register_rest_route(
$this->namespace,
// Regex representing a PHP class extracted from http://php.net/manual/en/language.oop5.basic.php.
'/' . $this->rest_base . '/(?P<identifier>[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*)/',
array(
'args' => array(
'identifier' => array(
'description' => __( 'Class name of the widget.', 'gutenberg' ),
'type' => 'string',
),
),
array(
'methods' => WP_REST_Server::EDITABLE,
'permission_callback' => array( $this, 'compute_new_widget_permissions_check' ),
'callback' => array( $this, 'compute_new_widget' ),
),
)
);
}

/**
* Checks if the user has permissions to make the request.
*
* @since 5.2.0
* @access public
*
* @return true|WP_Error True if the request has read access, WP_Error object otherwise.
*/
public function compute_new_widget_permissions_check() {
// Verify if the current user has edit_theme_options capability.
// This capability is required to access the widgets screen.
if ( ! current_user_can( 'edit_theme_options' ) ) {
return new WP_Error(
'widgets_cannot_access',
__( 'Sorry, you are not allowed to access widgets on this site.', 'gutenberg' ),
array(
'status' => rest_authorization_required_code(),
)
);
}
return true;
}

/**
* Returns the new widget instance and the form that represents it.
*
* @since 5.2.0
* @access public
*
* @param WP_REST_Request $request Full details about the request.
* @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
*/
public function compute_new_widget( $request ) {
$url_params = $request->get_url_params();

$widget = $request->get_param( 'identifier' );

global $wp_widget_factory;

if (
null === $widget ||
! isset( $wp_widget_factory->widgets[ $widget ] ) ||
! ( $wp_widget_factory->widgets[ $widget ] instanceof WP_Widget )
) {
return new WP_Error(
'widget_invalid',
__( 'Invalid widget.', 'gutenberg' ),
array(
'status' => 404,
)
);
}

$widget_obj = $wp_widget_factory->widgets[ $widget ];

$instance = $request->get_param( 'instance' );
if ( null === $instance ) {
$instance = array();
}
$id_to_use = $request->get_param( 'id_to_use' );
if ( null === $id_to_use ) {
$id_to_use = -1;
}

$widget_obj->_set( $id_to_use );
ob_start();

$instance_changes = $request->get_param( 'instance_changes' );
if ( null !== $instance_changes ) {
$old_instance = $instance;
$instance = $widget_obj->update( $instance_changes, $old_instance );
/**
* Filters a widget's settings before saving.
*
* Returning false will effectively short-circuit the widget's ability
* to update settings. The old setting will be returned.
*
* @since 5.2.0
*
* @param array $instance The current widget instance's settings.
* @param array $instance_changes Array of new widget settings.
* @param array $old_instance Array of old widget settings.
* @param WP_Widget $widget_ob The widget instance.
*/
$instance = apply_filters( 'widget_update_callback', $instance, $instance_changes, $old_instance, $widget_obj );
if ( false === $instance ) {
$instance = $old_instance;
}
}

$instance = apply_filters( 'widget_form_callback', $instance, $widget_obj );

$return = null;
if ( false !== $instance ) {
$return = $widget_obj->form( $instance );

/**
* Fires at the end of the widget control form.
*
* Use this hook to add extra fields to the widget form. The hook
* is only fired if the value passed to the 'widget_form_callback'
* hook is not false.
*
* Note: If the widget has no form, the text echoed from the default
* form method can be hidden using CSS.
*
* @since 5.2.0
*
* @param WP_Widget $widget_obj The widget instance (passed by reference).
* @param null $return Return null if new fields are added.
* @param array $instance An array of the widget's settings.
*/
do_action_ref_array( 'in_widget_form', array( &$widget_obj, &$return, $instance ) );
}

$id_base = $widget_obj->id_base;
$id = $widget_obj->id;
$form = ob_get_clean();

return rest_ensure_response(
array(
'instance' => $instance,
'form' => $form,
'id_base' => $id_base,
'id' => $id,
)
);
}
}
/**
* End: Include for phase 2
*/
85 changes: 67 additions & 18 deletions lib/client-assets.php
Original file line number Diff line number Diff line change
Expand Up @@ -880,34 +880,83 @@ function gutenberg_editor_scripts_and_styles( $hook ) {
);
}

/**
* Start: Include for phase 2
*/

/**
* Todo: The hardcoded array should be replaced with a mechanisms that allows core blocks
* and third party blocks to specify they already have equivalent blocks, and maybe even allow them
* to have a migration function.
*/
$core_widgets = array( 'WP_Widget_Pages', 'WP_Widget_Calendar', 'WP_Widget_Archives', 'WP_Widget_Media_Audio', 'WP_Widget_Media_Image', 'WP_Widget_Media_Gallery', 'WP_Widget_Media_Video', 'WP_Widget_Meta', 'WP_Widget_Search', 'WP_Widget_Text', 'WP_Widget_Categories', 'WP_Widget_Recent_Posts', 'WP_Widget_Recent_Comments', 'WP_Widget_RSS', 'WP_Widget_Tag_Cloud', 'WP_Nav_Menu_Widget', 'WP_Widget_Custom_HTML' );

$has_permissions_to_manage_widgets = current_user_can( 'edit_theme_options' );
$available_legacy_widgets = array();
global $wp_widget_factory, $wp_registered_widgets;
foreach ( $wp_widget_factory->widgets as $class => $widget_obj ) {
if ( ! in_array( $class, $core_widgets ) ) {
$available_legacy_widgets[ $class ] = array(
'name' => html_entity_decode( $widget_obj->name ),
'description' => html_entity_decode( $widget_obj->widget_options['description'] ),
'isCallbackWidget' => false,
);
}
}
foreach ( $wp_registered_widgets as $widget_id => $widget_obj ) {
if (
is_array( $widget_obj['callback'] ) &&
isset( $widget_obj['callback'][0] ) &&
( $widget_obj['callback'][0] instanceof WP_Widget )
) {
continue;
}
$available_legacy_widgets[ $widget_id ] = array(
'name' => html_entity_decode( $widget_obj['name'] ),
'description' => null,
'isCallbackWidget' => true,
);
}
/**
* End: Include for phase 2
*/

$editor_settings = array(
'alignWide' => $align_wide,
'availableTemplates' => $available_templates,
'allowedBlockTypes' => $allowed_block_types,
'disableCustomColors' => get_theme_support( 'disable-custom-colors' ),
'disableCustomFontSizes' => get_theme_support( 'disable-custom-font-sizes' ),
'disablePostFormats' => ! current_theme_supports( 'post-formats' ),
'titlePlaceholder' => apply_filters( 'enter_title_here', __( 'Add title', 'gutenberg' ), $post ),
'bodyPlaceholder' => apply_filters( 'write_your_story', __( 'Start writing or type / to choose a block', 'gutenberg' ), $post ),
'isRTL' => is_rtl(),
'autosaveInterval' => 10,
'maxUploadFileSize' => $max_upload_size,
'allowedMimeTypes' => get_allowed_mime_types(),
'styles' => $styles,
'imageSizes' => gutenberg_get_available_image_sizes(),
'richEditingEnabled' => user_can_richedit(),
'alignWide' => $align_wide,
'availableTemplates' => $available_templates,
/**
* Start: Include for phase 2
*/
'hasPermissionsToManageWidgets' => $has_permissions_to_manage_widgets,
'availableLegacyWidgets' => $available_legacy_widgets,
/**
* End: Include for phase 2
*/
'allowedBlockTypes' => $allowed_block_types,
'disableCustomColors' => get_theme_support( 'disable-custom-colors' ),
'disableCustomFontSizes' => get_theme_support( 'disable-custom-font-sizes' ),
'disablePostFormats' => ! current_theme_supports( 'post-formats' ),
'titlePlaceholder' => apply_filters( 'enter_title_here', __( 'Add title', 'gutenberg' ), $post ),
'bodyPlaceholder' => apply_filters( 'write_your_story', __( 'Start writing or type / to choose a block', 'gutenberg' ), $post ),
'isRTL' => is_rtl(),
'autosaveInterval' => 10,
'maxUploadFileSize' => $max_upload_size,
'allowedMimeTypes' => get_allowed_mime_types(),
'styles' => $styles,
'imageSizes' => gutenberg_get_available_image_sizes(),
'richEditingEnabled' => user_can_richedit(),

// Ideally, we'd remove this and rely on a REST API endpoint.
'postLock' => $lock_details,
'postLockUtils' => array(
'postLock' => $lock_details,
'postLockUtils' => array(
'nonce' => wp_create_nonce( 'lock-post_' . $post->ID ),
'unlockNonce' => wp_create_nonce( 'update-post_' . $post->ID ),
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
),

// Whether or not to load the 'postcustom' meta box is stored as a user meta
// field so that we're not always loading its assets.
'enableCustomFields' => (bool) get_user_meta( get_current_user_id(), 'enable_custom_fields', true ),
'enableCustomFields' => (bool) get_user_meta( get_current_user_id(), 'enable_custom_fields', true ),
);

$post_autosave = gutenberg_get_autosave_newer_than_post_save( $post );
Expand Down
21 changes: 21 additions & 0 deletions lib/load.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,15 @@
// These files only need to be loaded if within a rest server instance
// which this class will exist if that is the case.
if ( class_exists( 'WP_REST_Controller' ) ) {
/**
* Start: Include for phase 2
*/
if ( ! class_exists( 'WP_REST_Widget_Updater_Controller' ) ) {
require dirname( __FILE__ ) . '/class-wp-rest-widget-updater-controller.php';
}
/**
* End: Include for phase 2
*/
require dirname( __FILE__ ) . '/rest-api.php';
}

Expand Down Expand Up @@ -43,6 +52,18 @@
if ( ! function_exists( 'render_block_core_latest_posts' ) ) {
require dirname( __FILE__ ) . '/../packages/block-library/src/latest-posts/index.php';
}


/**
* Start: Include for phase 2
*/
if ( ! function_exists( 'render_block_legacy_widget' ) ) {
require dirname( __FILE__ ) . '/../packages/block-library/src/legacy-widget/index.php';
}
/**
* End: Include for phase 2
*/

if ( ! function_exists( 'render_block_core_rss' ) ) {
require dirname( __FILE__ ) . '/../packages/block-library/src/rss/index.php';
}
Expand Down
19 changes: 19 additions & 0 deletions lib/rest-api.php
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,22 @@ function gutenberg_filter_oembed_result( $response, $handler, $request ) {
);
}
add_filter( 'rest_request_after_callbacks', 'gutenberg_filter_oembed_result', 10, 3 );



/**
* Start: Include for phase 2
*/
/**
* Registers the REST API routes needed by the legacy widget block.
*
* @since 5.0.0
*/
function gutenberg_register_rest_widget_updater_routes() {
$widgets_controller = new WP_REST_Widget_Updater_Controller();
$widgets_controller->register_routes();
}
add_action( 'rest_api_init', 'gutenberg_register_rest_widget_updater_routes' );
/**
* End: Include for phase 2
*/
Loading

0 comments on commit 493c779

Please sign in to comment.