WordPress.org

Make WordPress Core

Opened 2 years ago

Closed 2 years ago

Last modified 2 years ago

#30936 closed enhancement (fixed)

Dynamically create WP_Customize_Settings for settings created on JS client

Reported by: westonruter Owned by: ocean90
Milestone: 4.2 Priority: normal
Severity: normal Version: 3.9
Component: Customize Keywords: has-patch
Focuses: Cc:

Description (last modified by westonruter)

When developing the Widget Customizer plugin, there was some hackery needed to support previewing the addition of new widgets. Normally when Widget Customizer boots up it creates a WP_Customize_Setting for each widget instance that exists in the DB.

Another problem is that widgets_init action also fires before customize_register, so it is too late to call $setting->preview() anyway to ensure that added widgets get registered.

To account for these issues, I implemented an ugly “pre-preview” system to intercept the incoming $_POST['customized'] JSON and basically duplicate the WP_Customize_Setting::preview() logic to make sure that the newly-added widgets were being supplied via filters when widgets_init happened, and then when customize_register happened it could remove those filters, and then add the WP_Customize_Setting objects properly.

This is all now in Core, and it is hacky.

I've been working on an alternate solution which would clean up Widget Customizer in core, and will be very helpful for Menu Customizer feature-as-plugin, in addition to other plugins (e.g. Customize Posts) that dynamically create settings on the client.

The work I've been doing is currently part of the introduction transactions for the Customizer, but the logic could be extracted to apply to the current system which relies on inspecting settings sent via $_POST[customized].

The work can be seen here: https://github.com/xwp/wordpress-develop/pull/61/files

The main piece is this new WP_Customize_Manager::add_dynamic_settings():

<?php
final class WP_Customize_Manager {

        /* ... */
        public function __construct()  {
                /* ... */
                add_action( 'customize_register',  array( $this, 'register_controls' ) );
                add_action( 'customize_register',  array( $this, 'register_dynamic_settings' ), 11 ); // allow code to create settings first
                /* ... */
        }

        /* ... */

        /**
         * Register any dynamically-created settings, such as those in a transaction that have no corresponding setting created.
         *
         * This is a mechanism to "wake up" settings that have been dynamically created
         * on the frontend and have been added to a transaction. When the transaction is
         * loaded, the dynamically-created settings then will get created and previewed
         * even though they are not directly created statically with code.
         *
         * @todo $customized should store more than just key/value, but also serialized settings. The update_transaction call should include the setting configs.
         *
         * @param array $customized mapping of settings IDs to values
         * @return WP_Customize_Setting[]
         */
        public function add_dynamic_settings( $customized ) {
                $new_settings = array();
                foreach ( $customized as $setting_id => $value ) {
                        if ( isset( $this->settings[ $setting_id ] ) || $this->get_setting( $setting_id ) ) {
                                continue;
                        }
                        $setting_class = 'WP_Customize_Setting';
                        $args = false;

                        /**
                         * Allow non-statically created settings to be constructed with custom WP_Customize_Setting subclass.
                         *
                         * @since 4.2.0
                         *
                         * @param string $class
                         * @param string $setting_id
                         */
                        $setting_class = apply_filters( 'customize_dynamic_setting_class', $setting_class, $setting_id );

                        /**
                         * Filter a dynamic setting's constructor args.
                         *
                         * This filter must return an array, overriding the false default, to be
                         *
                         * @since 4.2.0
                         *
                         * @param false|array $args
                         * @param string $setting_id
                         */
                        $setting_args = apply_filters( 'customize_dynamic_setting_args', $args, $setting_id );

                        if ( false === $setting_args ) {
                                continue;
                        }
                        $setting = new $setting_class( $this, $setting_id, $setting_args );
                        $this->add_setting( $setting );
                        $new_settings[] = $setting;
                }
                return $new_settings;
        }

        /* ... */

        /**
         * Add settings in the transaction that were not added with code, e.g. dynamically-created settings for Widgets
         *
         * @since 4.2.0
         */
        public function register_dynamic_settings() {
                // note here I'm using a transaction, but it could instead use json_decode( wp_unslash( $_POST['customized'] ) )
                $this->add_dynamic_settings( $this->transaction->data() );
        }
}

So then for how this is actually used, see WP_Customize_Widgets:

<?php
final class WP_Customize_Widgets {
        /* ... */

        /**
         * Mapping of setting type to setting ID pattern.
         *
         * @since 4.2.0
         * @access protected
         * @var array
         */
        protected $setting_id_patterns = array(
                'widget_instance' => '/^(widget_.+?)(?:\[(\d+)\])?$/',
                'sidebar_widgets' => '/^sidebars_widgets\[(.+?)\]$/',
        );

        /* ... */
        public function __construct( $manager ) {
                $this->manager = $manager;

                add_filter( 'customize_dynamic_setting_args', array( $this, 'filter_customize_dynamic_setting_args' ), 10, 2 );
                add_action( 'after_setup_theme', array( $this, 'register_settings' ) );
                /* ... */
        }

        /* ... */

        /**
         * Get the widget setting type given a setting ID.
         *
         * @since 4.2.0
         *
         * @param $setting_id
         *
         * @return string|null
         */
        protected function get_setting_type( $setting_id ) {
                static $cache = array();
                if ( isset( $cache[ $setting_id ] ) ) {
                        return $cache[ $setting_id ];
                }
                foreach ( $this->setting_id_patterns as $type => $pattern ) {
                        if ( preg_match( $pattern, $setting_id ) ) {
                                $cache[ $setting_id ] = $type;
                                return $type;
                        }
                }
                return null;
        }

        /**
         * Inspect the transaction for any widget settings, and dynamically add them up-front so widgets will be initialized properly.
         *
         * @since 4.2.0
         */
        public function register_settings() {
                $widget_customized = array();
                $all_customized = $this->manager->transaction->data(); // or this could get from json_decode( wp_unslash( $_POST['customized'] ) )
                foreach ( $all_customized as $setting_id => $value ) {
                        if ( $this->get_setting_type( $setting_id ) ) {
                                $widget_customized[ $setting_id ] = $value;
                        }
                }

                $settings = $this->manager->add_dynamic_settings( $widget_customized );

                /*
                 * Preview settings right away so that widgets and sidebars will get registered properly.
                 * But don't do this if a customize_save because this will cause WP to think there is nothing
                 * changed that needs to be saved.
                 */
                if ( ! $this->manager->doing_ajax( 'customize_save' ) ) {
                        foreach ( $settings as $setting ) {
                                $setting->preview();
                        }
                }
        }

        /* ... */

        /**
         * Determine the arguments for a dynamically-created setting. This is used
         * when updating the transaction.
         *
         * @since 4.2.0
         *
         * @param false|array $args
         * @param string $setting_id
         * @return false|array
         */
        public function filter_customize_dynamic_setting_args( $args, $setting_id ) {
                if ( $this->get_setting_type( $setting_id ) ) {
                        $args = $this->get_setting_args( $setting_id );
                }
                return $args;
        }
}

So you can see here that Widget Customizer is updated to register settings up front, eliminating the need for pre-preview. There is actually no need to defer settings to be created at customize_register. Additionally, the current customized data (aka the transaction) is looked at and any widget-specific settings are picked out and created as settings. This will ensure that added widgets will get recognized in time for widgets_Init since the filters would be applying.

So as long as dynamically-created settings have IDs that follow a certain pattern (e.g. scheduled_background_colors[2015-03-01] or term[421]), then new settings can be created for them on the JS client and they'll be automatically created in PHP when the customized data is received if a filters are added such as:

<?php
add_filter( 'customize_dynamic_setting_args', function ( $args, $setting_id ) {
        if ( preg_match( '/^scheduled_background_colors\[.+?\]$/', $setting_id ) ) {
                $args = array( 'type' => 'option', 'transport' => 'postMessage' );
        } else if ( preg_match( '/^term\[\d+\]$/', $setting_id ) ) {
                $args = array( 'type' => 'term',  );
        }
        return $args;
}, 10, 2 );

This would accompany any $wp_customize->add_setting() calls for existing data in the customize_register action.

This is distinct from #28580.

Attachments (7)

30936.diff (61.7 KB) - added by westonruter 2 years ago.
https://github.com/xwp/wordpress-develop/pull/67
30936.2.diff (35.5 KB) - added by westonruter 2 years ago.
Minimized patch after #30988 was merged
30936.3.diff (37.0 KB) - added by westonruter 2 years ago.
Restore removed public methods as no-op ones marked as deprecated: https://github.com/xwp/wordpress-develop/commit/53f4c0ae033294a91b928fb43c5ce385d24a4887
30936.4.diff (37.1 KB) - added by westonruter 2 years ago.
Additional change: https://github.com/xwp/wordpress-develop/commit/532ecc22808c6eab96c50a5ec0b0c2eccee9b645
30936.5.diff (38.4 KB) - added by ocean90 2 years ago.
30936.6.diff (41.6 KB) - added by westonruter 2 years ago.
Additional change: https://github.com/xwp/wordpress-develop/commit/c5f40f11d1d9d998f3335a4dcca1d71c8a126559 With code style improvements from ocean90: https://github.com/xwp/wordpress-develop/commit/ee71fbe1ab64b0dc4768b074538c92b71033da73
30936.7.diff (441 bytes) - added by ocean90 2 years ago.
Fix for https://travis-ci.org/aaronjorbin/develop.wordpress/builds/49994692

Download all attachments as: .zip

Change History (25)

#1 @westonruter
2 years ago

  • Description modified (diff)

This ticket was mentioned in Slack in #core-customize by westonruter. View the logs.


2 years ago

This ticket was mentioned in Slack in #core-customize by westonruter. View the logs.


2 years ago

#4 @westonruter
2 years ago

  • Milestone changed from Future Release to 4.2

Assigning this to 4.2 milestone because there is a patch in #30937, and because it will facilitate the Menu Customizer feature plugin, as well as clean up some mess that was needed to incorporate widgets into the Customizer in 3.9

This ticket was mentioned in Slack in #core-customize by westonruter. View the logs.


2 years ago

#6 @westonruter
2 years ago

  • Owner set to westonruter
  • Status changed from new to assigned

#7 @westonruter
2 years ago

Depends on #30988 (see comment:16)

#8 @westonruter
2 years ago

  • Owner changed from westonruter to oecan90
  • Status changed from assigned to reviewing

@ocean90: I've isolated the logic from the transactions patch and attached it here in 30936.diff. However, it needed to build on work done for #30988. So once you commit #30988, then I can refresh the patch to contain just the changes specific to this ticket.

#9 @westonruter
2 years ago

  • Keywords has-patch added

#10 @westonruter
2 years ago

  • Owner changed from oecan90 to ocean90

This ticket was mentioned in Slack in #core-customize by westonruter. View the logs.


2 years ago

This ticket was mentioned in Slack in #core-customize by westonruter. View the logs.


2 years ago

@westonruter
2 years ago

Minimized patch after #30988 was merged

This ticket was mentioned in Slack in #core by westonruter. View the logs.


2 years ago

@westonruter
2 years ago

Restore removed public methods as no-op ones marked as deprecated: https://github.com/xwp/wordpress-develop/commit/53f4c0ae033294a91b928fb43c5ce385d24a4887

This ticket was mentioned in Slack in #core-customize by ocean90. View the logs.


2 years ago

@ocean90
2 years ago

#15 @westonruter
2 years ago

In 30936.6.diff, I fixed the issue that ocean90 identified where the form for a newly-created widget gets reverted to its default state after user input.

The solution is to override the incoming $_POST['customized'] for a newly-created widget's setting with the new $instance so that the preview filter currently in place from WP_Customize_Setting::preview() will use this value instead of the default widget instance value (an empty array). This uses a newly-introduced method WP_Customize_Manager::set_post_value().

@ocean90: thoughts?

I also tried to preserve your changes which I applied here: https://github.com/xwp/wordpress-develop/commit/ee71fbe1ab64b0dc4768b074538c92b71033da73

#16 @ocean90
2 years ago

  • Resolution set to fixed
  • Status changed from reviewing to closed

In 31370:

Customizer: Introduce an API to create WP_Customize_Settings for dynamically-created settings.

  • Introduce WP_Customize_Manager::add_dynamic_settings() to register dynamically-created settings.
  • Introduce customize_dynamic_setting_args filter to pass an array of args to a dynamic setting's constructor.
  • Add unit tests for WP_Customize_Manager and WP_Customize_Widgets.
  • See WP_Customize_Widgets as an example.

props westonruter.
fixes #30936.

This ticket was mentioned in Slack in #core by westonruter. View the logs.


2 years ago

#18 @ocean90
2 years ago

In 31426:

Fix failing Tests_Dependencies_jQuery::test_wp_script_is_dep_enqueued test.

[31370] has broken the test because the Customizer test enqueues some scripts. Unset $GLOBALS['wp_scripts'] on tearDown() so other tests will start with zero enqueued scripts.

see #30936.
fixes #31302.

Note: See TracTickets for help on using tickets.