/** * Registers blocks in the Gutenberg editor. * * @since 1.9.6.5 * * @author ConvertKit */ /** * @typedef {import('@wordpress/element').WPElement} WPElement */ // Register Gutenberg Blocks if the Gutenberg Editor is loaded on screen. // This prevents JS errors if this script is accidentally enqueued on a non- // Gutenberg editor screen, or the Classic Editor Plugin is active. if (convertKitGutenbergEnabled()) { // Register each ConvertKit Block in Gutenberg. for (const block in convertkit_blocks) { convertKitGutenbergRegisterBlock(convertkit_blocks[block]); } if (convertKitEditingPostInGutenberg()) { // Register Plugin Sidebars in Gutenberg if we're editing a Post. if (typeof convertkit_plugin_sidebars !== 'undefined') { for (const pluginSidebar in convertkit_plugin_sidebars) { convertKitGutenbergRegisterPluginSidebar( convertkit_plugin_sidebars[pluginSidebar] ); } } // Register ConvertKit Pre-publish actions in Gutenberg if we're editing a Post. if (typeof convertkit_pre_publish_actions !== 'undefined') { convertKitGutenbergRegisterPrePublishActions( convertkit_pre_publish_actions ); } } } /** * Registers the given block in Gutenberg. * * @since 1.9.6.5 * * @param {Object} block Block. */ function convertKitGutenbergRegisterBlock(block) { (function (blocks, editor, element, components) { // Define some constants for the various items we'll use. const el = element.createElement; const { registerBlockType } = blocks; const { InspectorControls, InnerBlocks, useBlockProps } = editor; const { useState } = element; const { Button, Icon, TextControl, SelectControl, ToggleControl, Flex, FlexItem, PanelBody, PanelRow, ProgressBar, } = components; /** * Returns the icon to display for this block, depending * on the supplied block's configuration. * * @since 2.2.0 * * @return {WPElement|string} Either a WordPress element (RawHTML) or a dashicon string. */ const getIcon = function () { // Return a fallback default icon if none is specified for this block. if (typeof block.gutenberg_icon === 'undefined') { return 'dashicons-tablet'; } // Return HTML element if the icon is an SVG string. if (block.gutenberg_icon.search('svg') >= 0) { return element.RawHTML({ children: block.gutenberg_icon, }); } // Just return the string, as it's a dashicon CSS class. return block.gutenberg_icon; }; /** * Return a field element for the block sidebar, which is displayed in a panel's row * when this block is being edited. * * @since 2.2.0 * * @param {Object} props Block properties. * @param {Object} field Field attributes. * @param {string} attribute Attribute name to store the field's data in. * @return {Object} Field element. */ const getField = function (props, field, attribute) { // If this field is conditionally displayed, check if the field should be displayed. if (typeof field.display_if !== 'undefined') { // Assume the condition has not been met for this field to be displayed. let display_field = false; // Assert whether the condition is met based on the field type. switch (block.fields[field.display_if.key].type) { case 'toggle': // Field's condition value will be 0 or 1. // Attributes field value will be false or true. display_field = Boolean(Number(field.display_if.value)) === props.attributes[field.display_if.key]; break; default: // Assert based on the condition's value type (array, string, number). switch (typeof field.display_if.value) { case 'object': display_field = Object.values( field.display_if.value ).includes( props.attributes[field.display_if.key] ); break; default: display_field = field.display_if.value === props.attributes[field.display_if.key]; break; } break; } // Skip this field if the condition is not met. if (!display_field) { return false; } } // Define some field properties shared across all field types. const fieldProperties = { id: 'convertkit_' + block.name.replace(/-/g, '_') + '_' + attribute, label: field.label, help: field.description, value: props.attributes[attribute], // Add __next40pxDefaultSize and __nextHasNoMarginBottom properties, // preventing deprecation notices in the block editor and opt in to the new styles // from 7.0. __next40pxDefaultSize: true, __nextHasNoMarginBottom: true, onChange(value) { if (field.type === 'number') { // If value is a blank string i.e. no attribute value was provided, // cast it to the field's minimum number setting. // This prevents WordPress' block renderer API returning a 400 error // because a blank value will be passed as a string, when WordPress // expects it to be a numerical value. if (value === '') { value = field.min; } // Cast value to integer if a value exists. if (value.length > 0) { value = Number(value); } } const newValue = {}; newValue[attribute] = value; props.setAttributes(newValue); }, }; const fieldOptions = []; // Define additional Field Properties and the Field Element, // depending on the Field Type (select, textarea, text etc). switch (field.type) { case 'select': // Build options for input. fieldOptions.push({ label: '(None)', value: '', }); for (const value of Object.keys(field.values)) { fieldOptions.push({ label: field.values[value], value, }); } // If the block's saved value references a legacy form that // isn't in field.values (because legacy forms are not // offered as new selection choices), append it here so the // dropdown still reflects the saved selection. if ( fieldProperties.value && field.legacy_values && typeof field.legacy_values[fieldProperties.value] !== 'undefined' && !fieldOptions.some( (option) => option.value === fieldProperties.value ) ) { fieldOptions.push({ label: field.legacy_values[fieldProperties.value], value: fieldProperties.value, }); } // Sort field's 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. fieldProperties.options = fieldOptions; return el( Flex, { align: 'start', }, [ el( FlexItem, { key: attribute + '-select', }, el(SelectControl, fieldProperties) ), el( FlexItem, { key: attribute + '-refresh', }, inlineRefreshButton(props) ), ] ); case 'toggle': // Define field properties. fieldProperties.checked = props.attributes[attribute]; // Return field element. return el(ToggleControl, fieldProperties); case 'number': // Define field properties. fieldProperties.type = field.type; fieldProperties.min = field.min; fieldProperties.max = field.max; fieldProperties.step = field.step; // Return field element. return el(TextControl, fieldProperties); default: // Return field element. return el(TextControl, fieldProperties); } }; /** * Return an array of rows to display in the given block sidebar's panel when * this block is being edited. * * @since 2.2.0 * * @param {Object} props Block properties. * @param {string} panel Panel name. * @return {Array} Panel rows. */ const getPanelRows = function (props, panel) { // Build Inspector Control Panel Rows, one for each Field. const rows = []; for (const i in block.panels[panel].fields) { const attribute = block.panels[panel].fields[i], // e.g. 'term'. field = block.fields[attribute]; // field array. // If this field doesn't exist as an attribute in the block's get_attributes(), // this is a non-Gutenberg field (such as a color picker for shortcodes), // which should be ignored. if (typeof block.attributes[attribute] === 'undefined') { continue; } rows.push( el( PanelRow, { key: attribute, }, getField(props, field, attribute) ) ); } return rows; }; /** * Return an array of panels to display in the block's sidebar when the block * is being edited. * * @since 2.2.0 * * @param {Object} props Block formatter properties. * @return {Array} Block sidebar panels. */ const getPanels = function (props) { const panels = []; let initialOpen = true; // Build Inspector Control Panels. for (const panel in block.panels) { const panelRows = getPanelRows(props, panel); // If no panel rows exist (e.g. this is a shortcode only panel, // for styles, which Gutenberg registers in its own styles tab), // don't add this panel. if (!panelRows.length) { continue; } panels.push( el( PanelBody, { title: block.panels[panel].label, key: panel, initialOpen, }, panelRows ) ); // Don't open any further panels. initialOpen = false; } return panels; }; /** * Display settings sidebar when the block is being edited, and save * changes that are made. * * @since 2.2.0 * * @param {Object} props Block properties. * @return {Object} Block settings sidebar elements. */ const EditBlock = function (props) { const blockProps = useBlockProps(); // Refresh button disabled state on DisplayNoticeWithLink. // This must be here to avoid React error on hook order change when the user e.g. // connects their Kit account from within the block itself. const [buttonDisabled, setButtonDisabled] = useState(false); // If requesting an example of how this block looks (which is requested // when the user adds a new block and hovers over this block's icon), // show the preview image. if (props.attributes.is_gutenberg_example === true) { return el( 'div', blockProps, el('img', { src: block.gutenberg_example_image, }) ); } // If no access token has been defined in the Plugin, or no resources exist in Kit // for this block, show a message in the block to tell the user what to do. if (!block.has_access_token || !block.has_resources) { return DisplayNoticeWithLink( props, blockProps, buttonDisabled, setButtonDisabled ); } // Build Inspector Control Panels, which will appear in the Sidebar when editing the Block. const panels = getPanels(props); // Generate Block Preview. let preview = ''; // If a custom callback function to render this block's preview in the Gutenberg Editor // has been defined, use it. // This doesn't affect the output for this block on the frontend site, which will always // use the block's PHP's render() function. if ( typeof block.gutenberg_preview_render_callback !== 'undefined' ) { preview = window[block.gutenberg_preview_render_callback]( block, props ); return editBlockWithPanelsAndPreview( panels, preview, blockProps ); } // If no settings have been defined for this block, render the block with a notice // with instructions on how to configure the block. if ( typeof block.gutenberg_help_description_attribute !== 'undefined' && props.attributes[block.gutenberg_help_description_attribute] === '' ) { preview = convertKitGutenbergDisplayBlockNotice( block.name, block.gutenberg_help_description ); return editBlockWithPanelsAndPreview( panels, preview, blockProps ); } // If no render_callback is defined, render the block. if (typeof block.gutenberg_template !== 'undefined') { // Build template for the new block. const template = []; for (const templateBlockName in block.gutenberg_template) { if ( block.gutenberg_template.hasOwnProperty( templateBlockName ) ) { template.push([ templateBlockName, block.gutenberg_template[templateBlockName], ]); } } preview = el( 'div', {}, el(InnerBlocks, { template, }) ); return editBlockWithPanelsAndPreview( panels, preview, blockProps ); } // Use the block's PHP's render() function by calling the ServerSideRender component. preview = el(wp.serverSideRender, { block: 'convertkit/' + block.name, attributes: props.attributes, // This is only output in the Gutenberg editor, so must be slightly different from the inner class name used to // apply styles with i.e. convertkit-block.name. className: 'convertkit-ssr-' + block.name, }); return editBlockWithPanelsAndPreview(panels, preview, blockProps); }; /** * Display settings sidebar when the block is being edited, and save * changes that are made. * * @since 3.0.0 * * @param {Object} panels Block panels. * @param {Object} preview Block preview. * @param {Object} blockProps Block properties. * @return {Object} Block settings sidebar elements. */ const editBlockWithPanelsAndPreview = function ( panels, preview, blockProps ) { return el('div', blockProps, [ el(InspectorControls, {}, panels), preview, ]); }; /** * Save the block's content. * * @since 3.0.0 * * @return {Object} Block content. */ const saveBlock = function () { if (typeof block.gutenberg_template !== 'undefined') { // Use useBlockProps.save() to preserve styling classes and attributes // from block supports (colors, typography, spacing, etc.) const blockProps = useBlockProps.save(); return el('div', blockProps, el(InnerBlocks.Content)); } // Deliberate; preview in the editor is determined by the return statement in `edit` above. // On the frontend site, the block's render() PHP class is always called, so we dynamically // fetch the content. return null; }; /** * Display a notice in the block with a clickable link to perform an action, and a refresh * button to trigger editBlock(). Typically used when no API key exists in the Plugin, * or no resources (forms, products) exist in ConvertKit. * * @since 2.2.5 * * @param {Object} props Block properties. * @param {Object} blockProps Block properties. * @param {boolean} buttonDisabled Whether the refresh button is disabled (true) or enabled (false) * @param {Function} setButtonDisabled Function to enable or disable the refresh button. * @return {Object} Notice. */ const DisplayNoticeWithLink = function ( props, blockProps, buttonDisabled, setButtonDisabled ) { // Holds the array of elements to display in the notice component. let elements; // Define elements to display, based on whether the refresh button is disabled. if (buttonDisabled) { // Refresh button disabled; display a loading indicator and the button. elements = [ loadingIndicator(props), refreshButton(props, buttonDisabled, setButtonDisabled), ]; } else { // Refresh button enabled; display the notice, link and button. elements = [ el( 'div', { key: props.clientId + '-notice', }, !block.has_access_token ? block.no_access_token.notice : block.no_resources.notice ), noticeLink(props, setButtonDisabled), refreshButton(props, buttonDisabled, setButtonDisabled), ]; } // Return the element. return el( 'div', blockProps, el( 'div', { // convertkit-no-content class allows resources/backend/css/gutenberg.css // to apply styling/branding to the block. className: 'convertkit-' + block.name + ' convertkit-no-content', }, elements ) ); }; /** * Returns an indeterminate progress bar element, to show that a block is loading / refreshing. * * @since 2.2.6 * * @param {Object} props Block properties. * @return {Object} Progress Bar. */ const loadingIndicator = function (props) { // If the ProgressBar component is not available i.e. WordPress < 6.3, return a spinner. if (typeof ProgressBar === 'undefined') { return el('span', { key: props.clientId + '-spinner', className: 'spinner is-active convertkit-block-refreshing', }); } return el(ProgressBar, { key: props.clientId + '-progress-bar', className: 'convertkit-progress-bar convertkit-block-refreshing', }); }; /** * Returns a WordPress Icon element. * * @since 2.7.7 * * @param {string} iconName Icon Name. * @return {Object} Icon. */ const iconType = function (iconName) { return el(Icon, { icon: iconName, }); }; /** * Returns the notice link for the DisplayNoticeWithLink element. * * @since 2.2.6 * * @param {Object} props Block properties. * @param {Function} setButtonDisabled Function to enable or disable the refresh button. * @return {Object} Notice Link. */ const noticeLink = function (props, setButtonDisabled) { // Get the URL to set the button to. const url = !block.has_access_token ? block.no_access_token.link : block.no_resources.link; return el( Button, { key: props.clientId + '-notice-link', className: !block.has_access_token ? 'convertkit-block-modal' : '', variant: 'link', onClick(e) { e.preventDefault(); // Show popup window with setup wizard if we need to connect via OAuth. if (!block.has_access_token) { showConvertKitPopupWindow( props, url, setButtonDisabled ); return; } // Allow the link to load, as it's likely a link to the Kit site. window.open(url, '_blank'); }, }, !block.has_access_token ? block.no_access_token.link_text : block.no_resources.link_text ); }; /** * Returns a refresh button, used to refresh a block when it has no API Keys * or resources. * * @since 2.2.6 * * @param {Object} props Block properties. * @param {boolean} buttonDisabled Whether the refresh button is disabled (true) or enabled (false) * @param {Function} setButtonDisabled Function to enable or disable the refresh button. * @return {Object} Button. */ const refreshButton = function ( props, buttonDisabled, setButtonDisabled ) { return el(Button, { key: props.clientId + '-refresh-button', className: 'wp-convertkit-refresh-resources' + (buttonDisabled ? ' is-refreshing' : ''), disabled: buttonDisabled, text: 'Refresh', icon: iconType('update'), variant: 'secondary', onClick() { // Refresh block definitions. refreshBlocksDefinitions(props, setButtonDisabled); }, }); }; /** * Returns an inline refresh button, used to refresh a block's resources. * * @since 2.7.1 * * @param {Object} props Block properties. * @return {Object} Button. */ const inlineRefreshButton = function (props) { return el(BlockInlineRefreshButton, props); }; /** * Returns a refresh button. * * @since 2.7.1 * * @param {Object} props Block properties. * @return {Object} Button. */ const BlockInlineRefreshButton = function (props) { const [buttonDisabled, setButtonDisabled] = useState(false); return el(Button, { key: props.clientId + '-refresh-button', className: 'button button-secondary wp-convertkit-refresh-resources' + (buttonDisabled ? ' is-refreshing' : ''), disabled: buttonDisabled, icon: iconType('update'), onClick() { // Refresh block definitions. refreshBlocksDefinitions(props, setButtonDisabled); }, }); }; /** * Displays a new window with a given width and height to display the given URL. * * Typically used for displaying a modal version of the Setup Wizard, where the * user clicks the 'Click here to connect your ConvertKit account' link in a block, and then * enters their API Key and Secret. Will be used to show the ConvertKit * OAuth window in the future. * * @since 2.2.6 * * @param {Object} props Block properties. * @param {string} url URL to display in the popup window. * @param {Function} setButtonDisabled Function to enable or disable the refresh button. */ const showConvertKitPopupWindow = function ( props, url, setButtonDisabled ) { // Define popup width, height and positioning. const width = 640, height = 750, top = (window.screen.height - height) / 2, left = (window.screen.width - width) / 2; // Open popup. const convertKitPopup = window.open( url + '&convertkit-modal=1', 'convertkit_popup_window', 'toolbar=no,location=no,status=no,menubar=no,scrollbars=yes,resizable=yes,width=' + width + ',height=' + height + ',top=' + top + ',left=' + left ); // Center popup and focus. convertKitPopup.moveTo(left, top); convertKitPopup.focus(); // Refresh the block when the popup is closed using self.close(). // Won't fire if the user closes the popup manually, which is fine because that means // they didn't complete the steps, so refreshing wouldn't show anything new. // The onbeforeunload would seem suitable here, but it fires whenever the popup window's // document changes (e.g. as the user steps through a wizard), and doesn't fire when // the window is closed. // See https://stackoverflow.com/questions/9388380/capture-the-close-event-of-popup-window-in-javascript/48240128#48240128. const convertKitPopupTimer = setInterval(function () { if (convertKitPopup.closed) { clearInterval(convertKitPopupTimer); // Refresh block. refreshBlocksDefinitions(props, setButtonDisabled); } }, 1000); }; /** * Refreshes this block's properties by: * - making an AJAX call to fetch all registered blocks via convertkit_get_blocks(), * - storing the registered blocks in the `convertkit_blocks` global object, * - updating this block's properties by updating the `block` object. * * @since 2.2.6 * * @param {Object} props Block properties. * @param {Function} setButtonDisabled Function to enable or disable the refresh button. */ const refreshBlocksDefinitions = function (props, setButtonDisabled) { // Disable the button. if (typeof setButtonDisabled !== 'undefined') { setButtonDisabled(true); } // Send AJAX request. fetch(convertkit_gutenberg.ajaxurl, { method: 'GET', headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': convertkit_gutenberg.get_blocks_nonce, }, }) .then(function (response) { // Convert response JSON string to object. return response.json(); }) .then(function (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. if (typeof setButtonDisabled !== 'undefined') { setButtonDisabled(false); } return; } // 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. if (typeof setButtonDisabled !== 'undefined') { 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. if (typeof setButtonDisabled !== 'undefined') { setButtonDisabled(false); } }); }; // Register Block. registerBlockType('convertkit/' + block.name, { apiVersion: convertkit_gutenberg.block_api_version, title: block.title, description: block.description, category: block.category, icon: getIcon, keywords: block.keywords, attributes: block.attributes, supports: block.supports, example: { attributes: { is_gutenberg_example: true, }, }, // Editor. edit: EditBlock, // Output. save: saveBlock, }); })( window.wp.blocks, window.wp.blockEditor, window.wp.element, window.wp.components ); } /** * Registers a Plugin Sidebar in Gutenberg. * * @since 3.3.0 * * @param {Object} sidebar Plugin Sidebars */ function convertKitGutenbergRegisterPluginSidebar(sidebar) { (function (plugins, editor, element, components, data) { // Define some constants for the various items we'll use. const el = element.createElement; const { registerPlugin } = plugins; const { PluginSidebar } = editor; const { useState } = element; const { Icon, TextControl, SelectControl, Flex, FlexItem, FlexBlock, PanelBody, PanelRow, Button, } = components; const { useSelect, useDispatch, select } = data; /** * Returns a PluginDocumentSettingPanel for this plugin, containing * post-level settings. * * @since 3.3.0 */ const RenderPanel = function () { const meta = useSelect(function (wpSelect) { return ( wpSelect('core/editor').getEditedPostAttribute('meta') || {} ); }, []); const { editPost: wpEditPost } = useDispatch('core/editor'); 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. * * @since 3.3.0 * * @param {string} key Sub key within the meta_key object. * @param {string} value Value to assign to the sub key. */ const updateSetting = function (key, value) { wpEditPost({ meta: { [sidebar.meta_key]: Object.assign({}, settings, { [key]: value, }), }, }); }; /** * Return a field element for the settings panel. * * @since 3.3.0 * * @param {Object} field Field properties. * @param {string} key Field name. * @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, }); // Build the help element. Supports HTML in the description by // rendering each line as a paragraph via element.RawHTML, which // allows inline tags like and to render correctly. let helpElement; if (Array.isArray(field.description)) { helpElement = el( 'span', {}, field.description.map(function (line, index) { return el( 'p', { key: 'help-' + index, style: { margin: '0 0 0.5em 0' }, }, el(element.RawHTML, {}, line) ); }) ); } else if (field.description) { helpElement = el(element.RawHTML, {}, field.description); } // 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: helpElement, value: settings[key] || field.default_value || '', // Add __next40pxDefaultSize and __nextHasNoMarginBottom properties, // preventing deprecation notices in the block editor and opt in to the new styles // from 7.0. __next40pxDefaultSize: true, __nextHasNoMarginBottom: true, // Save Post Meta on value change. onChange(value) { updateSetting(key, value); }, }; // Define additional Field Properties and the Field Element, // depending on the Field Type (select, textarea, text etc). switch (field.type) { case 'select': // 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( 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 ); } 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 = []; const seenValues = new Set(); 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 )) { seenValues.add(groupValue); 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. seenValues.add(value); children.push( el( 'option', { value, key: value }, field.values[value] ) ); } } // If the saved value references a legacy form that isn't // otherwise in the dropdown, append it so the current // selection remains visible. if ( fieldProperties.value && field.legacy_values && typeof field.legacy_values[fieldProperties.value] !== 'undefined' && !seenValues.has(fieldProperties.value) ) { children.push( el( 'option', { value: fieldProperties.value, key: fieldProperties.value, }, field.legacy_values[fieldProperties.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, }); } // If the saved value references a legacy form that isn't in // field.values, append it so the current selection remains // visible in the dropdown. if ( fieldProperties.value && field.legacy_values && typeof field.legacy_values[fieldProperties.value] !== 'undefined' && !fieldOptions.some( (option) => option.value === fieldProperties.value ) ) { fieldOptions.push({ label: field.legacy_values[fieldProperties.value], value: fieldProperties.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 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); }); }; /** * Return an array of field elements to display in the settings panel. * * @since 3.3.0 * * @param {Object} fields Fields to display. * @return {Array} Panel rows. */ const getFields = function (fields) { const rows = []; for (const key in fields) { // Skip if the Post Type being edited is not the same as the Post Type specified in the field's post_type property. if ( typeof fields[key].post_type !== 'undefined' && fields[key].post_type !== currentPostType ) { continue; } rows.push( el( PanelRow, { key, }, getField(fields[key], key) ) ); } return el(PanelBody, {}, rows); }; // Return the settings sidebar panel with fields. return el( PluginSidebar, { name: sidebar.name, title: sidebar.title, className: sidebar.name, icon: element.RawHTML({ children: sidebar.gutenberg_icon, }), }, getFields(sidebar.fields) ); }; // Register the plugin sidebar. registerPlugin('convertkit-' + sidebar.name.replace(/_/g, '-'), { render: RenderPanel, }); })( window.wp.plugins, window.wp.editPost, window.wp.element, window.wp.components, window.wp.data ); } /** * Registers pre-publish actions in Gutenberg's pre-publish checks panel. * * @since 2.4.0 * * @param {Object} actions Pre-publish actions. */ function convertKitGutenbergRegisterPrePublishActions(actions) { (function (plugins, editPost, element, components, data) { const el = element.createElement; const { ToggleControl } = components; const { registerPlugin } = plugins; const { PluginPrePublishPanel } = editPost; const { useSelect, useDispatch, select } = data; /** * Returns a PluginPrePublishPanel for this plugin, containing all * pre-publish actions. * * @since 2.4.0 * @return {WPElement|null} Pre-publish panel element or null if not a post. */ const RenderPanel = function () { // --- Hooks must be called first --- const { meta } = useSelect((wpSelect) => ({ meta: wpSelect('core/editor').getEditedPostAttribute('meta'), })); const { editPost: wpEditPost } = useDispatch('core/editor'); const currentPostType = select('core/editor').getCurrentPostType(); // Bail early if not a 'post' if (currentPostType !== 'post') { return null; } // Build rows safely using .map() const rows = Object.values(actions).map((action) => { const key = '_convertkit_action_' + action.name; return el(ToggleControl, { key, id: 'convertkit_action_' + action.name, label: action.label, help: action.description, value: true, checked: meta[key], onChange(value) { wpEditPost({ meta: { [key]: value } }); }, }); }); // Return the pre-publish panel with rows return el( PluginPrePublishPanel, { className: 'convertkit-pre-publish-actions', title: 'Kit', initialOpen: true, }, rows ); }; // Register pre-publish actions registerPlugin('convertkit-pre-publish-actions', { render: RenderPanel, }); })( window.wp.plugins, window.wp.editPost, window.wp.element, window.wp.components, window.wp.data ); } /** * Outputs a notice for the block. Typically used when a block's settings * have not been defined, no API key exists in the Plugin or no resources * (forms, products) exist in ConvertKit, and the user adds an e.g. * Form / Product block. * * @since 2.2.3 * * @param {string} block_name Block Name. * @param {string} notice Notice to display. * @return {Object} HTMLElement */ function convertKitGutenbergDisplayBlockNotice(block_name, notice) { return wp.element.createElement( 'div', { // convertkit-no-content class allows resources/backend/css/gutenberg.css // to apply styling/branding to the block. className: 'convertkit-' + block_name + ' convertkit-no-content', }, notice ); } /** * Checks if the user is editing a post in the block editor. * * @since 3.0.8 * * @return {boolean} User is editing in the block editor */ function convertKitEditingPostInGutenberg() { // If the user is editing a post in the block editor, wp.editPost will be defined. return typeof wp !== 'undefined' && typeof wp.editPost !== 'undefined'; } /** * Checks if the Gutenberg editor is loaded on screen. * * Returns true when editing a Post, Page or Custom Post Type in the block editor, * or using the site editor. * * @since 3.0.8 * * @return {boolean} Block editor is loaded */ function convertKitGutenbergEnabled() { return typeof wp !== 'undefined' && typeof wp.blockEditor !== 'undefined'; }