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/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..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,33 +194,37 @@ public function get_fields() { return array( 'form' => array( - 'label' => __( 'Form', 'convertkit' ), - 'type' => 'select', - '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, + 'values' => $forms, + 'resource_type' => 'forms', ), 'landing_page' => array( - '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', + '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' => '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, + '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' => '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, + '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 3b1047548..d32096a80 100644 --- a/resources/backend/js/gutenberg.js +++ b/resources/backend/js/gutenberg.js @@ -935,7 +935,18 @@ 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, + FlexBlock, + PanelBody, + PanelRow, + Button, + } = components; const { useSelect, useDispatch, select } = data; /** @@ -954,6 +965,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. * @@ -982,9 +1005,16 @@ 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, + id: 'convertkit_plugin_sidebar_' + key, label: field.label, help: Array.isArray(field.description) ? field.description.join('\n\n') @@ -1007,97 +1037,417 @@ function convertKitGutenbergRegisterPluginSidebar(sidebar) { // 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 - ); - - 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( + // 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( + 'div', + { + key: + 'convertkit_plugin_sidebar_' + + key + + '_wrapper', + }, + el( + Flex, + { + align: 'end', + gap: 2, + }, + [ el( - 'optgroup', + FlexBlock, { - label: field.values[value] - .label, - key: value, + key: key + '-select', }, - ...groupChildren - ) - ); - } else { - // Option within optgroup. - children.push( + getSelectField( + field, + selectFieldProperties + ) + ), el( - 'option', - { value, key: value }, - field.values[value] + 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 ) - ); - } - } - - return el( - SelectControl, - fieldProperties, - ...children + : null ); } - // Options only, no optgroups. - const fieldOptions = []; - for (const value of Object.keys(field.values)) { - fieldOptions.push({ - label: field.values[value], - value, - }); + 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 = []; + + 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] + ) + ); } + } - // Sort options alphabetically by label. - fieldOptions.sort(function (x, y) { - const a = x.label.toUpperCase(), - b = y.label.toUpperCase(); - return a.localeCompare(b); - }); + 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, + }); + } - // Assign options to field properties. - fieldProperties.options = fieldOptions; + // Sort options alphabetically by label. + fieldOptions.sort(function (x, y) { + const a = x.label.toUpperCase(), + b = y.label.toUpperCase(); + return a.localeCompare(b); + }); - // Return field element. - return el(SelectControl, fieldProperties); + // Assign options to field properties. + fieldProperties.options = fieldOptions; - default: - // Return field element. - return el(TextControl, fieldProperties); + // 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 sidebar field's resources. + * + * @since 3.3.1 + * + * @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, fieldKey }) { + const [buttonDisabled, setButtonDisabled] = useState(false); + + return el(Button, { + key: fieldKey + '-refresh-button', + className: + 'button button-secondary wp-convertkit-refresh-resources' + + (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); + }, + }); + }; + + /** + * 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 buildSelectValues = 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 {Object} groups API response keyed by group name. + * @param {Object} existingValues Current values map for the field. + * @return {Object} Rebuilt values map. + */ + const buildSelectOptGroupValues = 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 optGroupKey in groups) { + // Skip if this optgroup doesn't have any options. + const items = groups[optGroupKey]; + 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 = optGroupKey.replace(/s$/, '') + '_'; + + // Reuse the existing optgroup label if one exists, falling + // back to the capitalized group key if not. + const existingGroup = existingValues[optGroupKey]; + const label = + existingGroup && + typeof existingGroup === 'object' && + existingGroup.label + ? existingGroup.label + : optGroupKey.charAt(0).toUpperCase() + + optGroupKey.slice(1); + + const groupValues = {}; + items.forEach(function (item) { + groupValues[itemKeyPrefix + item.id] = + labelForItem(item); + }); + + values[optGroupKey] = { + 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. + * + * @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, + fieldKey, + 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); + } + + // 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', + }); + + // Enable refresh button. + setButtonDisabled(false); + return; + } + + // Rebuild the field's values from the response, and + // update state so the SelectControl re-renders with + // 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: [...] }) + // produces optgroups. + setFieldValues(function (prev) { + const existing = prev[fieldKey] || {}; + const values = Array.isArray(response) + ? buildSelectValues(response, existing) + : buildSelectOptGroupValues(response, existing); + + return Object.assign({}, prev, { + [fieldKey]: values, + }); + }); + + // 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); + }); }; /** 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