From 52fc98f33cc75ca23edff12e1aa8b255c55192af Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Fri, 24 Apr 2026 16:18:21 +0800 Subject: [PATCH 01/10] Block Editor: Settings Sidebar: Add Refresh Resource Buttons --- includes/class-convertkit-gutenberg.php | 8 +- ...onvertkit-plugin-sidebar-post-settings.php | 12 +- resources/backend/js/gutenberg.js | 324 +++++++++++++----- 3 files changed, 255 insertions(+), 89 deletions(-) diff --git a/includes/class-convertkit-gutenberg.php b/includes/class-convertkit-gutenberg.php index 2ee100eeb..9088937a7 100644 --- a/includes/class-convertkit-gutenberg.php +++ b/includes/class-convertkit-gutenberg.php @@ -323,9 +323,11 @@ public function enqueue_scripts() { 'convertkit-gutenberg', 'convertkit_gutenberg', array( - 'ajaxurl' => rest_url( 'kit/v1/blocks' ), - 'block_api_version' => $this->get_block_api_version(), - 'get_blocks_nonce' => wp_create_nonce( 'wp_rest' ), + 'ajaxurl' => rest_url( 'kit/v1/blocks' ), + 'block_api_version' => $this->get_block_api_version(), + 'get_blocks_nonce' => wp_create_nonce( 'wp_rest' ), + 'refresh_resources_url' => rest_url( 'kit/v1/resources/refresh/' ), + 'refresh_resources_nonce' => wp_create_nonce( 'wp_rest' ), ) ); diff --git a/includes/plugin-sidebars/class-convertkit-plugin-sidebar-post-settings.php b/includes/plugin-sidebars/class-convertkit-plugin-sidebar-post-settings.php index 5f319715b..c10318a05 100644 --- a/includes/plugin-sidebars/class-convertkit-plugin-sidebar-post-settings.php +++ b/includes/plugin-sidebars/class-convertkit-plugin-sidebar-post-settings.php @@ -195,32 +195,36 @@ public function get_fields() { return array( 'form' => array( 'label' => __( 'Form', 'convertkit' ), - 'type' => 'select', + 'type' => 'resource', 'description' => array( __( 'Default', 'convertkit' ) . ': ' . __( 'Uses the form specified on the settings page.', 'convertkit' ), __( 'None', 'convertkit' ) . ': ' . __( 'do not display a form.', 'convertkit' ), __( 'Any other option will display that form after the main content.', 'convertkit' ), ), 'values' => $forms, + 'resource' => 'forms', ), 'landing_page' => array( 'label' => __( 'Landing Page', 'convertkit' ), - 'type' => 'select', + 'type' => 'resource', 'description' => __( 'Select a landing page to make it appear in place of this page.', 'convertkit' ), 'values' => $landing_pages, 'post_type' => 'page', + 'resource' => 'landing_pages', ), 'tag' => array( 'label' => __( 'Tag', 'convertkit' ), - 'type' => 'select', + 'type' => 'resource', 'description' => __( 'Select a tag to apply to visitors of this page who are subscribed. A visitor is deemed to be subscribed if they have clicked a link in an email to this site which includes their subscriber ID, or have entered their email address in a Kit Form on this site.', 'convertkit' ), 'values' => $tags, + 'resource' => 'tags', ), 'restrict_content' => array( 'label' => __( 'Restrict Content', 'convertkit' ), - 'type' => 'select', + 'type' => 'resource', 'description' => __( 'Select the Kit form, tag or product that the visitor must be subscribed to, permitting them access to view this member-only content.', 'convertkit' ), 'values' => $restrict_content, + 'resource' => 'restrict_content', ), ); diff --git a/resources/backend/js/gutenberg.js b/resources/backend/js/gutenberg.js index 3b1047548..4d5bc03cf 100644 --- a/resources/backend/js/gutenberg.js +++ b/resources/backend/js/gutenberg.js @@ -935,7 +935,17 @@ function convertKitGutenbergRegisterPluginSidebar(sidebar) { const el = element.createElement; const { registerPlugin } = plugins; const { PluginSidebar } = editor; - const { TextControl, SelectControl, PanelBody, PanelRow } = components; + const { useState } = element; + const { + Icon, + TextControl, + SelectControl, + Flex, + FlexItem, + PanelBody, + PanelRow, + Button, + } = components; const { useSelect, useDispatch, select } = data; /** @@ -1006,98 +1016,248 @@ function convertKitGutenbergRegisterPluginSidebar(sidebar) { // Define additional Field Properties and the Field Element, // depending on the Field Type (select, textarea, text etc). switch (field.type) { - case 'select': - // Check if any values are optgroups. - const hasOptgroups = Object.keys(field.values).some( - (subKey) => - typeof field.values[subKey] === 'object' && - field.values[subKey].label && - field.values[subKey].values + case 'resource': + return el( + Flex, + { + align: 'start', + }, + [ + el( + FlexItem, + { + key: key + '-select', + }, + getSelectField(field, fieldProperties) + ), + el( + FlexItem, + { + key: key + '-refresh', + }, + InlineRefreshButton(field.resource) + ), + ] ); - if (hasOptgroups) { - const children = []; - - for (const value of Object.keys(field.values)) { - if ( - typeof field.values[value] === 'object' && - field.values[value].label && - field.values[value].values - ) { - // Optgroup. - const groupChildren = []; - for (const groupValue of Object.keys( - field.values[value].values - )) { - groupChildren.push( - el( - 'option', - { - value: groupValue, - key: groupValue, - }, - field.values[value].values[ - groupValue - ] - ) - ); - } - children.push( - el( - 'optgroup', - { - label: field.values[value] - .label, - key: value, - }, - ...groupChildren - ) - ); - } else { - // Option within optgroup. - children.push( - el( - 'option', - { value, key: value }, - field.values[value] - ) - ); - } - } + case 'select': + return getSelectField(field, fieldProperties); + + default: + // Return field element. + return el(TextControl, fieldProperties); + } + }; + + /** + * Returns a select field element, with optgroups and options + * depending on the field's values. + * + * @since 3.3.1 + * + * @param {Object} field Field properties. + * @param {Object} fieldProperties Field properties. + * @return {Object} Select field element. + */ + const getSelectField = function (field, fieldProperties) { + // Check if any values are optgroups. + const hasOptgroups = Object.keys(field.values).some( + (subKey) => + typeof field.values[subKey] === 'object' && + field.values[subKey].label && + field.values[subKey].values + ); + + if (hasOptgroups) { + const children = []; - return el( - SelectControl, - fieldProperties, - ...children + for (const value of Object.keys(field.values)) { + if ( + typeof field.values[value] === 'object' && + field.values[value].label && + field.values[value].values + ) { + // Optgroup. + const groupChildren = []; + for (const groupValue of Object.keys( + field.values[value].values + )) { + groupChildren.push( + el( + 'option', + { + value: groupValue, + key: groupValue, + }, + field.values[value].values[groupValue] + ) + ); + } + children.push( + el( + 'optgroup', + { + label: field.values[value].label, + key: value, + }, + ...groupChildren + ) + ); + } else { + // Option within optgroup. + children.push( + el( + 'option', + { value, key: value }, + field.values[value] + ) ); } + } - // Options only, no optgroups. - const fieldOptions = []; - for (const value of Object.keys(field.values)) { - fieldOptions.push({ - label: field.values[value], - value, - }); + return el(SelectControl, fieldProperties, ...children); + } + + // Options only, no optgroups. + const fieldOptions = []; + for (const value of Object.keys(field.values)) { + fieldOptions.push({ + label: field.values[value], + value, + }); + } + + // Sort options alphabetically by label. + fieldOptions.sort(function (x, y) { + const a = x.label.toUpperCase(), + b = y.label.toUpperCase(); + return a.localeCompare(b); + }); + + // Assign options to field properties. + fieldProperties.options = fieldOptions; + + // Return field element. + return el(SelectControl, fieldProperties); + }; + + /** + * Returns a WordPress Icon element. + * + * @since 3.3.1 + * + * @param {string} iconName Icon Name. + * @return {Object} Icon. + */ + const iconType = function (iconName) { + return el(Icon, { + icon: iconName, + }); + }; + + /** + * Returns an inline refresh button, used to refresh a block's resources. + * + * @since 3.3.1 + * + * @param {string} resource Resource type (forms,tags,landing_pages,restrict_content). + * @return {Object} Button. + */ + const InlineRefreshButton = function (resource) { + const [buttonDisabled, setButtonDisabled] = useState(false); + + return el(Button, { + key: resource + '-refresh-button', + className: + 'button button-secondary wp-convertkit-refresh-resources' + + (buttonDisabled ? ' is-refreshing' : ''), + disabled: buttonDisabled, + icon: iconType('update'), + onClick() { + // Refresh resources. + refreshResources(resource, setButtonDisabled); + }, + }); + }; + + /** + * Refreshes resources for the given resource type. + * + * @since 3.3.1 + * + * @param {string} resource Resource type (forms,tags,landing_pages,restrict_content). + * @param {Function} setButtonDisabled Function to enable or disable the refresh button. + */ + const refreshResources = function (resource, setButtonDisabled) { + // Disable the button. + setButtonDisabled(true); + + // Send AJAX request. + fetch(convertkit_gutenberg.refresh_resources_url + resource, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-WP-Nonce': + convertkit_gutenberg.refresh_resources_nonce, + }, + }) + .then(function (response) { + // Convert response JSON string to object. + return response.json(); + }) + .then(function (response) { + if (convertkit_gutenberg.debug) { + console.log(response); } - // Sort options alphabetically by label. - fieldOptions.sort(function (x, y) { - const a = x.label.toUpperCase(), - b = y.label.toUpperCase(); - return a.localeCompare(b); - }); + // If the response includes a code, show an error notice. + if (typeof response.code !== 'undefined') { + // Show an error in the Gutenberg editor. + wp.data + .dispatch('core/notices') + .createErrorNotice('Kit: ' + response.message, { + id: 'convertkit-error', + }); - // Assign options to field properties. - fieldProperties.options = fieldOptions; + // Enable refresh button. + setButtonDisabled(false); + return; + } - // Return field element. - return el(SelectControl, fieldProperties); + // @TODO Update something here - convertkit_plugin_sidebars? - default: - // Return field element. - return el(TextControl, fieldProperties); - } + // @TODO Refresh/redraw the field? + // The below code is how we do it for a block, but that doesn't apply to a block editor sidebar. + /* + // Update global ConvertKit Blocks object, so that any updated resources + // are reflected when adding new ConvertKit Blocks. + convertkit_blocks = response; + + // Update this block's properties, so that has_access_token, has_resources + // and the resources properties are updated. + block = convertkit_blocks[block.name]; + + // Call setAttributes on props to trigger the editBlock() function, which will re-render + // the block, reflecting any changes to its properties. + props.setAttributes({ + refresh: Date.now(), + }); + */ + + // Enable refresh button. + setButtonDisabled(false); + }) + .catch(function (error) { + // Show an error in the Gutenberg editor. + wp.data + .dispatch('core/notices') + .createErrorNotice('Kit: ' + error, { + id: 'convertkit-error', + }); + + // Enable refresh button. + setButtonDisabled(false); + }); }; /** From 8701caabff1c8d03ab9f51bf649553482c753ea3 Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Fri, 24 Apr 2026 16:41:38 +0800 Subject: [PATCH 02/10] Repopulate fields on refresh --- ...onvertkit-plugin-sidebar-post-settings.php | 42 +-- resources/backend/js/gutenberg.js | 253 ++++++++++++++---- 2 files changed, 223 insertions(+), 72 deletions(-) diff --git a/includes/plugin-sidebars/class-convertkit-plugin-sidebar-post-settings.php b/includes/plugin-sidebars/class-convertkit-plugin-sidebar-post-settings.php index c10318a05..9b2a882fe 100644 --- a/includes/plugin-sidebars/class-convertkit-plugin-sidebar-post-settings.php +++ b/includes/plugin-sidebars/class-convertkit-plugin-sidebar-post-settings.php @@ -194,37 +194,37 @@ public function get_fields() { return array( 'form' => array( - 'label' => __( 'Form', 'convertkit' ), - 'type' => 'resource', - 'description' => array( + 'label' => __( 'Form', 'convertkit' ), + 'type' => 'select', + 'description' => array( __( 'Default', 'convertkit' ) . ': ' . __( 'Uses the form specified on the settings page.', 'convertkit' ), __( 'None', 'convertkit' ) . ': ' . __( 'do not display a form.', 'convertkit' ), __( 'Any other option will display that form after the main content.', 'convertkit' ), ), - 'values' => $forms, - 'resource' => 'forms', + 'values' => $forms, + 'resource_type' => 'forms', ), 'landing_page' => array( - 'label' => __( 'Landing Page', 'convertkit' ), - 'type' => 'resource', - 'description' => __( 'Select a landing page to make it appear in place of this page.', 'convertkit' ), - 'values' => $landing_pages, - 'post_type' => 'page', - 'resource' => 'landing_pages', + 'label' => __( 'Landing Page', 'convertkit' ), + 'type' => 'select', + 'description' => __( 'Select a landing page to make it appear in place of this page.', 'convertkit' ), + 'values' => $landing_pages, + 'post_type' => 'page', + 'resource_type' => 'landing_pages', ), 'tag' => array( - 'label' => __( 'Tag', 'convertkit' ), - 'type' => 'resource', - 'description' => __( 'Select a tag to apply to visitors of this page who are subscribed. A visitor is deemed to be subscribed if they have clicked a link in an email to this site which includes their subscriber ID, or have entered their email address in a Kit Form on this site.', 'convertkit' ), - 'values' => $tags, - 'resource' => 'tags', + 'label' => __( 'Tag', 'convertkit' ), + 'type' => 'select', + 'description' => __( 'Select a tag to apply to visitors of this page who are subscribed. A visitor is deemed to be subscribed if they have clicked a link in an email to this site which includes their subscriber ID, or have entered their email address in a Kit Form on this site.', 'convertkit' ), + 'values' => $tags, + 'resource_type' => 'tags', ), 'restrict_content' => array( - 'label' => __( 'Restrict Content', 'convertkit' ), - 'type' => 'resource', - 'description' => __( 'Select the Kit form, tag or product that the visitor must be subscribed to, permitting them access to view this member-only content.', 'convertkit' ), - 'values' => $restrict_content, - 'resource' => 'restrict_content', + 'label' => __( 'Restrict Content', 'convertkit' ), + 'type' => 'select', + 'description' => __( 'Select the Kit form, tag or product that the visitor must be subscribed to, permitting them access to view this member-only content.', 'convertkit' ), + 'values' => $restrict_content, + 'resource_type' => 'restrict_content', ), ); diff --git a/resources/backend/js/gutenberg.js b/resources/backend/js/gutenberg.js index 4d5bc03cf..a7b170edb 100644 --- a/resources/backend/js/gutenberg.js +++ b/resources/backend/js/gutenberg.js @@ -964,6 +964,18 @@ function convertKitGutenbergRegisterPluginSidebar(sidebar) { const settings = meta[sidebar.meta_key] || sidebar.default_values; const currentPostType = select('core/editor').getCurrentPostType(); + // Seed each field's `values` into component state, so that when a + // refresh button is clicked we can update the field's options + // and trigger a re-render without mutating the global + // convertkit_plugin_sidebars object. + const [fieldValues, setFieldValues] = useState(function () { + const initial = {}; + for (const key in sidebar.fields) { + initial[key] = sidebar.fields[key].values; + } + return initial; + }); + /** * Updates the Post meta meta_key object. * @@ -992,6 +1004,12 @@ function convertKitGutenbergRegisterPluginSidebar(sidebar) { * @return {Object} Field element. */ const getField = function (field, key) { + // Override field.values with the latest values from state, which + // may have been refreshed by clicking the refresh button. + field = Object.assign({}, field, { + values: fieldValues[key] || field.values, + }); + // Define some field properties shared across all field types. const fieldProperties = { key: 'convertkit_plugin_sidebar_' + key, @@ -1016,31 +1034,37 @@ function convertKitGutenbergRegisterPluginSidebar(sidebar) { // Define additional Field Properties and the Field Element, // depending on the Field Type (select, textarea, text etc). switch (field.type) { - case 'resource': - return el( - Flex, - { - align: 'start', - }, - [ - el( - FlexItem, - { - key: key + '-select', - }, - getSelectField(field, fieldProperties) - ), - el( - FlexItem, - { - key: key + '-refresh', - }, - InlineRefreshButton(field.resource) - ), - ] - ); - case 'select': + // If the field has a resource_type, wrap the select in a + // Flex container alongside a refresh button. + if (field.resource_type) { + return el( + Flex, + { + align: 'start', + }, + [ + el( + FlexItem, + { + key: key + '-select', + }, + getSelectField(field, fieldProperties) + ), + el( + FlexItem, + { + key: key + '-refresh', + }, + el(InlineRefreshButton, { + resource: field.resource_type, + fieldKey: key, + }) + ), + ] + ); + } + return getSelectField(field, fieldProperties); default: @@ -1156,18 +1180,20 @@ function convertKitGutenbergRegisterPluginSidebar(sidebar) { }; /** - * Returns an inline refresh button, used to refresh a block's resources. + * Returns an inline refresh button, used to refresh a sidebar field's resources. * * @since 3.3.1 * - * @param {string} resource Resource type (forms,tags,landing_pages,restrict_content). - * @return {Object} Button. + * @param {Object} props Component props. + * @param {string} props.resource Resource type (forms,tags,landing_pages,restrict_content). + * @param {string} props.fieldKey The sidebar field key whose values should be updated on refresh. + * @return {Object} Button. */ - const InlineRefreshButton = function (resource) { + const InlineRefreshButton = function ({ resource, fieldKey }) { const [buttonDisabled, setButtonDisabled] = useState(false); return el(Button, { - key: resource + '-refresh-button', + key: fieldKey + '-refresh-button', className: 'button button-secondary wp-convertkit-refresh-resources' + (buttonDisabled ? ' is-refreshing' : ''), @@ -1175,20 +1201,145 @@ function convertKitGutenbergRegisterPluginSidebar(sidebar) { icon: iconType('update'), onClick() { // Refresh resources. - refreshResources(resource, setButtonDisabled); + refreshResources(resource, fieldKey, setButtonDisabled); }, }); }; /** - * Refreshes resources for the given resource type. + * Returns a label for a resource item. If the item has a `format` + * property (as forms do), append it in square brackets; otherwise + * return the name on its own. + * + * @since 3.3.1 + * + * @param {Object} item API response item. + * @return {string} Label. + */ + const labelForItem = function (item) { + // Detect whether this collection of items includes a `format` + // property anywhere in the item (legacy forms may omit it, in + // which case we fall back to 'inline'). + if (Object.prototype.hasOwnProperty.call(item, 'format')) { + return ( + item.name + + ' [' + + (item.format ? item.format : 'inline') + + ']' + ); + } + + return item.name; + }; + + /** + * Builds a flat values map from an array of API items, preserving + * any existing placeholder options (those whose value is a string, + * rather than an optgroup object) from the current field values. + * + * @since 3.3.1 + * + * @param {Array} items API response items. + * @param {Object} existingValues Current values map for the field. + * @return {Object} Rebuilt values map. + */ + const buildFlatValues = function (items, existingValues) { + const values = {}; + + // Preserve existing placeholder options (Default, None, etc.) + // from the current values. Placeholders are identified by + // having a string value rather than an optgroup object. + for (const existingKey in existingValues) { + if (typeof existingValues[existingKey] === 'string') { + values[existingKey] = existingValues[existingKey]; + } + } + + // Add the refreshed items. + items.forEach(function (item) { + values[item.id] = labelForItem(item); + }); + + return values; + }; + + /** + * Builds an optgroup-style values map from a response object whose + * keys are group names and whose values are arrays of items. Each + * option key within an optgroup is prefixed with the singularized + * group name (e.g. `forms` => `form_123`), matching the + * prefixing convention used on the PHP side for grouped fields. + * + * Preserves any existing top-level placeholder options from the + * current field values. * * @since 3.3.1 * - * @param {string} resource Resource type (forms,tags,landing_pages,restrict_content). + * @param {Object} groups API response keyed by group name. + * @param {Object} existingValues Current values map for the field. + * @return {Object} Rebuilt values map. + */ + const buildOptgroupValues = function (groups, existingValues) { + const values = {}; + + // Preserve any top-level placeholder options (e.g. 'Do not restrict...'). + for (const existingKey in existingValues) { + if (typeof existingValues[existingKey] !== 'object') { + values[existingKey] = existingValues[existingKey]; + } + } + + // Build each optgroup from the response. + for (const groupKey in groups) { + const items = groups[groupKey]; + if (!Array.isArray(items) || items.length === 0) { + continue; + } + + // Derive the per-item key prefix from the group name + // (e.g. 'forms' => 'form_', 'tags' => 'tag_'). + const itemKeyPrefix = groupKey.replace(/s$/, '') + '_'; + + // Label the optgroup using the group name with the first + // letter capitalized (e.g. 'forms' => 'Forms'). + const label = + groupKey.charAt(0).toUpperCase() + groupKey.slice(1); + + const groupValues = {}; + items.forEach(function (item) { + groupValues[itemKeyPrefix + item.id] = + labelForItem(item); + }); + + values[groupKey] = { + label, + values: groupValues, + }; + } + + return values; + }; + + /** + * Refreshes resources for the given resource type, updating + * the specified field's values on success so the SelectControl + * re-renders with the latest options. + * + * The values shape is inferred from the API response: an array + * produces a flat list of options; an object keyed by group name + * produces optgroups. + * + * @since 3.3.1 + * + * @param {string} resource Resource type, appended to the refresh URL. + * @param {string} fieldKey The sidebar field key whose values should be updated. * @param {Function} setButtonDisabled Function to enable or disable the refresh button. */ - const refreshResources = function (resource, setButtonDisabled) { + const refreshResources = function ( + resource, + fieldKey, + setButtonDisabled + ) { // Disable the button. setButtonDisabled(true); @@ -1224,25 +1375,25 @@ function convertKitGutenbergRegisterPluginSidebar(sidebar) { return; } - // @TODO Update something here - convertkit_plugin_sidebars? - - // @TODO Refresh/redraw the field? - // The below code is how we do it for a block, but that doesn't apply to a block editor sidebar. - /* - // Update global ConvertKit Blocks object, so that any updated resources - // are reflected when adding new ConvertKit Blocks. - convertkit_blocks = response; - - // Update this block's properties, so that has_access_token, has_resources - // and the resources properties are updated. - block = convertkit_blocks[block.name]; - - // Call setAttributes on props to trigger the editBlock() function, which will re-render - // the block, reflecting any changes to its properties. - props.setAttributes({ - refresh: Date.now(), + // Rebuild the field's values from the response, and + // update state so the SelectControl re-renders with + // the latest options. The currently-selected value is + // stored in post meta and so is preserved automatically. + // + // The response shape determines the values shape: + // an array produces a flat list; an object keyed by + // group name (e.g. { forms: [...], tags: [...] }) + // produces optgroups. + setFieldValues(function (prev) { + const existing = prev[fieldKey] || {}; + const values = Array.isArray(response) + ? buildFlatValues(response, existing) + : buildOptgroupValues(response, existing); + + return Object.assign({}, prev, { + [fieldKey]: values, + }); }); - */ // Enable refresh button. setButtonDisabled(false); From 80ef1c762d0dab47f056b8808972ffabff5b1084 Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Fri, 24 Apr 2026 16:48:38 +0800 Subject: [PATCH 03/10] Improve comments and const names --- resources/backend/js/gutenberg.js | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/resources/backend/js/gutenberg.js b/resources/backend/js/gutenberg.js index a7b170edb..deb5ef45e 100644 --- a/resources/backend/js/gutenberg.js +++ b/resources/backend/js/gutenberg.js @@ -1243,7 +1243,7 @@ function convertKitGutenbergRegisterPluginSidebar(sidebar) { * @param {Object} existingValues Current values map for the field. * @return {Object} Rebuilt values map. */ - const buildFlatValues = function (items, existingValues) { + const buildSelectValues = function (items, existingValues) { const values = {}; // Preserve existing placeholder options (Default, None, etc.) @@ -1279,7 +1279,10 @@ function convertKitGutenbergRegisterPluginSidebar(sidebar) { * @param {Object} existingValues Current values map for the field. * @return {Object} Rebuilt values map. */ - const buildOptgroupValues = function (groups, existingValues) { + const buildSelectOptGroupValues = function ( + groups, + existingValues + ) { const values = {}; // Preserve any top-level placeholder options (e.g. 'Do not restrict...'). @@ -1325,10 +1328,6 @@ function convertKitGutenbergRegisterPluginSidebar(sidebar) { * the specified field's values on success so the SelectControl * re-renders with the latest options. * - * The values shape is inferred from the API response: an array - * produces a flat list of options; an object keyed by group name - * produces optgroups. - * * @since 3.3.1 * * @param {string} resource Resource type, appended to the refresh URL. @@ -1377,9 +1376,7 @@ function convertKitGutenbergRegisterPluginSidebar(sidebar) { // Rebuild the field's values from the response, and // update state so the SelectControl re-renders with - // the latest options. The currently-selected value is - // stored in post meta and so is preserved automatically. - // + // the latest options. // The response shape determines the values shape: // an array produces a flat list; an object keyed by // group name (e.g. { forms: [...], tags: [...] }) @@ -1387,8 +1384,8 @@ function convertKitGutenbergRegisterPluginSidebar(sidebar) { setFieldValues(function (prev) { const existing = prev[fieldKey] || {}; const values = Array.isArray(response) - ? buildFlatValues(response, existing) - : buildOptgroupValues(response, existing); + ? buildSelectValues(response, existing) + : buildSelectOptGroupValues(response, existing); return Object.assign({}, prev, { [fieldKey]: values, From 16ec82273f6dc17c3b5334cf25eefce1d47e0dae Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Fri, 24 Apr 2026 16:52:28 +0800 Subject: [PATCH 04/10] Align refresh button --- resources/backend/js/gutenberg.js | 73 ++++++++++++++++++++++--------- 1 file changed, 52 insertions(+), 21 deletions(-) diff --git a/resources/backend/js/gutenberg.js b/resources/backend/js/gutenberg.js index deb5ef45e..76ed86e94 100644 --- a/resources/backend/js/gutenberg.js +++ b/resources/backend/js/gutenberg.js @@ -942,6 +942,7 @@ function convertKitGutenbergRegisterPluginSidebar(sidebar) { SelectControl, Flex, FlexItem, + FlexBlock, PanelBody, PanelRow, Button, @@ -1038,30 +1039,60 @@ function convertKitGutenbergRegisterPluginSidebar(sidebar) { // If the field has a resource_type, wrap the select in a // Flex container alongside a refresh button. if (field.resource_type) { + const selectFieldProperties = Object.assign( + {}, + fieldProperties, + { help: undefined } + ); + return el( - Flex, + 'div', { - align: 'start', + key: + 'convertkit_plugin_sidebar_' + + key + + '_wrapper', }, - [ - el( - FlexItem, - { - key: key + '-select', - }, - getSelectField(field, fieldProperties) - ), - el( - FlexItem, - { - key: key + '-refresh', - }, - el(InlineRefreshButton, { - resource: field.resource_type, - fieldKey: key, - }) - ), - ] + el( + Flex, + { + align: 'end', + gap: 2, + }, + [ + el( + FlexBlock, + { + key: key + '-select', + }, + getSelectField( + field, + selectFieldProperties + ) + ), + el( + FlexItem, + { + key: key + '-refresh', + }, + el(InlineRefreshButton, { + resource: field.resource_type, + fieldKey: key, + }) + ), + ] + ), + fieldProperties.help + ? el( + 'p', + { + key: key + '-help', + className: + 'components-base-control__help', + }, + fieldProperties.help + ) + : null ); } From a24e9c8dbd61f05cd33c7bece2618f000f8a7695 Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Fri, 24 Apr 2026 17:08:49 +0800 Subject: [PATCH 05/10] Add ID to sidebar fields --- resources/backend/js/gutenberg.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/resources/backend/js/gutenberg.js b/resources/backend/js/gutenberg.js index 76ed86e94..68861df6f 100644 --- a/resources/backend/js/gutenberg.js +++ b/resources/backend/js/gutenberg.js @@ -1014,6 +1014,7 @@ function convertKitGutenbergRegisterPluginSidebar(sidebar) { // Define some field properties shared across all field types. const fieldProperties = { key: 'convertkit_plugin_sidebar_' + key, + id: 'convertkit_plugin_sidebar_' + key, label: field.label, help: Array.isArray(field.description) ? field.description.join('\n\n') @@ -1230,6 +1231,9 @@ function convertKitGutenbergRegisterPluginSidebar(sidebar) { (buttonDisabled ? ' is-refreshing' : ''), disabled: buttonDisabled, icon: iconType('update'), + // `data-resource` is used by tests and as a stable hook + // matching the classic-editor refresh button. + 'data-resource': resource, onClick() { // Refresh resources. refreshResources(resource, fieldKey, setButtonDisabled); From 2ecb502307639e84207140f91b245181c458eccd Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Fri, 24 Apr 2026 17:08:54 +0800 Subject: [PATCH 06/10] Added test --- .../other/RefreshResourcesButtonCest.php | 74 ++++++++++++++++++- 1 file changed, 72 insertions(+), 2 deletions(-) diff --git a/tests/EndToEnd/general/other/RefreshResourcesButtonCest.php b/tests/EndToEnd/general/other/RefreshResourcesButtonCest.php index 0c4dc7644..3aaa2274a 100644 --- a/tests/EndToEnd/general/other/RefreshResourcesButtonCest.php +++ b/tests/EndToEnd/general/other/RefreshResourcesButtonCest.php @@ -28,13 +28,14 @@ public function _before(EndToEndTester $I) } /** - * Test that the refresh buttons for Forms, Landing Pages, Tags and Restrict Content works when adding a new Page. + * Test that the refresh buttons for Forms, Landing Pages, Tags and Restrict Content works when adding a new Page + * using the Classic Editor. * * @since 1.9.8.0 * * @param EndToEndTester $I Tester. */ - public function testRefreshResourcesOnPage(EndToEndTester $I) + public function testRefreshResourcesInClassicEditor(EndToEndTester $I) { // Activate Classic Editor Plugin. $I->activateThirdPartyPlugin($I, 'classic-editor'); @@ -138,6 +139,75 @@ public function testRefreshResourcesOnPage(EndToEndTester $I) ); } + /** + * Test that the refresh buttons for Forms, Landing Pages, Tags and Restrict Content works when adding a new Page + * using the Gutenberg editor. + * + * @since 3.3.1 + * + * @param EndToEndTester $I Tester. + */ + public function testRefreshResourcesInGutenbergEditor(EndToEndTester $I) + { + // Setup Kit Plugin. + $I->setupKitPlugin($I); + + // Add a Post using the Gutenberg editor. + $I->addGutenbergPage( + $I, + title: 'Kit: Page: Refresh Resources: Gutenberg Editor', + postType: 'page' + ); + + // Open the Plugin sidebar settings. + $I->openPluginSidebarSettings($I); + + // Click the Forms refresh button. + $I->click('button.wp-convertkit-refresh-resources[data-resource="forms"]'); + + // Wait for button to change its state from disabled. + $I->waitForElementVisible('button.wp-convertkit-refresh-resources[data-resource="forms"]:not(:disabled)'); + + // Change resource to value specified in the .env file, which should now be available. + // If the expected dropdown value does not exist in the Select field, this will fail the test. + $I->selectOption('#convertkit_plugin_sidebar_form', $_ENV['CONVERTKIT_API_FORM_NAME']); + + // Click the Landing Pages refresh button. + $I->click('button.wp-convertkit-refresh-resources[data-resource="landing_pages"]'); + + // Wait for button to change its state from disabled. + $I->waitForElementVisible('button.wp-convertkit-refresh-resources[data-resource="landing_pages"]:not(:disabled)'); + + // Change resource to value specified in the .env file, which should now be available. + $I->selectOption('#convertkit_plugin_sidebar_landing_page', $_ENV['CONVERTKIT_API_LANDING_PAGE_NAME']); + + // Click the Tags refresh button. + $I->click('button.wp-convertkit-refresh-resources[data-resource="tags"]'); + + // Wait for button to change its state from disabled. + $I->waitForElementVisible('button.wp-convertkit-refresh-resources[data-resource="tags"]:not(:disabled)'); + + // Change resource to value specified in the .env file, which should now be available. + $I->selectOption('#convertkit_plugin_sidebar_tag', $_ENV['CONVERTKIT_API_TAG_NAME']); + + // Click the Restrict Content refresh button. + $I->click('button.wp-convertkit-refresh-resources[data-resource="restrict_content"]'); + + // Wait for button to change its state from disabled. + $I->waitForElementVisible('button.wp-convertkit-refresh-resources[data-resource="restrict_content"]:not(:disabled)'); + + // Confirm that the expected Tag is within the Tags option group and selectable. + $I->seeElementInDOM('#convertkit_plugin_sidebar_restrict_content optgroup[label="Tags"] option[value="tag_' . $_ENV['CONVERTKIT_API_TAG_ID'] . '"]'); + $I->selectOption('#convertkit_plugin_sidebar_restrict_content', $_ENV['CONVERTKIT_API_TAG_NAME']); + + // Confirm that the expected Product is within the Products option group and selectable. + $I->seeElementInDOM('#convertkit_plugin_sidebar_restrict_content optgroup[label="Products"] option[value="product_' . $_ENV['CONVERTKIT_API_PRODUCT_ID'] . '"]'); + $I->selectOption('#convertkit_plugin_sidebar_restrict_content', $_ENV['CONVERTKIT_API_PRODUCT_NAME']); + + // Close the Plugin sidebar settings. + $I->closePluginSidebarSettings($I); + } + /** * Test that the refresh buttons for Forms and Tags works when Quick Editing a Page. * From f88de97352a9c80816d5e6a6c527022864f2def2 Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Fri, 24 Apr 2026 17:13:45 +0800 Subject: [PATCH 07/10] Refresh Resource: Member Content: Include Forms --- ...ass-convertkit-admin-refresh-resources.php | 10 +++++++++ resources/backend/js/refresh-resources.js | 21 ++++++++++++++++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/admin/class-convertkit-admin-refresh-resources.php b/admin/class-convertkit-admin-refresh-resources.php index 9c9418a8d..9cae3a61f 100644 --- a/admin/class-convertkit-admin-refresh-resources.php +++ b/admin/class-convertkit-admin-refresh-resources.php @@ -105,6 +105,15 @@ public function refresh_resources( $request ) { break; case 'restrict_content': + // Fetch Forms. + $forms = new ConvertKit_Resource_Forms( 'user_refresh_resource' ); + $results_forms = $forms->refresh(); + + // Bail if an error occured. + if ( is_wp_error( $results_forms ) ) { + return rest_ensure_response( $results_forms ); + } + // Fetch Tags. $tags = new ConvertKit_Resource_Tags( 'user_refresh_resource' ); $results_tags = $tags->refresh(); @@ -126,6 +135,7 @@ public function refresh_resources( $request ) { // Return resources. return rest_ensure_response( array( + 'forms' => array_values( $results_forms ), 'tags' => array_values( $results_tags ), 'products' => array_values( $results_products ), ) diff --git a/resources/backend/js/refresh-resources.js b/resources/backend/js/refresh-resources.js index fca71e8d4..0d457bdd1 100644 --- a/resources/backend/js/refresh-resources.js +++ b/resources/backend/js/refresh-resources.js @@ -96,7 +96,26 @@ function convertKitRefreshResources(button) { // Depending on the resource we're refreshing, populate the