PluginProbe
Kit (formerly ConvertKit) – Email Newsletter, Email Marketing, Membership, Subscribers and Landing Pages / 3.4.3
Kit (formerly ConvertKit) – Email Newsletter, Email Marketing, Membership, Subscribers and Landing Pages v3.4.3
3.4.3 3.4.2 3.4.1 3.4.0 3.3.9 3.3.8 3.3.7 3.3.6 3.3.5 3.3.4 3.3.3 3.3.2 3.3.1 2.2.0 2.2.1 2.2.2 2.2.3 2.2.4 2.2.5 2.2.6 2.2.7 2.2.8 2.2.9 2.3.0 2.3.1 All 196 releases
← All changes | resources/backend/js/gutenberg.js +1525 -215 2.2.33.4.3 View file →
@@ -2,23 +2,41 @@
2 2 * Registers blocks in the Gutenberg editor.
3 3 *
4 4 * @since 1.9.6.5
5 5 *
6 - * @package ConvertKit
7 6 * @author ConvertKit
8 7 */
9 8
9 +/**
10 + * @typedef {import('@wordpress/element').WPElement} WPElement
11 + */
12 +
10 13 // Register Gutenberg Blocks if the Gutenberg Editor is loaded on screen.
11 14 // This prevents JS errors if this script is accidentally enqueued on a non-
12 15 // Gutenberg editor screen, or the Classic Editor Plugin is active.
13 -if ( typeof wp !== 'undefined' &&
14 - typeof wp.blocks !== 'undefined' ) {
15 -
16 +if (convertKitGutenbergEnabled()) {
16 17 // Register each ConvertKit Block in Gutenberg.
17 - for ( const block in convertkit_blocks ) {
18 - convertKitGutenbergRegisterBlock( convertkit_blocks[ block ] );
18 + for (const block in convertkit_blocks) {
19 + convertKitGutenbergRegisterBlock(convertkit_blocks[block]);
19 20 }
20 21
22 + if (convertKitEditingPostInGutenberg()) {
23 + // Register Plugin Sidebars in Gutenberg if we're editing a Post.
24 + if (typeof convertkit_plugin_sidebars !== 'undefined') {
25 + for (const pluginSidebar in convertkit_plugin_sidebars) {
26 + convertKitGutenbergRegisterPluginSidebar(
27 + convertkit_plugin_sidebars[pluginSidebar]
28 + );
29 + }
30 + }
31 +
32 + // Register ConvertKit Pre-publish actions in Gutenberg if we're editing a Post.
33 + if (typeof convertkit_pre_publish_actions !== 'undefined') {
34 + convertKitGutenbergRegisterPrePublishActions(
35 + convertkit_pre_publish_actions
36 + );
37 + }
38 + }
21 39 }
22 40
23 41 /**
24 42 * Registers the given block in Gutenberg.
@@ -24,27 +42,29 @@
24 42 * Registers the given block in Gutenberg.
25 43 *
26 44 * @since 1.9.6.5
27 45 *
28 - * @param object block Block
46 + * @param {Object} block Block.
29 47 */
30 -function convertKitGutenbergRegisterBlock( block ) {
31 -
32 - ( function( blocks, editor, element, components ) {
33 -
48 +function convertKitGutenbergRegisterBlock(block) {
49 + (function (blocks, editor, element, components) {
34 50 // Define some constants for the various items we'll use.
35 - const el = element.createElement;
51 + const el = element.createElement;
36 52 const { registerBlockType } = blocks;
37 - const { InspectorControls } = editor;
38 - const { Fragment } = element;
53 + const { InspectorControls, InnerBlocks, useBlockProps } = editor;
54 + const { useState } = element;
39 55 const {
56 + Button,
57 + Icon,
40 58 TextControl,
41 59 SelectControl,
42 60 ToggleControl,
43 - Panel,
61 + Flex,
62 + FlexItem,
44 63 PanelBody,
45 - PanelRow
46 - } = components;
64 + PanelRow,
65 + ProgressBar,
66 + } = components;
47 67
48 68 /**
49 69 * Returns the icon to display for this block, depending
50 70 * on the supplied block's configuration.
@@ -50,31 +70,27 @@
50 70 * on the supplied block's configuration.
51 71 *
52 72 * @since 2.2.0
53 73 *
54 - * @return element|string
74 + * @return {WPElement|string} Either a WordPress element (RawHTML) or a dashicon string.
55 75 */
56 - const getIcon = function() {
57 -
76 + const getIcon = function () {
58 77 // Return a fallback default icon if none is specified for this block.
59 - if ( typeof block.gutenberg_icon === 'undefined' ) {
78 + if (typeof block.gutenberg_icon === 'undefined') {
60 79 return 'dashicons-tablet';
61 80 }
62 81
63 82 // Return HTML element if the icon is an SVG string.
64 - if ( block.gutenberg_icon.search( 'svg' ) >= 0 ) {
65 - return element.RawHTML(
66 - {
67 - children: block.gutenberg_icon
68 - }
69 - );
83 + if (block.gutenberg_icon.search('svg') >= 0) {
84 + return element.RawHTML({
85 + children: block.gutenberg_icon,
86 + });
70 87 }
71 88
72 89 // Just return the string, as it's a dashicon CSS class.
73 90 return block.gutenberg_icon;
91 + };
74 92
75 - }
76 -
77 93 /**
78 94 * Return a field element for the block sidebar, which is displayed in a panel's row
79 95 * when this block is being edited.
80 96 *
@@ -79,123 +95,233 @@
79 95 * when this block is being edited.
80 96 *
81 97 * @since 2.2.0
82 98 *
83 - * @param object props Block properties.
84 - * @param object field Field attributes.
85 - * @param string attribute Attribute name to store the field's data in.
86 - * @return array Field element
99 + * @param {Object} props Block properties.
100 + * @param {Object} field Field attributes.
101 + * @param {string} attribute Attribute name to store the field's data in.
102 + * @return {Object} Field element.
87 103 */
88 - const getField = function( props, field, attribute ) {
104 + const getField = function (props, field, attribute) {
105 + // If this field is conditionally displayed, check if the field should be displayed.
106 + if (typeof field.display_if !== 'undefined') {
107 + // Assume the condition has not been met for this field to be displayed.
108 + let display_field = false;
89 109
110 + // Assert whether the condition is met based on the field type.
111 + switch (block.fields[field.display_if.key].type) {
112 + case 'toggle':
113 + // Field's condition value will be 0 or 1.
114 + // Attributes field value will be false or true.
115 + display_field =
116 + Boolean(Number(field.display_if.value)) ===
117 + props.attributes[field.display_if.key];
118 + break;
119 +
120 + default:
121 + // Assert based on the condition's value type (array, string, number).
122 + switch (typeof field.display_if.value) {
123 + case 'object':
124 + display_field = Object.values(
125 + field.display_if.value
126 + ).includes(
127 + props.attributes[field.display_if.key]
128 + );
129 + break;
130 +
131 + default:
132 + display_field =
133 + field.display_if.value ===
134 + props.attributes[field.display_if.key];
135 + break;
136 + }
137 + break;
138 + }
139 +
140 + // Skip this field if the condition is not met.
141 + if (!display_field) {
142 + return false;
143 + }
144 + }
145 +
90 146 // Define some field properties shared across all field types.
91 - let fieldProperties = {
92 - id: 'convertkit_' + block.name + '_' + attribute,
93 - label: field.label,
94 - help: field.description,
95 - value: props.attributes[ attribute ],
96 - onChange: function( value ) {
97 - if ( field.type === 'number' ) {
147 + const fieldProperties = {
148 + id:
149 + 'convertkit_' +
150 + block.name.replace(/-/g, '_') +
151 + '_' +
152 + attribute,
153 + label: field.label,
154 + help: field.description,
155 + value: props.attributes[attribute],
156 +
157 + // Add __next40pxDefaultSize and __nextHasNoMarginBottom properties,
158 + // preventing deprecation notices in the block editor and opt in to the new styles
159 + // from 7.0.
160 + __next40pxDefaultSize: true,
161 + __nextHasNoMarginBottom: true,
162 +
163 + onChange(value) {
164 + if (field.type === 'number') {
98 165 // If value is a blank string i.e. no attribute value was provided,
99 166 // cast it to the field's minimum number setting.
100 167 // This prevents WordPress' block renderer API returning a 400 error
101 168 // because a blank value will be passed as a string, when WordPress
102 169 // expects it to be a numerical value.
103 - if ( value === '' ) {
170 + if (value === '') {
104 171 value = field.min;
105 172 }
106 173
107 174 // Cast value to integer if a value exists.
108 - if ( value.length > 0 ) {
109 - value = Number( value );
175 + if (value.length > 0) {
176 + value = Number(value);
110 177 }
111 178 }
112 179
113 - let newValue = {};
114 - newValue[ attribute ] = value;
115 - props.setAttributes( newValue );
116 - }
180 + const newValue = {};
181 + newValue[attribute] = value;
182 + props.setAttributes(newValue);
183 + },
117 184 };
118 185
186 + const fieldOptions = [];
187 +
119 188 // Define additional Field Properties and the Field Element,
120 189 // depending on the Field Type (select, textarea, text etc).
121 - switch ( field.type ) {
122 -
190 + switch (field.type) {
123 191 case 'select':
124 192 // Build options for <select> input.
125 - let fieldOptions = [];
126 - fieldOptions.push(
127 - {
128 - label: '(None)',
129 - value: '',
130 - }
131 - );
132 - for ( let value in field.values ) {
133 - fieldOptions.push(
134 - {
135 - label: field.values[ value ],
136 - value: value
137 - }
138 - );
193 + fieldOptions.push({
194 + label: '(None)',
195 + value: '',
196 + });
197 + for (const value of Object.keys(field.values)) {
198 + fieldOptions.push({
199 + label: field.values[value],
200 + value,
201 + });
139 202 }
140 203
204 + // If the block's saved value references a legacy form that
205 + // isn't in field.values (legacy forms are not offered as
206 + // new selection choices), append it here so the dropdown
207 + // still reflects the saved selection.
208 + if (
209 + fieldProperties.value &&
210 + field.legacy_values &&
211 + typeof field.legacy_values[fieldProperties.value] !==
212 + 'undefined' &&
213 + !fieldOptions.some(
214 + (option) => option.value === fieldProperties.value
215 + )
216 + ) {
217 + fieldOptions.push({
218 + label: field.legacy_values[fieldProperties.value],
219 + value: fieldProperties.value,
220 + });
221 + }
222 +
141 223 // Sort field's options alphabetically by label.
142 - fieldOptions.sort(
143 - function ( x, y ) {
224 + fieldOptions.sort(function (x, y) {
225 + const a = x.label.toUpperCase(),
226 + b = y.label.toUpperCase();
227 + return a.localeCompare(b);
228 + });
144 229
145 - let a = x.label.toUpperCase(),
146 - b = y.label.toUpperCase();
147 - return a.localeCompare( b );
230 + // Assign options to field.
231 + fieldProperties.options = fieldOptions;
148 232
149 - }
150 - );
233 + // Return field element.
234 + return el(SelectControl, fieldProperties);
151 235
236 + case 'resource':
237 + // Build options for <select> input.
238 + fieldOptions.push({
239 + label: '(None)',
240 + value: '',
241 + });
242 + for (const value of Object.keys(field.values)) {
243 + fieldOptions.push({
244 + label: field.values[value],
245 + value,
246 + });
247 + }
248 +
249 + // If the block's saved value references a legacy form that
250 + // isn't in field.values (because legacy forms are not
251 + // offered as new selection choices), append it here so the
252 + // dropdown still reflects the saved selection.
253 + if (
254 + fieldProperties.value &&
255 + field.legacy_values &&
256 + typeof field.legacy_values[fieldProperties.value] !==
257 + 'undefined' &&
258 + !fieldOptions.some(
259 + (option) => option.value === fieldProperties.value
260 + )
261 + ) {
262 + fieldOptions.push({
263 + label: field.legacy_values[fieldProperties.value],
264 + value: fieldProperties.value,
265 + });
266 + }
267 +
268 + // Sort field's options alphabetically by label.
269 + fieldOptions.sort(function (x, y) {
270 + const a = x.label.toUpperCase(),
271 + b = y.label.toUpperCase();
272 + return a.localeCompare(b);
273 + });
274 +
152 275 // Assign options to field.
153 276 fieldProperties.options = fieldOptions;
154 277
155 - // Return field element.
156 278 return el(
157 - SelectControl,
158 - fieldProperties
279 + Flex,
280 + {
281 + align: 'start',
282 + },
283 + [
284 + el(
285 + FlexItem,
286 + {
287 + key: attribute + '-select',
288 + },
289 + el(SelectControl, fieldProperties)
290 + ),
291 + el(
292 + FlexItem,
293 + {
294 + key: attribute + '-refresh',
295 + },
296 + inlineRefreshButton(props)
297 + ),
298 + ]
159 299 );
160 - break;
161 300
162 301 case 'toggle':
163 302 // Define field properties.
164 - fieldProperties.checked = props.attributes[ attribute ];
303 + fieldProperties.checked = props.attributes[attribute];
165 304
166 305 // Return field element.
167 - return el(
168 - ToggleControl,
169 - fieldProperties
170 - );
171 - break;
306 + return el(ToggleControl, fieldProperties);
172 307
173 308 case 'number':
174 309 // Define field properties.
175 310 fieldProperties.type = field.type;
176 - fieldProperties.min = field.min;
177 - fieldProperties.max = field.max;
311 + fieldProperties.min = field.min;
312 + fieldProperties.max = field.max;
178 313 fieldProperties.step = field.step;
179 314
180 315 // Return field element.
181 - return el(
182 - TextControl,
183 - fieldProperties
184 - );
185 - break;
316 + return el(TextControl, fieldProperties);
186 317
187 318 default:
188 319 // Return field element.
189 - return el(
190 - TextControl,
191 - fieldProperties
192 - );
193 - break;
320 + return el(TextControl, fieldProperties);
194 321 }
322 + };
195 323
196 - }
197 -
198 324 /**
199 325 * Return an array of rows to display in the given block sidebar's panel when
200 326 * this block is being edited.
201 327 *
@@ -200,24 +326,23 @@
200 326 * this block is being edited.
201 327 *
202 328 * @since 2.2.0
203 329 *
204 - * @param object props Block properties.
205 - * @param string panel Panel name.
206 - * @return array Panel rows
330 + * @param {Object} props Block properties.
331 + * @param {string} panel Panel name.
332 + * @return {Array} Panel rows.
207 333 */
208 - const getPanelRows = function( props, panel ) {
209 -
334 + const getPanelRows = function (props, panel) {
210 335 // Build Inspector Control Panel Rows, one for each Field.
211 - let rows = [];
212 - for ( let i in block.panels[ panel ].fields ) {
213 - const attribute = block.panels[ panel ].fields[ i ], // e.g. 'term'.
214 - field = block.fields[ attribute ]; // field array.
336 + const rows = [];
337 + for (const i in block.panels[panel].fields) {
338 + const attribute = block.panels[panel].fields[i], // e.g. 'term'.
339 + field = block.fields[attribute]; // field array.
215 340
216 341 // If this field doesn't exist as an attribute in the block's get_attributes(),
217 342 // this is a non-Gutenberg field (such as a color picker for shortcodes),
218 343 // which should be ignored.
219 - if ( typeof block.attributes[ attribute ] === 'undefined' ) {
344 + if (typeof block.attributes[attribute] === 'undefined') {
220 345 continue;
221 346 }
222 347
223 348 rows.push(
@@ -223,19 +348,18 @@
223 348 rows.push(
224 349 el(
225 350 PanelRow,
226 351 {
227 - key: attribute
352 + key: attribute,
228 353 },
229 - getField( props, field, attribute )
354 + getField(props, field, attribute)
230 355 )
231 356 );
232 357 }
233 358
234 359 return rows;
360 + };
235 361
236 - }
237 -
238 362 /**
239 363 * Return an array of panels to display in the block's sidebar when the block
240 364 * is being edited.
241 365 *
@@ -240,27 +364,35 @@
240 364 * is being edited.
241 365 *
242 366 * @since 2.2.0
243 367 *
244 - * @param object props Block formatter properties.
245 - * @return array Block sidebar panels.
368 + * @param {Object} props Block formatter properties.
369 + * @return {Array} Block sidebar panels.
246 370 */
247 - const getPanels = function( props ) {
371 + const getPanels = function (props) {
372 + const panels = [];
373 + let initialOpen = true;
248 374
249 - let panels = [],
250 - initialOpen = true;
375 + // Build Inspector Control Panels.
376 + for (const panel in block.panels) {
377 + const panelRows = getPanelRows(props, panel);
251 378
252 - // Build Inspector Control Panels.
253 - for ( const panel in block.panels ) {
379 + // If no panel rows exist (e.g. this is a shortcode only panel,
380 + // for styles, which Gutenberg registers in its own styles tab),
381 + // don't add this panel.
382 + if (!panelRows.length) {
383 + continue;
384 + }
385 +
254 386 panels.push(
255 387 el(
256 388 PanelBody,
257 389 {
258 - title: block.panels[ panel ].label,
390 + title: block.panels[panel].label,
259 391 key: panel,
260 - initialOpen: initialOpen
392 + initialOpen,
261 393 },
262 - getPanelRows( props, panel )
394 + panelRows
263 395 )
264 396 );
265 397
266 398 // Don't open any further panels.
@@ -267,11 +399,10 @@
267 399 initialOpen = false;
268 400 }
269 401
270 402 return panels;
403 + };
271 404
272 - }
273 -
274 405 /**
275 406 * Display settings sidebar when the block is being edited, and save
276 407 * changes that are made.
277 408 *
@@ -276,101 +407,1294 @@
276 407 * changes that are made.
277 408 *
278 409 * @since 2.2.0
279 410 *
280 - * @param object props Block properties.
281 - * @return object Block settings sidebar elements
411 + * @param {Object} props Block properties.
412 + * @return {Object} Block settings sidebar elements.
282 413 */
283 - const editBlock = function( props ) {
414 + const EditBlock = function (props) {
415 + const blockProps = useBlockProps();
284 416
417 + // Refresh button disabled state on DisplayNoticeWithLink.
418 + // This must be here to avoid React error on hook order change when the user e.g.
419 + // connects their Kit account from within the block itself.
420 + const [buttonDisabled, setButtonDisabled] = useState(false);
421 +
285 422 // If requesting an example of how this block looks (which is requested
286 423 // when the user adds a new block and hovers over this block's icon),
287 424 // show the preview image.
288 - if ( props.attributes.is_gutenberg_example === true ) {
289 - return (
290 - Fragment,
291 - {},
292 - el(
293 - 'img',
294 - {
295 - src: block.gutenberg_example_image,
296 - }
297 - )
425 + if (props.attributes.is_gutenberg_example === true) {
426 + return el(
427 + 'div',
428 + blockProps,
429 + el('img', {
430 + src: block.gutenberg_example_image,
431 + })
298 432 );
299 433 }
300 434
435 + // If no access token has been defined in the Plugin, or no resources exist in Kit
436 + // for this block, show a message in the block to tell the user what to do.
437 + if (!block.has_access_token || !block.has_resources) {
438 + return DisplayNoticeWithLink(
439 + props,
440 + blockProps,
441 + buttonDisabled,
442 + setButtonDisabled
443 + );
444 + }
445 +
301 446 // Build Inspector Control Panels, which will appear in the Sidebar when editing the Block.
302 - let panels = getPanels( props );
447 + const panels = getPanels(props);
303 448
304 449 // Generate Block Preview.
305 450 let preview = '';
306 - if ( typeof block.gutenberg_preview_render_callback !== 'undefined' ) {
307 - // Use a custom callback function to render this block's preview in the Gutenberg Editor.
308 - // This doesn't affect the output for this block on the frontend site, which will always
309 - // use the block's PHP's render() function.
310 - preview = window[ block.gutenberg_preview_render_callback ]( block, props );
451 +
452 + // If a custom callback function to render this block's preview in the Gutenberg Editor
453 + // has been defined, use it.
454 + // This doesn't affect the output for this block on the frontend site, which will always
455 + // use the block's PHP's render() function.
456 + if (
457 + typeof block.gutenberg_preview_render_callback !== 'undefined'
458 + ) {
459 + preview = window[block.gutenberg_preview_render_callback](
460 + block,
461 + props
462 + );
463 + return editBlockWithPanelsAndPreview(
464 + panels,
465 + preview,
466 + blockProps
467 + );
311 468 }
312 469
313 - // Return settings sidebar panel with fields and the bloc preview.
314 - return (
315 - el(
316 - // Sidebar Panel with Fields.
317 - Fragment,
470 + // If no settings have been defined for this block, render the block with a notice
471 + // with instructions on how to configure the block.
472 + if (
473 + typeof block.gutenberg_help_description_attribute !==
474 + 'undefined' &&
475 + props.attributes[block.gutenberg_help_description_attribute] ===
476 + ''
477 + ) {
478 + preview = convertKitGutenbergDisplayBlockNotice(
479 + block.name,
480 + block.gutenberg_help_description
481 + );
482 + return editBlockWithPanelsAndPreview(
483 + panels,
484 + preview,
485 + blockProps
486 + );
487 + }
488 +
489 + // If no render_callback is defined, render the block.
490 + if (typeof block.gutenberg_template !== 'undefined') {
491 + // Build template for the new block.
492 + const template = [];
493 + for (const templateBlockName in block.gutenberg_template) {
494 + if (
495 + block.gutenberg_template.hasOwnProperty(
496 + templateBlockName
497 + )
498 + ) {
499 + template.push([
500 + templateBlockName,
501 + block.gutenberg_template[templateBlockName],
502 + ]);
503 + }
504 + }
505 +
506 + preview = el(
507 + 'div',
318 508 {},
509 + el(InnerBlocks, {
510 + template,
511 + })
512 + );
513 + return editBlockWithPanelsAndPreview(
514 + panels,
515 + preview,
516 + blockProps
517 + );
518 + }
519 +
520 + // Use the block's PHP's render() function by calling the ServerSideRender component.
521 + preview = el(wp.serverSideRender, {
522 + block: 'convertkit/' + block.name,
523 + attributes: props.attributes,
524 +
525 + // This is only output in the Gutenberg editor, so must be slightly different from the inner class name used to
526 + // apply styles with i.e. convertkit-block.name.
527 + className: 'convertkit-ssr-' + block.name,
528 + });
529 + return editBlockWithPanelsAndPreview(panels, preview, blockProps);
530 + };
531 +
532 + /**
533 + * Display settings sidebar when the block is being edited, and save
534 + * changes that are made.
535 + *
536 + * @since 3.0.0
537 + *
538 + * @param {Object} panels Block panels.
539 + * @param {Object} preview Block preview.
540 + * @param {Object} blockProps Block properties.
541 + * @return {Object} Block settings sidebar elements.
542 + */
543 + const editBlockWithPanelsAndPreview = function (
544 + panels,
545 + preview,
546 + blockProps
547 + ) {
548 + return el('div', blockProps, [
549 + el(InspectorControls, {}, panels),
550 + preview,
551 + ]);
552 + };
553 +
554 + /**
555 + * Save the block's content.
556 + *
557 + * @since 3.0.0
558 + *
559 + * @return {Object} Block content.
560 + */
561 + const saveBlock = function () {
562 + if (typeof block.gutenberg_template !== 'undefined') {
563 + // Use useBlockProps.save() to preserve styling classes and attributes
564 + // from block supports (colors, typography, spacing, etc.)
565 + const blockProps = useBlockProps.save();
566 + return el('div', blockProps, el(InnerBlocks.Content));
567 + }
568 +
569 + // Deliberate; preview in the editor is determined by the return statement in `edit` above.
570 + // On the frontend site, the block's render() PHP class is always called, so we dynamically
571 + // fetch the content.
572 + return null;
573 + };
574 +
575 + /**
576 + * Display a notice in the block with a clickable link to perform an action, and a refresh
577 + * button to trigger editBlock(). Typically used when no API key exists in the Plugin,
578 + * or no resources (forms, products) exist in ConvertKit.
579 + *
580 + * @since 2.2.5
581 + *
582 + * @param {Object} props Block properties.
583 + * @param {Object} blockProps Block properties.
584 + * @param {boolean} buttonDisabled Whether the refresh button is disabled (true) or enabled (false)
585 + * @param {Function} setButtonDisabled Function to enable or disable the refresh button.
586 + * @return {Object} Notice.
587 + */
588 + const DisplayNoticeWithLink = function (
589 + props,
590 + blockProps,
591 + buttonDisabled,
592 + setButtonDisabled
593 + ) {
594 + // Holds the array of elements to display in the notice component.
595 + let elements;
596 +
597 + // Define elements to display, based on whether the refresh button is disabled.
598 + if (buttonDisabled) {
599 + // Refresh button disabled; display a loading indicator and the button.
600 + elements = [
601 + loadingIndicator(props),
602 + refreshButton(props, buttonDisabled, setButtonDisabled),
603 + ];
604 + } else {
605 + // Refresh button enabled; display the notice, link and button.
606 + elements = [
319 607 el(
320 - InspectorControls,
321 - {},
322 - panels
608 + 'div',
609 + {
610 + key: props.clientId + '-notice',
611 + },
612 + !block.has_access_token
613 + ? block.no_access_token.notice
614 + : block.no_resources.notice
323 615 ),
324 - // Block Preview.
325 - preview
616 + noticeLink(props, setButtonDisabled),
617 + refreshButton(props, buttonDisabled, setButtonDisabled),
618 + ];
619 + }
620 +
621 + // Return the element.
622 + return el(
623 + 'div',
624 + blockProps,
625 + el(
626 + 'div',
627 + {
628 + // convertkit-no-content class allows resources/backend/css/gutenberg.css
629 + // to apply styling/branding to the block.
630 + className:
631 + 'convertkit-' +
632 + block.name +
633 + ' convertkit-no-content',
634 + },
635 + elements
326 636 )
327 637 );
638 + };
328 639
329 - }
640 + /**
641 + * Returns an indeterminate progress bar element, to show that a block is loading / refreshing.
642 + *
643 + * @since 2.2.6
644 + *
645 + * @param {Object} props Block properties.
646 + * @return {Object} Progress Bar.
647 + */
648 + const loadingIndicator = function (props) {
649 + // If the ProgressBar component is not available i.e. WordPress < 6.3, return a spinner.
650 + if (typeof ProgressBar === 'undefined') {
651 + return el('span', {
652 + key: props.clientId + '-spinner',
653 + className: 'spinner is-active convertkit-block-refreshing',
654 + });
655 + }
330 656
331 - // Register Block.
332 - registerBlockType(
333 - 'convertkit/' + block.name,
334 - {
335 - title: block.title,
336 - description:block.description,
337 - category: block.category,
338 - icon: getIcon,
339 - keywords: block.keywords,
340 - attributes: block.attributes,
341 - supports: block.supports,
342 - example: {
343 - attributes: {
344 - is_gutenberg_example: true,
345 - }
657 + return el(ProgressBar, {
658 + key: props.clientId + '-progress-bar',
659 + className:
660 + 'convertkit-progress-bar convertkit-block-refreshing',
661 + });
662 + };
663 +
664 + /**
665 + * Returns a WordPress Icon element.
666 + *
667 + * @since 2.7.7
668 + *
669 + * @param {string} iconName Icon Name.
670 + * @return {Object} Icon.
671 + */
672 + const iconType = function (iconName) {
673 + return el(Icon, {
674 + icon: iconName,
675 + });
676 + };
677 +
678 + /**
679 + * Returns the notice link for the DisplayNoticeWithLink element.
680 + *
681 + * @since 2.2.6
682 + *
683 + * @param {Object} props Block properties.
684 + * @param {Function} setButtonDisabled Function to enable or disable the refresh button.
685 + * @return {Object} Notice Link.
686 + */
687 + const noticeLink = function (props, setButtonDisabled) {
688 + // Get the URL to set the button to.
689 + const url = !block.has_access_token
690 + ? block.no_access_token.link
691 + : block.no_resources.link;
692 +
693 + return el(
694 + Button,
695 + {
696 + key: props.clientId + '-notice-link',
697 + className: !block.has_access_token
698 + ? 'convertkit-block-modal'
699 + : '',
700 + variant: 'link',
701 + onClick(e) {
702 + e.preventDefault();
703 +
704 + // Show popup window with setup wizard if we need to connect via OAuth.
705 + if (!block.has_access_token) {
706 + showConvertKitPopupWindow(
707 + props,
708 + url,
709 + setButtonDisabled
710 + );
711 + return;
712 + }
713 +
714 + // Allow the link to load, as it's likely a link to the Kit site.
715 + window.open(url, '_blank');
716 + },
346 717 },
718 + !block.has_access_token
719 + ? block.no_access_token.link_text
720 + : block.no_resources.link_text
721 + );
722 + };
347 723
348 - // Editor.
349 - edit: editBlock,
724 + /**
725 + * Returns a refresh button, used to refresh a block when it has no API Keys
726 + * or resources.
727 + *
728 + * @since 2.2.6
729 + *
730 + * @param {Object} props Block properties.
731 + * @param {boolean} buttonDisabled Whether the refresh button is disabled (true) or enabled (false)
732 + * @param {Function} setButtonDisabled Function to enable or disable the refresh button.
733 + * @return {Object} Button.
734 + */
735 + const refreshButton = function (
736 + props,
737 + buttonDisabled,
738 + setButtonDisabled
739 + ) {
740 + return el(Button, {
741 + key: props.clientId + '-refresh-button',
742 + className:
743 + 'wp-convertkit-refresh-resources' +
744 + (buttonDisabled ? ' is-refreshing' : ''),
745 + disabled: buttonDisabled,
746 + text: 'Refresh',
747 + icon: iconType('update'),
748 + variant: 'secondary',
749 + onClick() {
750 + // Refresh block definitions.
751 + refreshBlocksDefinitions(props, setButtonDisabled);
752 + },
753 + });
754 + };
350 755
351 - // Output.
352 - save: function( props ) {
756 + /**
757 + * Returns an inline refresh button, used to refresh a block's resources.
758 + *
759 + * @since 2.7.1
760 + *
761 + * @param {Object} props Block properties.
762 + * @return {Object} Button.
763 + */
764 + const inlineRefreshButton = function (props) {
765 + return el(BlockInlineRefreshButton, props);
766 + };
353 767
354 - // Deliberate; preview in the editor is determined by the return statement in `edit` above.
355 - // On the frontend site, the block's render() PHP class is always called, so we dynamically
356 - // fetch the content.
357 - return null;
768 + /**
769 + * Returns a refresh button.
770 + *
771 + * @since 2.7.1
772 + *
773 + * @param {Object} props Block properties.
774 + * @return {Object} Button.
775 + */
776 + const BlockInlineRefreshButton = function (props) {
777 + const [buttonDisabled, setButtonDisabled] = useState(false);
358 778
779 + return el(Button, {
780 + key: props.clientId + '-refresh-button',
781 + className:
782 + 'button button-secondary wp-convertkit-refresh-resources' +
783 + (buttonDisabled ? ' is-refreshing' : ''),
784 + disabled: buttonDisabled,
785 + icon: iconType('update'),
786 + onClick() {
787 + // Refresh block definitions.
788 + refreshBlocksDefinitions(props, setButtonDisabled);
359 789 },
790 + });
791 + };
792 +
793 + /**
794 + * Displays a new window with a given width and height to display the given URL.
795 + *
796 + * Typically used for displaying a modal version of the Setup Wizard, where the
797 + * user clicks the 'Click here to connect your ConvertKit account' link in a block, and then
798 + * enters their API Key and Secret. Will be used to show the ConvertKit
799 + * OAuth window in the future.
800 + *
801 + * @since 2.2.6
802 + *
803 + * @param {Object} props Block properties.
804 + * @param {string} url URL to display in the popup window.
805 + * @param {Function} setButtonDisabled Function to enable or disable the refresh button.
806 + */
807 + const showConvertKitPopupWindow = function (
808 + props,
809 + url,
810 + setButtonDisabled
811 + ) {
812 + // Define popup width, height and positioning.
813 + const width = 640,
814 + height = 750,
815 + top = (window.screen.height - height) / 2,
816 + left = (window.screen.width - width) / 2;
817 +
818 + // Open popup.
819 + const convertKitPopup = window.open(
820 + url + '&convertkit-modal=1',
821 + 'convertkit_popup_window',
822 + 'toolbar=no,location=no,status=no,menubar=no,scrollbars=yes,resizable=yes,width=' +
823 + width +
824 + ',height=' +
825 + height +
826 + ',top=' +
827 + top +
828 + ',left=' +
829 + left
830 + );
831 +
832 + // Center popup and focus.
833 + convertKitPopup.moveTo(left, top);
834 + convertKitPopup.focus();
835 +
836 + // Refresh the block when the popup is closed using self.close().
837 + // Won't fire if the user closes the popup manually, which is fine because that means
838 + // they didn't complete the steps, so refreshing wouldn't show anything new.
839 + // The onbeforeunload would seem suitable here, but it fires whenever the popup window's
840 + // document changes (e.g. as the user steps through a wizard), and doesn't fire when
841 + // the window is closed.
842 + // See https://stackoverflow.com/questions/9388380/capture-the-close-event-of-popup-window-in-javascript/48240128#48240128.
843 + const convertKitPopupTimer = setInterval(function () {
844 + if (convertKitPopup.closed) {
845 + clearInterval(convertKitPopupTimer);
846 +
847 + // Refresh block.
848 + refreshBlocksDefinitions(props, setButtonDisabled);
849 + }
850 + }, 1000);
851 + };
852 +
853 + /**
854 + * Refreshes this block's properties by:
855 + * - making an AJAX call to fetch all registered blocks via convertkit_get_blocks(),
856 + * - storing the registered blocks in the `convertkit_blocks` global object,
857 + * - updating this block's properties by updating the `block` object.
858 + *
859 + * @since 2.2.6
860 + *
861 + * @param {Object} props Block properties.
862 + * @param {Function} setButtonDisabled Function to enable or disable the refresh button.
863 + */
864 + const refreshBlocksDefinitions = function (props, setButtonDisabled) {
865 + // Disable the button.
866 + if (typeof setButtonDisabled !== 'undefined') {
867 + setButtonDisabled(true);
360 868 }
361 - );
362 869
363 - } (
870 + // Send AJAX request.
871 + fetch(convertkit_gutenberg.ajaxurl, {
872 + method: 'GET',
873 + headers: {
874 + 'Content-Type': 'application/json',
875 + 'X-WP-Nonce': convertkit_gutenberg.get_blocks_nonce,
876 + },
877 + })
878 + .then(function (response) {
879 + // Convert response JSON string to object.
880 + return response.json();
881 + })
882 + .then(function (response) {
883 + // If the response includes a code, show an error notice.
884 + if (typeof response.code !== 'undefined') {
885 + // Show an error in the Gutenberg editor.
886 + wp.data
887 + .dispatch('core/notices')
888 + .createErrorNotice('Kit: ' + response.message, {
889 + id: 'convertkit-error',
890 + });
891 +
892 + // Enable refresh button.
893 + if (typeof setButtonDisabled !== 'undefined') {
894 + setButtonDisabled(false);
895 + }
896 + return;
897 + }
898 +
899 + // Update global ConvertKit Blocks object, so that any updated resources
900 + // are reflected when adding new ConvertKit Blocks.
901 + convertkit_blocks = response;
902 +
903 + // Update this block's properties, so that has_access_token, has_resources
904 + // and the resources properties are updated.
905 + block = convertkit_blocks[block.name];
906 +
907 + // Call setAttributes on props to trigger the editBlock() function, which will re-render
908 + // the block, reflecting any changes to its properties.
909 + props.setAttributes({
910 + refresh: Date.now(),
911 + });
912 +
913 + // Enable refresh button.
914 + if (typeof setButtonDisabled !== 'undefined') {
915 + setButtonDisabled(false);
916 + }
917 + })
918 + .catch(function (error) {
919 + // Show an error in the Gutenberg editor.
920 + wp.data
921 + .dispatch('core/notices')
922 + .createErrorNotice('Kit: ' + error, {
923 + id: 'convertkit-error',
924 + });
925 +
926 + // Enable refresh button.
927 + if (typeof setButtonDisabled !== 'undefined') {
928 + setButtonDisabled(false);
929 + }
930 + });
931 + };
932 +
933 + // Register Block.
934 + registerBlockType('convertkit/' + block.name, {
935 + apiVersion: convertkit_gutenberg.block_api_version,
936 + title: block.title,
937 + description: block.description,
938 + category: block.category,
939 + icon: getIcon,
940 + keywords: block.keywords,
941 + attributes: block.attributes,
942 + supports: block.supports,
943 + example: {
944 + attributes: {
945 + is_gutenberg_example: true,
946 + },
947 + },
948 +
949 + // Editor.
950 + edit: EditBlock,
951 +
952 + // Output.
953 + save: saveBlock,
954 + });
955 + })(
364 956 window.wp.blocks,
365 957 window.wp.blockEditor,
366 958 window.wp.element,
367 959 window.wp.components
368 - ) );
960 + );
961 +}
369 962
963 +/**
964 + * Registers a Plugin Sidebar in Gutenberg.
965 + *
966 + * @since 3.3.0
967 + *
968 + * @param {Object} sidebar Plugin Sidebars
969 + */
970 +function convertKitGutenbergRegisterPluginSidebar(sidebar) {
971 + (function (plugins, editor, element, components, data) {
972 + // Define some constants for the various items we'll use.
973 + const el = element.createElement;
974 + const { registerPlugin } = plugins;
975 + const { PluginSidebar } = editor;
976 + const { useState } = element;
977 + const {
978 + Icon,
979 + TextControl,
980 + SelectControl,
981 + Flex,
982 + FlexItem,
983 + FlexBlock,
984 + PanelBody,
985 + PanelRow,
986 + Button,
987 + } = components;
988 + const { useSelect, useDispatch, select } = data;
989 +
990 + /**
991 + * Returns a PluginDocumentSettingPanel for this plugin, containing
992 + * post-level settings.
993 + *
994 + * @since 3.3.0
995 + */
996 + const RenderPanel = function () {
997 + const meta = useSelect(function (wpSelect) {
998 + return (
999 + wpSelect('core/editor').getEditedPostAttribute('meta') || {}
1000 + );
1001 + }, []);
1002 + const { editPost: wpEditPost } = useDispatch('core/editor');
1003 + const settings = meta[sidebar.meta_key] || sidebar.default_values;
1004 + const currentPostType = select('core/editor').getCurrentPostType();
1005 +
1006 + // Seed each field's `values` into component state, so that when a
1007 + // refresh button is clicked we can update the field's options
1008 + // and trigger a re-render without mutating the global
1009 + // convertkit_plugin_sidebars object.
1010 + const [fieldValues, setFieldValues] = useState(function () {
1011 + const initial = {};
1012 + for (const key in sidebar.fields) {
1013 + initial[key] = sidebar.fields[key].values;
1014 + }
1015 + return initial;
1016 + });
1017 +
1018 + /**
1019 + * Updates the Post meta meta_key object.
1020 + *
1021 + * @since 3.3.0
1022 + *
1023 + * @param {string} key Sub key within the meta_key object.
1024 + * @param {string} value Value to assign to the sub key.
1025 + */
1026 + const updateSetting = function (key, value) {
1027 + wpEditPost({
1028 + meta: {
1029 + [sidebar.meta_key]: Object.assign({}, settings, {
1030 + [key]: value,
1031 + }),
1032 + },
1033 + });
1034 + };
1035 +
1036 + /**
1037 + * Return a field element for the settings panel.
1038 + *
1039 + * @since 3.3.0
1040 + *
1041 + * @param {Object} field Field properties.
1042 + * @param {string} key Field name.
1043 + * @return {Object} Field element.
1044 + */
1045 + const getField = function (field, key) {
1046 + // Override field.values with the latest values from state, which
1047 + // may have been refreshed by clicking the refresh button.
1048 + field = Object.assign({}, field, {
1049 + values: fieldValues[key] || field.values,
1050 + });
1051 +
1052 + // Build the help element. Supports HTML in the description by
1053 + // rendering each line as a paragraph via element.RawHTML, which
1054 + // allows inline tags like <code> and <a> to render correctly.
1055 + let helpElement;
1056 + if (Array.isArray(field.description)) {
1057 + helpElement = el(
1058 + 'span',
1059 + {},
1060 + field.description.map(function (line, index) {
1061 + return el(
1062 + 'p',
1063 + {
1064 + key: 'help-' + index,
1065 + style: { margin: '0 0 0.5em 0' },
1066 + },
1067 + el(element.RawHTML, {}, line)
1068 + );
1069 + })
1070 + );
1071 + } else if (field.description) {
1072 + helpElement = el(element.RawHTML, {}, field.description);
1073 + }
1074 +
1075 + // Define some field properties shared across all field types.
1076 + const fieldProperties = {
1077 + key: 'convertkit_plugin_sidebar_' + key,
1078 + id: 'convertkit_plugin_sidebar_' + key,
1079 + label: field.label,
1080 + help: helpElement,
1081 + value: settings[key] || field.default_value || '',
1082 +
1083 + // Add __next40pxDefaultSize and __nextHasNoMarginBottom properties,
1084 + // preventing deprecation notices in the block editor and opt in to the new styles
1085 + // from 7.0.
1086 + __next40pxDefaultSize: true,
1087 + __nextHasNoMarginBottom: true,
1088 +
1089 + // Save Post Meta on value change.
1090 + onChange(value) {
1091 + updateSetting(key, value);
1092 + },
1093 + };
1094 +
1095 + // Define additional Field Properties and the Field Element,
1096 + // depending on the Field Type (select, textarea, text etc).
1097 + switch (field.type) {
1098 + case 'select':
1099 + // If the field has a resource_type, wrap the select in a
1100 + // Flex container alongside a refresh button.
1101 + if (field.resource_type) {
1102 + const selectFieldProperties = Object.assign(
1103 + {},
1104 + fieldProperties,
1105 + { help: undefined }
1106 + );
1107 +
1108 + return el(
1109 + 'div',
1110 + {
1111 + key:
1112 + 'convertkit_plugin_sidebar_' +
1113 + key +
1114 + '_wrapper',
1115 + },
1116 + el(
1117 + Flex,
1118 + {
1119 + align: 'end',
1120 + gap: 2,
1121 + },
1122 + [
1123 + el(
1124 + FlexBlock,
1125 + {
1126 + key: key + '-select',
1127 + },
1128 + getSelectField(
1129 + field,
1130 + selectFieldProperties
1131 + )
1132 + ),
1133 + el(
1134 + FlexItem,
1135 + {
1136 + key: key + '-refresh',
1137 + },
1138 + el(InlineRefreshButton, {
1139 + resource: field.resource_type,
1140 + fieldKey: key,
1141 + })
1142 + ),
1143 + ]
1144 + ),
1145 + fieldProperties.help
1146 + ? el(
1147 + 'p',
1148 + {
1149 + key: key + '-help',
1150 + className:
1151 + 'components-base-control__help',
1152 + },
1153 + fieldProperties.help
1154 + )
1155 + : null
1156 + );
1157 + }
1158 +
1159 + return getSelectField(field, fieldProperties);
1160 +
1161 + default:
1162 + // Return field element.
1163 + return el(TextControl, fieldProperties);
1164 + }
1165 + };
1166 +
1167 + /**
1168 + * Returns a select field element, with optgroups and options
1169 + * depending on the field's values.
1170 + *
1171 + * @since 3.3.1
1172 + *
1173 + * @param {Object} field Field properties.
1174 + * @param {Object} fieldProperties Field properties.
1175 + * @return {Object} Select field element.
1176 + */
1177 + const getSelectField = function (field, fieldProperties) {
1178 + // Check if any values are optgroups.
1179 + const hasOptgroups = Object.keys(field.values).some(
1180 + (subKey) =>
1181 + typeof field.values[subKey] === 'object' &&
1182 + field.values[subKey].label &&
1183 + field.values[subKey].values
1184 + );
1185 +
1186 + if (hasOptgroups) {
1187 + const children = [];
1188 + const seenValues = new Set();
1189 +
1190 + for (const value of Object.keys(field.values)) {
1191 + if (
1192 + typeof field.values[value] === 'object' &&
1193 + field.values[value].label &&
1194 + field.values[value].values
1195 + ) {
1196 + // Optgroup.
1197 + const groupChildren = [];
1198 + for (const groupValue of Object.keys(
1199 + field.values[value].values
1200 + )) {
1201 + seenValues.add(groupValue);
1202 + groupChildren.push(
1203 + el(
1204 + 'option',
1205 + {
1206 + value: groupValue,
1207 + key: groupValue,
1208 + },
1209 + field.values[value].values[groupValue]
1210 + )
1211 + );
1212 + }
1213 + children.push(
1214 + el(
1215 + 'optgroup',
1216 + {
1217 + label: field.values[value].label,
1218 + key: value,
1219 + },
1220 + ...groupChildren
1221 + )
1222 + );
1223 + } else {
1224 + // Option within optgroup.
1225 + seenValues.add(value);
1226 + children.push(
1227 + el(
1228 + 'option',
1229 + { value, key: value },
1230 + field.values[value]
1231 + )
1232 + );
1233 + }
1234 + }
1235 +
1236 + // If the saved value references a legacy form that isn't
1237 + // otherwise in the dropdown, append it so the current
1238 + // selection remains visible.
1239 + if (
1240 + fieldProperties.value &&
1241 + field.legacy_values &&
1242 + typeof field.legacy_values[fieldProperties.value] !==
1243 + 'undefined' &&
1244 + !seenValues.has(fieldProperties.value)
1245 + ) {
1246 + children.push(
1247 + el(
1248 + 'option',
1249 + {
1250 + value: fieldProperties.value,
1251 + key: fieldProperties.value,
1252 + },
1253 + field.legacy_values[fieldProperties.value]
1254 + )
1255 + );
1256 + }
1257 +
1258 + return el(SelectControl, fieldProperties, ...children);
1259 + }
1260 +
1261 + // Options only, no optgroups.
1262 + const fieldOptions = [];
1263 + for (const value of Object.keys(field.values)) {
1264 + fieldOptions.push({
1265 + label: field.values[value],
1266 + value,
1267 + });
1268 + }
1269 +
1270 + // If the saved value references a legacy form that isn't in
1271 + // field.values, append it so the current selection remains
1272 + // visible in the dropdown.
1273 + if (
1274 + fieldProperties.value &&
1275 + field.legacy_values &&
1276 + typeof field.legacy_values[fieldProperties.value] !==
1277 + 'undefined' &&
1278 + !fieldOptions.some(
1279 + (option) => option.value === fieldProperties.value
1280 + )
1281 + ) {
1282 + fieldOptions.push({
1283 + label: field.legacy_values[fieldProperties.value],
1284 + value: fieldProperties.value,
1285 + });
1286 + }
1287 +
1288 + // Sort options alphabetically by label.
1289 + fieldOptions.sort(function (x, y) {
1290 + const a = x.label.toUpperCase(),
1291 + b = y.label.toUpperCase();
1292 + return a.localeCompare(b);
1293 + });
1294 +
1295 + // Assign options to field properties.
1296 + fieldProperties.options = fieldOptions;
1297 +
1298 + // Return field element.
1299 + return el(SelectControl, fieldProperties);
1300 + };
1301 +
1302 + /**
1303 + * Returns a WordPress Icon element.
1304 + *
1305 + * @since 3.3.1
1306 + *
1307 + * @param {string} iconName Icon Name.
1308 + * @return {Object} Icon.
1309 + */
1310 + const iconType = function (iconName) {
1311 + return el(Icon, {
1312 + icon: iconName,
1313 + });
1314 + };
1315 +
1316 + /**
1317 + * Returns an inline refresh button, used to refresh a sidebar field's resources.
1318 + *
1319 + * @since 3.3.1
1320 + *
1321 + * @param {Object} props Component props.
1322 + * @param {string} props.resource Resource type (forms,tags,landing_pages,restrict_content).
1323 + * @param {string} props.fieldKey The sidebar field key whose values should be updated on refresh.
1324 + * @return {Object} Button.
1325 + */
1326 + const InlineRefreshButton = function ({ resource, fieldKey }) {
1327 + const [buttonDisabled, setButtonDisabled] = useState(false);
1328 +
1329 + return el(Button, {
1330 + key: fieldKey + '-refresh-button',
1331 + className:
1332 + 'button button-secondary wp-convertkit-refresh-resources' +
1333 + (buttonDisabled ? ' is-refreshing' : ''),
1334 + disabled: buttonDisabled,
1335 + icon: iconType('update'),
1336 + // `data-resource` is used by tests and as a stable hook
1337 + // matching the classic-editor refresh button.
1338 + 'data-resource': resource,
1339 + onClick() {
1340 + // Refresh resources.
1341 + refreshResources(resource, fieldKey, setButtonDisabled);
1342 + },
1343 + });
1344 + };
1345 +
1346 + /**
1347 + * Returns a label for a resource item. If the item has a `format`
1348 + * property (as forms do), append it in square brackets; otherwise
1349 + * return the name on its own.
1350 + *
1351 + * @since 3.3.1
1352 + *
1353 + * @param {Object} item API response item.
1354 + * @return {string} Label.
1355 + */
1356 + const labelForItem = function (item) {
1357 + // Detect whether this collection of items includes a `format`
1358 + // property anywhere in the item (legacy forms may omit it, in
1359 + // which case we fall back to 'inline').
1360 + if (Object.prototype.hasOwnProperty.call(item, 'format')) {
1361 + return (
1362 + item.name +
1363 + ' [' +
1364 + (item.format ? item.format : 'inline') +
1365 + ']'
1366 + );
1367 + }
1368 +
1369 + return item.name;
1370 + };
1371 +
1372 + /**
1373 + * Builds a flat values map from an array of API items, preserving
1374 + * any existing placeholder options (those whose value is a string,
1375 + * rather than an optgroup object) from the current field values.
1376 + *
1377 + * @since 3.3.1
1378 + *
1379 + * @param {Array} items API response items.
1380 + * @param {Object} existingValues Current values map for the field.
1381 + * @return {Object} Rebuilt values map.
1382 + */
1383 + const buildSelectValues = function (items, existingValues) {
1384 + const values = {};
1385 +
1386 + // Preserve existing placeholder options (Default, None, etc.)
1387 + // from the current values. Placeholders are identified by
1388 + // having a string value rather than an optgroup object.
1389 + for (const existingKey in existingValues) {
1390 + if (typeof existingValues[existingKey] === 'string') {
1391 + values[existingKey] = existingValues[existingKey];
1392 + }
1393 + }
1394 +
1395 + // Add the refreshed items.
1396 + items.forEach(function (item) {
1397 + values[item.id] = labelForItem(item);
1398 + });
1399 +
1400 + return values;
1401 + };
1402 +
1403 + /**
1404 + * Builds an optgroup-style values map from a response object whose
1405 + * keys are group names and whose values are arrays of items. Each
1406 + * option key within an optgroup is prefixed with the singularized
1407 + * group name (e.g. `forms` => `form_123`), matching the
1408 + * prefixing convention used on the PHP side for grouped fields.
1409 + *
1410 + * Preserves any existing top-level placeholder options from the
1411 + * current field values.
1412 + *
1413 + * @since 3.3.1
1414 + *
1415 + * @param {Object} groups API response keyed by group name.
1416 + * @param {Object} existingValues Current values map for the field.
1417 + * @return {Object} Rebuilt values map.
1418 + */
1419 + const buildSelectOptGroupValues = function (
1420 + groups,
1421 + existingValues
1422 + ) {
1423 + const values = {};
1424 +
1425 + // Preserve any top-level placeholder options (e.g. 'Do not restrict...').
1426 + for (const existingKey in existingValues) {
1427 + if (typeof existingValues[existingKey] !== 'object') {
1428 + values[existingKey] = existingValues[existingKey];
1429 + }
1430 + }
1431 +
1432 + // Build each optgroup from the response.
1433 + for (const optGroupKey in groups) {
1434 + // Skip if this optgroup doesn't have any options.
1435 + const items = groups[optGroupKey];
1436 + if (!Array.isArray(items) || items.length === 0) {
1437 + continue;
1438 + }
1439 +
1440 + // Derive the per-item key prefix from the group name
1441 + // (e.g. 'forms' => 'form_', 'tags' => 'tag_').
1442 + const itemKeyPrefix = optGroupKey.replace(/s$/, '') + '_';
1443 +
1444 + // Reuse the existing optgroup label if one exists, falling
1445 + // back to the capitalized group key if not.
1446 + const existingGroup = existingValues[optGroupKey];
1447 + const label =
1448 + existingGroup &&
1449 + typeof existingGroup === 'object' &&
1450 + existingGroup.label
1451 + ? existingGroup.label
1452 + : optGroupKey.charAt(0).toUpperCase() +
1453 + optGroupKey.slice(1);
1454 +
1455 + const groupValues = {};
1456 + items.forEach(function (item) {
1457 + groupValues[itemKeyPrefix + item.id] =
1458 + labelForItem(item);
1459 + });
1460 +
1461 + values[optGroupKey] = {
1462 + label,
1463 + values: groupValues,
1464 + };
1465 + }
1466 +
1467 + return values;
1468 + };
1469 +
1470 + /**
1471 + * Refreshes resources for the given resource type, updating
1472 + * the specified field's values on success so the SelectControl
1473 + * re-renders with the latest options.
1474 + *
1475 + * @since 3.3.1
1476 + *
1477 + * @param {string} resource Resource type, appended to the refresh URL.
1478 + * @param {string} fieldKey The sidebar field key whose values should be updated.
1479 + * @param {Function} setButtonDisabled Function to enable or disable the refresh button.
1480 + */
1481 + const refreshResources = function (
1482 + resource,
1483 + fieldKey,
1484 + setButtonDisabled
1485 + ) {
1486 + // Disable the button.
1487 + setButtonDisabled(true);
1488 +
1489 + // Send AJAX request.
1490 + fetch(convertkit_gutenberg.refresh_resources_url + resource, {
1491 + method: 'POST',
1492 + headers: {
1493 + 'Content-Type': 'application/json',
1494 + 'X-WP-Nonce':
1495 + convertkit_gutenberg.refresh_resources_nonce,
1496 + },
1497 + })
1498 + .then(function (response) {
1499 + // Convert response JSON string to object.
1500 + return response.json();
1501 + })
1502 + .then(function (response) {
1503 + if (convertkit_gutenberg.debug) {
1504 + console.log(response);
1505 + }
1506 +
1507 + // If the response includes a code, show an error notice.
1508 + if (typeof response.code !== 'undefined') {
1509 + // Show an error in the Gutenberg editor.
1510 + wp.data
1511 + .dispatch('core/notices')
1512 + .createErrorNotice('Kit: ' + response.message, {
1513 + id: 'convertkit-error',
1514 + });
1515 +
1516 + // Enable refresh button.
1517 + setButtonDisabled(false);
1518 + return;
1519 + }
1520 +
1521 + // Rebuild the field's values from the response, and
1522 + // update state so the SelectControl re-renders with
1523 + // the latest options.
1524 + // The response shape determines the values shape:
1525 + // an array produces a flat list; an object keyed by
1526 + // group name (e.g. { forms: [...], tags: [...] })
1527 + // produces optgroups.
1528 + setFieldValues(function (prev) {
1529 + const existing = prev[fieldKey] || {};
1530 + const values = Array.isArray(response)
1531 + ? buildSelectValues(response, existing)
1532 + : buildSelectOptGroupValues(response, existing);
1533 +
1534 + return Object.assign({}, prev, {
1535 + [fieldKey]: values,
1536 + });
1537 + });
1538 +
1539 + // Enable refresh button.
1540 + setButtonDisabled(false);
1541 + })
1542 + .catch(function (error) {
1543 + // Show an error in the Gutenberg editor.
1544 + wp.data
1545 + .dispatch('core/notices')
1546 + .createErrorNotice('Kit: ' + error, {
1547 + id: 'convertkit-error',
1548 + });
1549 +
1550 + // Enable refresh button.
1551 + setButtonDisabled(false);
1552 + });
1553 + };
1554 +
1555 + /**
1556 + * Return an array of field elements to display in the settings panel.
1557 + *
1558 + * @since 3.3.0
1559 + *
1560 + * @param {Object} fields Fields to display.
1561 + * @return {Array} Panel rows.
1562 + */
1563 + const getFields = function (fields) {
1564 + const rows = [];
1565 +
1566 + for (const key in fields) {
1567 + // Skip if the Post Type being edited is not the same as the Post Type specified in the field's post_type property.
1568 + if (
1569 + typeof fields[key].post_type !== 'undefined' &&
1570 + fields[key].post_type !== currentPostType
1571 + ) {
1572 + continue;
1573 + }
1574 +
1575 + rows.push(
1576 + el(
1577 + PanelRow,
1578 + {
1579 + key,
1580 + },
1581 + getField(fields[key], key)
1582 + )
1583 + );
1584 + }
1585 +
1586 + return el(PanelBody, {}, rows);
1587 + };
1588 +
1589 + // Return the settings sidebar panel with fields.
1590 + return el(
1591 + PluginSidebar,
1592 + {
1593 + name: sidebar.name,
1594 + title: sidebar.title,
1595 + className: sidebar.name,
1596 + icon: element.RawHTML({
1597 + children: sidebar.gutenberg_icon,
1598 + }),
1599 + },
1600 + getFields(sidebar.fields)
1601 + );
1602 + };
1603 +
1604 + // Register the plugin sidebar.
1605 + registerPlugin('convertkit-' + sidebar.name.replace(/_/g, '-'), {
1606 + render: RenderPanel,
1607 + });
1608 + })(
1609 + window.wp.plugins,
1610 + window.wp.editPost,
1611 + window.wp.element,
1612 + window.wp.components,
1613 + window.wp.data
1614 + );
370 1615 }
371 1616
372 1617 /**
1618 + * Registers pre-publish actions in Gutenberg's pre-publish checks panel.
1619 + *
1620 + * @since 2.4.0
1621 + *
1622 + * @param {Object} actions Pre-publish actions.
1623 + */
1624 +function convertKitGutenbergRegisterPrePublishActions(actions) {
1625 + (function (plugins, editPost, element, components, data) {
1626 + const el = element.createElement;
1627 + const { ToggleControl } = components;
1628 + const { registerPlugin } = plugins;
1629 + const { PluginPrePublishPanel } = editPost;
1630 + const { useSelect, useDispatch, select } = data;
1631 +
1632 + /**
1633 + * Returns a PluginPrePublishPanel for this plugin, containing all
1634 + * pre-publish actions.
1635 + *
1636 + * @since 2.4.0
1637 + * @return {WPElement|null} Pre-publish panel element or null if not a post.
1638 + */
1639 + const RenderPanel = function () {
1640 + // --- Hooks must be called first ---
1641 + const { meta } = useSelect((wpSelect) => ({
1642 + meta: wpSelect('core/editor').getEditedPostAttribute('meta'),
1643 + }));
1644 +
1645 + const { editPost: wpEditPost } = useDispatch('core/editor');
1646 +
1647 + const currentPostType = select('core/editor').getCurrentPostType();
1648 +
1649 + // Bail early if not a 'post'
1650 + if (currentPostType !== 'post') {
1651 + return null;
1652 + }
1653 +
1654 + // Build rows safely using .map()
1655 + const rows = Object.values(actions).map((action) => {
1656 + const key = '_convertkit_action_' + action.name;
1657 +
1658 + return el(ToggleControl, {
1659 + key,
1660 + id: 'convertkit_action_' + action.name,
1661 + label: action.label,
1662 + help: action.description,
1663 + value: true,
1664 + checked: meta[key],
1665 + onChange(value) {
1666 + wpEditPost({ meta: { [key]: value } });
1667 + },
1668 + });
1669 + });
1670 +
1671 + // Return the pre-publish panel with rows
1672 + return el(
1673 + PluginPrePublishPanel,
1674 + {
1675 + className: 'convertkit-pre-publish-actions',
1676 + title: 'Kit',
1677 + initialOpen: true,
1678 + },
1679 + rows
1680 + );
1681 + };
1682 +
1683 + // Register pre-publish actions
1684 + registerPlugin('convertkit-pre-publish-actions', {
1685 + render: RenderPanel,
1686 + });
1687 + })(
1688 + window.wp.plugins,
1689 + window.wp.editPost,
1690 + window.wp.element,
1691 + window.wp.components,
1692 + window.wp.data
1693 + );
1694 +}
1695 +
1696 +/**
373 1697 * Outputs a notice for the block. Typically used when a block's settings
374 1698 * have not been defined, no API key exists in the Plugin or no resources
375 1699 * (forms, products) exist in ConvertKit, and the user adds an e.g.
376 1700 * Form / Product block.
@@ -376,59 +1700,45 @@
376 1700 * Form / Product block.
377 1701 *
378 1702 * @since 2.2.3
379 1703 *
380 - * @param string block_name Block Name.
381 - * @param string notice Notice to display.
382 - * @return object HTMLElement
1704 + * @param {string} block_name Block Name.
1705 + * @param {string} notice Notice to display.
1706 + * @return {Object} HTMLElement
383 1707 */
384 -function convertKitGutenbergDisplayBlockNotice( block_name, notice ) {
385 -
1708 +function convertKitGutenbergDisplayBlockNotice(block_name, notice) {
386 1709 return wp.element.createElement(
387 1710 'div',
388 1711 {
389 1712 // convertkit-no-content class allows resources/backend/css/gutenberg.css
390 1713 // to apply styling/branding to the block.
391 - className: 'convertkit-' + block_name + ' convertkit-no-content'
1714 + className: 'convertkit-' + block_name + ' convertkit-no-content',
392 1715 },
393 1716 notice
394 1717 );
1718 +}
395 1719
1720 +/**
1721 + * Checks if the user is editing a post in the block editor.
1722 + *
1723 + * @since 3.0.8
1724 + *
1725 + * @return {boolean} User is editing in the block editor
1726 + */
1727 +function convertKitEditingPostInGutenberg() {
1728 + // If the user is editing a post in the block editor, wp.editPost will be defined.
1729 + return typeof wp !== 'undefined' && typeof wp.editPost !== 'undefined';
396 1730 }
397 1731
398 1732 /**
399 - * Outputs a notice for the block with a clickable link. Typically used when a block's settings
400 - * have not been defined, no API key exists in the Plugin or no resources
401 - * (forms, products) exist in ConvertKit, and the user adds an e.g.
402 - * Form / Product block.
1733 + * Checks if the Gutenberg editor is loaded on screen.
403 1734 *
404 - * @since 2.2.3
1735 + * Returns true when editing a Post, Page or Custom Post Type in the block editor,
1736 + * or using the site editor.
405 1737 *
406 - * @param string block_name Block Name.
407 - * @param string notice Notice to display.
408 - * @param string link URL.
409 - * @param string link_text Link text for URL.
410 - * @return object HTMLElement
1738 + * @since 3.0.8
1739 + *
1740 + * @return {boolean} Block editor is loaded
411 1741 */
412 -function convertKitGutenbergDisplayBlockNoticeWithLink( block_name, notice, link, link_text ) {
413 -
414 - return wp.element.createElement(
415 - 'div',
416 - {
417 - // convertkit-no-content class allows resources/backend/css/gutenberg.css
418 - // to apply styling/branding to the block.
419 - className: 'convertkit-' + block_name + ' convertkit-no-content'
420 - },
421 - [
422 - notice + ' ',
423 - wp.element.createElement(
424 - 'a',
425 - {
426 - href: link,
427 - target: '_blank'
428 - },
429 - link_text
430 - )
431 - ]
432 - );
433 -
1742 +function convertKitGutenbergEnabled() {
1743 + return typeof wp !== 'undefined' && typeof wp.blockEditor !== 'undefined';
434 1744 }