PluginProbe
Kit (formerly ConvertKit) – Email Newsletter, Email Marketing, Membership, Subscribers and Landing Pages / 3.4.4
Kit (formerly ConvertKit) – Email Newsletter, Email Marketing, Membership, Subscribers and Landing Pages v3.4.4
3.4.4 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 All 197 releases
← All changes | resources/backend/js/gutenberg.js +1517 -216 2.2.5 → 3.4.4 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,24 +364,23 @@
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;
251 -
252 375 // Build Inspector Control Panels.
253 - for ( const panel in block.panels ) {
254 - let panelRows = getPanelRows( props, panel );
376 + for (const panel in block.panels) {
377 + const panelRows = getPanelRows(props, panel);
255 378
256 379 // If no panel rows exist (e.g. this is a shortcode only panel,
257 380 // for styles, which Gutenberg registers in its own styles tab),
258 381 // don't add this panel.
259 - if ( ! panelRows.length ) {
382 + if (!panelRows.length) {
260 383 continue;
261 384 }
262 385
263 386 panels.push(
@@ -263,11 +386,11 @@
263 386 panels.push(
264 387 el(
265 388 PanelBody,
266 389 {
267 - title: block.panels[ panel ].label,
390 + title: block.panels[panel].label,
268 391 key: panel,
269 - initialOpen: initialOpen
392 + initialOpen,
270 393 },
271 394 panelRows
272 395 )
273 396 );
@@ -276,11 +399,10 @@
276 399 initialOpen = false;
277 400 }
278 401
279 402 return panels;
403 + };
280 404
281 - }
282 -
283 405 /**
284 406 * Display settings sidebar when the block is being edited, and save
285 407 * changes that are made.
286 408 *
@@ -285,101 +407,1294 @@
285 407 * changes that are made.
286 408 *
287 409 * @since 2.2.0
288 410 *
289 - * @param object props Block properties.
290 - * @return object Block settings sidebar elements
411 + * @param {Object} props Block properties.
412 + * @return {Object} Block settings sidebar elements.
291 413 */
292 - const editBlock = function( props ) {
414 + const EditBlock = function (props) {
415 + const blockProps = useBlockProps();
293 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 +
294 422 // If requesting an example of how this block looks (which is requested
295 423 // when the user adds a new block and hovers over this block's icon),
296 424 // show the preview image.
297 - if ( props.attributes.is_gutenberg_example === true ) {
298 - return (
299 - Fragment,
300 - {},
301 - el(
302 - 'img',
303 - {
304 - src: block.gutenberg_example_image,
305 - }
306 - )
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 + })
307 432 );
308 433 }
309 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 +
310 446 // Build Inspector Control Panels, which will appear in the Sidebar when editing the Block.
311 - let panels = getPanels( props );
447 + const panels = getPanels(props);
312 448
313 449 // Generate Block Preview.
314 450 let preview = '';
315 - if ( typeof block.gutenberg_preview_render_callback !== 'undefined' ) {
316 - // Use a custom callback function to render this block's preview in the Gutenberg Editor.
317 - // This doesn't affect the output for this block on the frontend site, which will always
318 - // use the block's PHP's render() function.
319 - 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 + );
320 468 }
321 469
322 - // Return settings sidebar panel with fields and the bloc preview.
323 - return (
324 - el(
325 - // Sidebar Panel with Fields.
326 - 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',
327 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 = [
328 607 el(
329 - InspectorControls,
330 - {},
331 - 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
332 615 ),
333 - // Block Preview.
334 - 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
335 636 )
336 637 );
638 + };
337 639
338 - }
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 + }
339 656
340 - // Register Block.
341 - registerBlockType(
342 - 'convertkit/' + block.name,
343 - {
344 - title: block.title,
345 - description:block.description,
346 - category: block.category,
347 - icon: getIcon,
348 - keywords: block.keywords,
349 - attributes: block.attributes,
350 - supports: block.supports,
351 - example: {
352 - attributes: {
353 - is_gutenberg_example: true,
354 - }
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 + },
355 717 },
718 + !block.has_access_token
719 + ? block.no_access_token.link_text
720 + : block.no_resources.link_text
721 + );
722 + };
356 723
357 - // Editor.
358 - 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 + };
359 755
360 - // Output.
361 - 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 + };
362 767
363 - // Deliberate; preview in the editor is determined by the return statement in `edit` above.
364 - // On the frontend site, the block's render() PHP class is always called, so we dynamically
365 - // fetch the content.
366 - 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);
367 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);
368 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);
369 868 }
370 - );
371 869
372 - } (
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 + })(
373 956 window.wp.blocks,
374 957 window.wp.blockEditor,
375 958 window.wp.element,
376 959 window.wp.components
377 - ) );
960 + );
961 +}
378 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 + );
379 1615 }
380 1616
381 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 +/**
382 1697 * Outputs a notice for the block. Typically used when a block's settings
383 1698 * have not been defined, no API key exists in the Plugin or no resources
384 1699 * (forms, products) exist in ConvertKit, and the user adds an e.g.
385 1700 * Form / Product block.
@@ -385,59 +1700,45 @@
385 1700 * Form / Product block.
386 1701 *
387 1702 * @since 2.2.3
388 1703 *
389 - * @param string block_name Block Name.
390 - * @param string notice Notice to display.
391 - * @return object HTMLElement
1704 + * @param {string} block_name Block Name.
1705 + * @param {string} notice Notice to display.
1706 + * @return {Object} HTMLElement
392 1707 */
393 -function convertKitGutenbergDisplayBlockNotice( block_name, notice ) {
394 -
1708 +function convertKitGutenbergDisplayBlockNotice(block_name, notice) {
395 1709 return wp.element.createElement(
396 1710 'div',
397 1711 {
398 1712 // convertkit-no-content class allows resources/backend/css/gutenberg.css
399 1713 // to apply styling/branding to the block.
400 - className: 'convertkit-' + block_name + ' convertkit-no-content'
1714 + className: 'convertkit-' + block_name + ' convertkit-no-content',
401 1715 },
402 1716 notice
403 1717 );
1718 +}
404 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';
405 1730 }
406 1731
407 1732 /**
408 - * Outputs a notice for the block with a clickable link. Typically used when a block's settings
409 - * have not been defined, no API key exists in the Plugin or no resources
410 - * (forms, products) exist in ConvertKit, and the user adds an e.g.
411 - * Form / Product block.
1733 + * Checks if the Gutenberg editor is loaded on screen.
412 1734 *
413 - * @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.
414 1737 *
415 - * @param string block_name Block Name.
416 - * @param string notice Notice to display.
417 - * @param string link URL.
418 - * @param string link_text Link text for URL.
419 - * @return object HTMLElement
1738 + * @since 3.0.8
1739 + *
1740 + * @return {boolean} Block editor is loaded
420 1741 */
421 -function convertKitGutenbergDisplayBlockNoticeWithLink( block_name, notice, link, link_text ) {
422 -
423 - return wp.element.createElement(
424 - 'div',
425 - {
426 - // convertkit-no-content class allows resources/backend/css/gutenberg.css
427 - // to apply styling/branding to the block.
428 - className: 'convertkit-' + block_name + ' convertkit-no-content'
429 - },
430 - [
431 - notice + ' ',
432 - wp.element.createElement(
433 - 'a',
434 - {
435 - href: link,
436 - target: '_blank'
437 - },
438 - link_text
439 - )
440 - ]
441 - );
442 -
1742 +function convertKitGutenbergEnabled() {
1743 + return typeof wp !== 'undefined' && typeof wp.blockEditor !== 'undefined';
443 1744 }