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 +1399 -360 2.3.03.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,32 +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;
53 + const { InspectorControls, InnerBlocks, useBlockProps } = editor;
54 + const { useState } = element;
38 55 const {
39 - Fragment,
40 - useState
41 - } = element;
42 - const {
43 56 Button,
44 - Dashicon,
57 + Icon,
45 58 TextControl,
46 59 SelectControl,
47 60 ToggleControl,
48 - Panel,
61 + Flex,
62 + FlexItem,
49 63 PanelBody,
50 - PanelRow
51 - } = components;
64 + PanelRow,
65 + ProgressBar,
66 + } = components;
52 67
53 68 /**
54 69 * Returns the icon to display for this block, depending
55 70 * on the supplied block's configuration.
@@ -55,31 +70,27 @@
55 70 * on the supplied block's configuration.
56 71 *
57 72 * @since 2.2.0
58 73 *
59 - * @return element|string
74 + * @return {WPElement|string} Either a WordPress element (RawHTML) or a dashicon string.
60 75 */
61 76 const getIcon = function () {
62 -
63 77 // Return a fallback default icon if none is specified for this block.
64 - if ( typeof block.gutenberg_icon === 'undefined' ) {
78 + if (typeof block.gutenberg_icon === 'undefined') {
65 79 return 'dashicons-tablet';
66 80 }
67 81
68 82 // Return HTML element if the icon is an SVG string.
69 - if ( block.gutenberg_icon.search( 'svg' ) >= 0 ) {
70 - return element.RawHTML(
71 - {
72 - children: block.gutenberg_icon
73 - }
74 - );
83 + if (block.gutenberg_icon.search('svg') >= 0) {
84 + return element.RawHTML({
85 + children: block.gutenberg_icon,
86 + });
75 87 }
76 88
77 89 // Just return the string, as it's a dashicon CSS class.
78 90 return block.gutenberg_icon;
91 + };
79 92
80 - }
81 -
82 93 /**
83 94 * Return a field element for the block sidebar, which is displayed in a panel's row
84 95 * when this block is being edited.
85 96 *
@@ -84,123 +95,233 @@
84 95 * when this block is being edited.
85 96 *
86 97 * @since 2.2.0
87 98 *
88 - * @param object props Block properties.
89 - * @param object field Field attributes.
90 - * @param string attribute Attribute name to store the field's data in.
91 - * @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.
92 103 */
93 - 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;
94 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 +
95 146 // Define some field properties shared across all field types.
96 - let fieldProperties = {
97 - id: 'convertkit_' + block.name + '_' + attribute,
98 - label: field.label,
99 - help: field.description,
100 - value: props.attributes[ attribute ],
101 - onChange: function ( value ) {
102 - 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') {
103 165 // If value is a blank string i.e. no attribute value was provided,
104 166 // cast it to the field's minimum number setting.
105 167 // This prevents WordPress' block renderer API returning a 400 error
106 168 // because a blank value will be passed as a string, when WordPress
107 169 // expects it to be a numerical value.
108 - if ( value === '' ) {
170 + if (value === '') {
109 171 value = field.min;
110 172 }
111 173
112 174 // Cast value to integer if a value exists.
113 - if ( value.length > 0 ) {
114 - value = Number( value );
175 + if (value.length > 0) {
176 + value = Number(value);
115 177 }
116 178 }
117 179
118 - let newValue = {};
119 - newValue[ attribute ] = value;
120 - props.setAttributes( newValue );
121 - }
180 + const newValue = {};
181 + newValue[attribute] = value;
182 + props.setAttributes(newValue);
183 + },
122 184 };
123 185
186 + const fieldOptions = [];
187 +
124 188 // Define additional Field Properties and the Field Element,
125 189 // depending on the Field Type (select, textarea, text etc).
126 - switch ( field.type ) {
127 -
190 + switch (field.type) {
128 191 case 'select':
129 192 // Build options for <select> input.
130 - let fieldOptions = [];
131 - fieldOptions.push(
132 - {
133 - label: '(None)',
134 - value: '',
135 - }
136 - );
137 - for ( let value in field.values ) {
138 - fieldOptions.push(
139 - {
140 - label: field.values[ value ],
141 - value: value
142 - }
143 - );
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 + });
144 202 }
145 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 +
146 223 // Sort field's options alphabetically by label.
147 - fieldOptions.sort(
148 - 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 + });
149 229
150 - let a = x.label.toUpperCase(),
151 - b = y.label.toUpperCase();
152 - return a.localeCompare( b );
230 + // Assign options to field.
231 + fieldProperties.options = fieldOptions;
153 232
154 - }
155 - );
233 + // Return field element.
234 + return el(SelectControl, fieldProperties);
156 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 +
157 275 // Assign options to field.
158 276 fieldProperties.options = fieldOptions;
159 277
160 - // Return field element.
161 278 return el(
162 - SelectControl,
163 - 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 + ]
164 299 );
165 - break;
166 300
167 301 case 'toggle':
168 302 // Define field properties.
169 - fieldProperties.checked = props.attributes[ attribute ];
303 + fieldProperties.checked = props.attributes[attribute];
170 304
171 305 // Return field element.
172 - return el(
173 - ToggleControl,
174 - fieldProperties
175 - );
176 - break;
306 + return el(ToggleControl, fieldProperties);
177 307
178 308 case 'number':
179 309 // Define field properties.
180 310 fieldProperties.type = field.type;
181 - fieldProperties.min = field.min;
182 - fieldProperties.max = field.max;
311 + fieldProperties.min = field.min;
312 + fieldProperties.max = field.max;
183 313 fieldProperties.step = field.step;
184 314
185 315 // Return field element.
186 - return el(
187 - TextControl,
188 - fieldProperties
189 - );
190 - break;
316 + return el(TextControl, fieldProperties);
191 317
192 318 default:
193 319 // Return field element.
194 - return el(
195 - TextControl,
196 - fieldProperties
197 - );
198 - break;
320 + return el(TextControl, fieldProperties);
199 321 }
322 + };
200 323
201 - }
202 -
203 324 /**
204 325 * Return an array of rows to display in the given block sidebar's panel when
205 326 * this block is being edited.
206 327 *
@@ -205,24 +326,23 @@
205 326 * this block is being edited.
206 327 *
207 328 * @since 2.2.0
208 329 *
209 - * @param object props Block properties.
210 - * @param string panel Panel name.
211 - * @return array Panel rows
330 + * @param {Object} props Block properties.
331 + * @param {string} panel Panel name.
332 + * @return {Array} Panel rows.
212 333 */
213 - const getPanelRows = function ( props, panel ) {
214 -
334 + const getPanelRows = function (props, panel) {
215 335 // Build Inspector Control Panel Rows, one for each Field.
216 - let rows = [];
217 - for ( let i in block.panels[ panel ].fields ) {
218 - const attribute = block.panels[ panel ].fields[ i ], // e.g. 'term'.
219 - 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.
220 340
221 341 // If this field doesn't exist as an attribute in the block's get_attributes(),
222 342 // this is a non-Gutenberg field (such as a color picker for shortcodes),
223 343 // which should be ignored.
224 - if ( typeof block.attributes[ attribute ] === 'undefined' ) {
344 + if (typeof block.attributes[attribute] === 'undefined') {
225 345 continue;
226 346 }
227 347
228 348 rows.push(
@@ -228,19 +348,18 @@
228 348 rows.push(
229 349 el(
230 350 PanelRow,
231 351 {
232 - key: attribute
352 + key: attribute,
233 353 },
234 - getField( props, field, attribute )
354 + getField(props, field, attribute)
235 355 )
236 356 );
237 357 }
238 358
239 359 return rows;
360 + };
240 361
241 - }
242 -
243 362 /**
244 363 * Return an array of panels to display in the block's sidebar when the block
245 364 * is being edited.
246 365 *
@@ -245,24 +364,23 @@
245 364 * is being edited.
246 365 *
247 366 * @since 2.2.0
248 367 *
249 - * @param object props Block formatter properties.
250 - * @return array Block sidebar panels.
368 + * @param {Object} props Block formatter properties.
369 + * @return {Array} Block sidebar panels.
251 370 */
252 - const getPanels = function ( props ) {
371 + const getPanels = function (props) {
372 + const panels = [];
373 + let initialOpen = true;
253 374
254 - let panels = [],
255 - initialOpen = true;
256 -
257 375 // Build Inspector Control Panels.
258 - for ( const panel in block.panels ) {
259 - let panelRows = getPanelRows( props, panel );
376 + for (const panel in block.panels) {
377 + const panelRows = getPanelRows(props, panel);
260 378
261 379 // If no panel rows exist (e.g. this is a shortcode only panel,
262 380 // for styles, which Gutenberg registers in its own styles tab),
263 381 // don't add this panel.
264 - if ( ! panelRows.length ) {
382 + if (!panelRows.length) {
265 383 continue;
266 384 }
267 385
268 386 panels.push(
@@ -268,11 +386,11 @@
268 386 panels.push(
269 387 el(
270 388 PanelBody,
271 389 {
272 - title: block.panels[ panel ].label,
390 + title: block.panels[panel].label,
273 391 key: panel,
274 - initialOpen: initialOpen
392 + initialOpen,
275 393 },
276 394 panelRows
277 395 )
278 396 );
@@ -281,11 +399,10 @@
281 399 initialOpen = false;
282 400 }
283 401
284 402 return panels;
403 + };
285 404
286 - }
287 -
288 405 /**
289 406 * Display settings sidebar when the block is being edited, and save
290 407 * changes that are made.
291 408 *
@@ -290,67 +407,173 @@
290 407 * changes that are made.
291 408 *
292 409 * @since 2.2.0
293 410 *
294 - * @param object props Block properties.
295 - * @return object Block settings sidebar elements
411 + * @param {Object} props Block properties.
412 + * @return {Object} Block settings sidebar elements.
296 413 */
297 - const editBlock = function ( props ) {
414 + const EditBlock = function (props) {
415 + const blockProps = useBlockProps();
298 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 +
299 422 // If requesting an example of how this block looks (which is requested
300 423 // when the user adds a new block and hovers over this block's icon),
301 424 // show the preview image.
302 - if ( props.attributes.is_gutenberg_example === true ) {
303 - return (
304 - Fragment,
305 - {},
306 - el(
307 - 'img',
308 - {
309 - src: block.gutenberg_example_image,
310 - }
311 - )
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 + })
312 432 );
313 433 }
314 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 +
315 446 // Build Inspector Control Panels, which will appear in the Sidebar when editing the Block.
316 - let panels = getPanels( props );
447 + const panels = getPanels(props);
317 448
318 449 // Generate Block Preview.
319 450 let preview = '';
320 451
321 - // If no API Key has been defined in the Plugin, or no resources exist in ConvertKit
322 - // for this block, show a message in the block to tell the user what to do.
323 - if ( ! block.has_api_key || ! block.has_resources ) {
324 - return displayNoticeWithLink( props );
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 + );
325 468 }
326 469
327 - if ( typeof block.gutenberg_preview_render_callback !== 'undefined' ) {
328 - // Use a custom callback function to render this block's preview in the Gutenberg Editor.
329 - // This doesn't affect the output for this block on the frontend site, which will always
330 - // use the block's PHP's render() function.
331 - preview = window[ block.gutenberg_preview_render_callback ]( block, props );
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 + );
332 487 }
333 488
334 - // Return settings sidebar panel with fields and the block preview.
335 - return (
336 - el(
337 - // Sidebar Panel with Fields.
338 - Fragment,
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',
339 508 {},
340 - el(
341 - InspectorControls,
342 - {},
343 - panels
344 - ),
345 - // Block Preview.
346 - preview
347 - )
348 - );
509 + el(InnerBlocks, {
510 + template,
511 + })
512 + );
513 + return editBlockWithPanelsAndPreview(
514 + panels,
515 + preview,
516 + blockProps
517 + );
518 + }
349 519
350 - }
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,
351 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 +
352 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 + /**
353 576 * Display a notice in the block with a clickable link to perform an action, and a refresh
354 577 * button to trigger editBlock(). Typically used when no API key exists in the Plugin,
355 578 * or no resources (forms, products) exist in ConvertKit.
356 579 *
@@ -355,32 +578,44 @@
355 578 * or no resources (forms, products) exist in ConvertKit.
356 579 *
357 580 * @since 2.2.5
358 581 *
359 - * @param object props Block properties.
360 - * @return object Notice.
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.
361 587 */
362 - const displayNoticeWithLink = function ( props ) {
363 -
364 - // useState to toggle the refresh button's disabled state.
365 - const [ buttonDisabled, setButtonDisabled ] = useState( false );
366 -
588 + const DisplayNoticeWithLink = function (
589 + props,
590 + blockProps,
591 + buttonDisabled,
592 + setButtonDisabled
593 + ) {
367 594 // Holds the array of elements to display in the notice component.
368 595 let elements;
369 596
370 597 // Define elements to display, based on whether the refresh button is disabled.
371 - if ( buttonDisabled ) {
372 - // Refresh button disabled; display a spinner and the button.
598 + if (buttonDisabled) {
599 + // Refresh button disabled; display a loading indicator and the button.
373 600 elements = [
374 - spinner( props ),
375 - refreshButton( props, buttonDisabled, setButtonDisabled )
601 + loadingIndicator(props),
602 + refreshButton(props, buttonDisabled, setButtonDisabled),
376 603 ];
377 604 } else {
378 605 // Refresh button enabled; display the notice, link and button.
379 606 elements = [
380 - ( ! block.has_api_key ? block.no_api_key.notice : block.no_resources.notice ),
381 - noticeLink( props, setButtonDisabled ),
382 - refreshButton( props, buttonDisabled, setButtonDisabled )
607 + el(
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
615 + ),
616 + noticeLink(props, setButtonDisabled),
617 + refreshButton(props, buttonDisabled, setButtonDisabled),
383 618 ];
384 619 }
385 620
386 621 // Return the element.
@@ -385,91 +620,108 @@
385 620
386 621 // Return the element.
387 622 return el(
388 623 'div',
389 - {
390 - // convertkit-no-content class allows resources/backend/css/gutenberg.css
391 - // to apply styling/branding to the block.
392 - className: 'convertkit-' + block.name + ' convertkit-no-content'
393 - },
394 - elements
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
636 + )
395 637 );
638 + };
396 639
397 - }
398 -
399 640 /**
400 - * Returns a spinner element, to show that a block is loading / refreshing.
641 + * Returns an indeterminate progress bar element, to show that a block is loading / refreshing.
401 642 *
402 643 * @since 2.2.6
403 644 *
404 - * @param object props Block properties.
405 - * @return object Spinner.
645 + * @param {Object} props Block properties.
646 + * @return {Object} Progress Bar.
406 647 */
407 - const spinner = function ( props ) {
408 -
409 - return el(
410 - 'span',
411 - {
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', {
412 652 key: props.clientId + '-spinner',
413 - className: 'spinner is-active'
414 - }
415 - );
653 + className: 'spinner is-active convertkit-block-refreshing',
654 + });
655 + }
416 656
417 - }
657 + return el(ProgressBar, {
658 + key: props.clientId + '-progress-bar',
659 + className:
660 + 'convertkit-progress-bar convertkit-block-refreshing',
661 + });
662 + };
418 663
419 664 /**
420 - * Returns a WordPress Dashicon element.
665 + * Returns a WordPress Icon element.
421 666 *
422 - * @since 2.2.6
667 + * @since 2.7.7
423 668 *
424 - * @param string iconName Dashicon Name.
425 - * @return object Dashicon.
669 + * @param {string} iconName Icon Name.
670 + * @return {Object} Icon.
426 671 */
427 - const dashIcon = function ( iconName ) {
672 + const iconType = function (iconName) {
673 + return el(Icon, {
674 + icon: iconName,
675 + });
676 + };
428 677
429 - return el(
430 - Dashicon,
431 - {
432 - icon: iconName
433 - }
434 - );
435 -
436 - }
437 -
438 678 /**
439 - * Returns the notice link for the displayNoticeWithLink element.
679 + * Returns the notice link for the DisplayNoticeWithLink element.
440 680 *
441 681 * @since 2.2.6
442 682 *
443 - * @param object props Block properties.
444 - * @param object setButtonDisabled Function to enable or disable the refresh button.
445 - * @return object Notice Link.
683 + * @param {Object} props Block properties.
684 + * @param {Function} setButtonDisabled Function to enable or disable the refresh button.
685 + * @return {Object} Notice Link.
446 686 */
447 - const noticeLink = function ( props, setButtonDisabled ) {
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;
448 692
449 693 return el(
450 - 'a',
694 + Button,
451 695 {
452 696 key: props.clientId + '-notice-link',
453 - href: ( ! block.has_api_key ? block.no_api_key.link : block.no_resources.link ),
454 - className: ( ! block.has_api_key ? 'convertkit-block-modal' : '' ),
455 - target: '_blank',
456 - onClick: function ( e ) {
697 + className: !block.has_access_token
698 + ? 'convertkit-block-modal'
699 + : '',
700 + variant: 'link',
701 + onClick(e) {
702 + e.preventDefault();
457 703
458 - // Show popup window with setup wizard if we need to define an API Key.
459 - if ( ! block.has_api_key ) {
460 - e.preventDefault();
461 - showConvertKitPopupWindow( props, e.target, setButtonDisabled );
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;
462 712 }
463 713
464 - // Allow the link to load, as it's likely a link to the ConvertKit site.
465 - }
714 + // Allow the link to load, as it's likely a link to the Kit site.
715 + window.open(url, '_blank');
716 + },
466 717 },
467 - ( ! block.has_api_key ? block.no_api_key.link_text : block.no_resources.link_text )
718 + !block.has_access_token
719 + ? block.no_access_token.link_text
720 + : block.no_resources.link_text
468 721 );
722 + };
469 723
470 - }
471 -
472 724 /**
473 725 * Returns a refresh button, used to refresh a block when it has no API Keys
474 726 * or resources.
475 727 *
@@ -474,65 +726,112 @@
474 726 * or resources.
475 727 *
476 728 * @since 2.2.6
477 729 *
478 - * @param object props Block properties.
479 - * @param bool buttonDisabled Whether the refresh button is disabled (true) or enabled (false)/
480 - * @param object setButtonDisabled Function to enable or disable the refresh button.
481 - * @return object Button.
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.
482 734 */
483 - const refreshButton = function ( props, buttonDisabled, setButtonDisabled ) {
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 + };
484 755
485 - return el(
486 - Button,
487 - {
488 - key: props.clientId + '-refresh-button',
489 - className: 'button button-secondary convertkit-block-refresh',
490 - disabled: buttonDisabled,
491 - text: 'Refresh',
492 - icon: dashIcon( 'update' ),
493 - onClick: function () {
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 + };
494 767
495 - // Refresh block definitions.
496 - refreshBlocksDefinitions( props, setButtonDisabled );
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);
497 778
498 - }
499 - }
500 - )
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);
789 + },
790 + });
791 + };
501 792
502 - }
503 -
504 793 /**
505 794 * Displays a new window with a given width and height to display the given URL.
506 795 *
507 796 * Typically used for displaying a modal version of the Setup Wizard, where the
508 - * user clicks the 'click here to add your API Key' link in a block, and then
797 + * user clicks the 'Click here to connect your ConvertKit account' link in a block, and then
509 798 * enters their API Key and Secret. Will be used to show the ConvertKit
510 - * oAuth window in the future.
799 + * OAuth window in the future.
511 800 *
512 801 * @since 2.2.6
513 802 *
514 - * @param object props Block properties.
515 - * @param object link Link that was clicked.
516 - * @param object setButtonDisabled Function to enable or disable the refresh button.
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.
517 806 */
518 - const showConvertKitPopupWindow = function ( props, link, setButtonDisabled ) {
519 -
807 + const showConvertKitPopupWindow = function (
808 + props,
809 + url,
810 + setButtonDisabled
811 + ) {
520 812 // Define popup width, height and positioning.
521 - const width = 640,
522 - height = 520,
523 - top = ( window.screen.height - height ) / 2,
524 - left = ( window.screen.width - width ) / 2;
813 + const width = 640,
814 + height = 750,
815 + top = (window.screen.height - height) / 2,
816 + left = (window.screen.width - width) / 2;
525 817
526 818 // Open popup.
527 819 const convertKitPopup = window.open(
528 - link.href + '&convertkit-modal=1',
820 + url + '&convertkit-modal=1',
529 821 'convertkit_popup_window',
530 - 'toolbar=no,location=no,status=no,menubar=no,scrollbars=yes,resizable=yes,width=' + width + ',height=' + height + ',top=' + top + ',left=' + left
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
531 830 );
532 831
533 832 // Center popup and focus.
534 - convertKitPopup.moveTo( left, top );
833 + convertKitPopup.moveTo(left, top);
535 834 convertKitPopup.focus();
536 835
537 836 // Refresh the block when the popup is closed using self.close().
538 837 // Won't fire if the user closes the popup manually, which is fine because that means
@@ -540,22 +839,18 @@
540 839 // The onbeforeunload would seem suitable here, but it fires whenever the popup window's
541 840 // document changes (e.g. as the user steps through a wizard), and doesn't fire when
542 841 // the window is closed.
543 842 // See https://stackoverflow.com/questions/9388380/capture-the-close-event-of-popup-window-in-javascript/48240128#48240128.
544 - var convertKitPopupTimer = setInterval(
545 - function () {
546 - if ( convertKitPopup.closed ) {
547 - clearInterval( convertKitPopupTimer );
843 + const convertKitPopupTimer = setInterval(function () {
844 + if (convertKitPopup.closed) {
845 + clearInterval(convertKitPopupTimer);
548 846
549 - // Refresh block.
550 - refreshBlocksDefinitions( props, setButtonDisabled );
551 - }
552 - },
553 - 1000
554 - );
847 + // Refresh block.
848 + refreshBlocksDefinitions(props, setButtonDisabled);
849 + }
850 + }, 1000);
851 + };
555 852
556 - }
557 -
558 853 /**
559 854 * Refreshes this block's properties by:
560 855 * - making an AJAX call to fetch all registered blocks via convertkit_get_blocks(),
561 856 * - storing the registered blocks in the `convertkit_blocks` global object,
@@ -562,121 +857,841 @@
562 857 * - updating this block's properties by updating the `block` object.
563 858 *
564 859 * @since 2.2.6
565 860 *
566 - * @param object props Block properties.
567 - * @param object setButtonDisabled Function to enable or disable the refresh button.
568 - * @return object Notice.
861 + * @param {Object} props Block properties.
862 + * @param {Function} setButtonDisabled Function to enable or disable the refresh button.
569 863 */
570 - const refreshBlocksDefinitions = function ( props, setButtonDisabled ) {
571 -
572 - // Define data for WordPress AJAX request.
573 - let data = new FormData();
574 - data.append( 'action', 'convertkit_get_blocks' );
575 - data.append( 'nonce', convertkit_gutenberg.get_blocks_nonce );
576 -
864 + const refreshBlocksDefinitions = function (props, setButtonDisabled) {
577 865 // Disable the button.
578 - setButtonDisabled( true );
866 + if (typeof setButtonDisabled !== 'undefined') {
867 + setButtonDisabled(true);
868 + }
579 869
580 870 // Send AJAX request.
581 - fetch(
582 - ajaxurl,
583 - {
584 - method: 'POST',
585 - credentials: 'same-origin',
586 - body: data
587 - }
588 - )
589 - .then(
590 - function ( response ) {
591 -
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) {
592 879 // Convert response JSON string to object.
593 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 + });
594 891
595 - }
596 - )
597 - .then(
598 - function ( response ) {
892 + // Enable refresh button.
893 + if (typeof setButtonDisabled !== 'undefined') {
894 + setButtonDisabled(false);
895 + }
896 + return;
897 + }
599 898
600 899 // Update global ConvertKit Blocks object, so that any updated resources
601 900 // are reflected when adding new ConvertKit Blocks.
602 - convertkit_blocks = response.data;
901 + convertkit_blocks = response;
603 902
604 - // Update this block's properties, so that has_api_key, has_resources
903 + // Update this block's properties, so that has_access_token, has_resources
605 904 // and the resources properties are updated.
606 - block = convertkit_blocks[ block.name ];
905 + block = convertkit_blocks[block.name];
607 906
608 907 // Call setAttributes on props to trigger the editBlock() function, which will re-render
609 908 // the block, reflecting any changes to its properties.
610 - props.setAttributes(
611 - {
612 - refresh: Date.now()
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 + })(
956 + window.wp.blocks,
957 + window.wp.blockEditor,
958 + window.wp.element,
959 + window.wp.components
960 + );
961 +}
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 + );
613 1157 }
614 - );
615 1158
616 - // Enable refresh button.
617 - setButtonDisabled( false );
1159 + return getSelectField(field, fieldProperties);
618 1160
1161 + default:
1162 + // Return field element.
1163 + return el(TextControl, fieldProperties);
619 1164 }
620 - )
621 - .catch(
622 - function ( error ) {
1165 + };
623 1166
624 - // Show an error in the Gutenberg editor.
625 - wp.data.dispatch( 'core/notices' ).createErrorNotice(
626 - 'ConvertKit: ' + error,
627 - {
628 - id: 'convertkit-error'
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 + );
629 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 + ']'
630 1366 );
1367 + }
631 1368
632 - // Enable refresh button.
633 - setButtonDisabled( false );
1369 + return item.name;
1370 + };
634 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 + }
635 1393 }
636 - );
637 1394
638 - }
1395 + // Add the refreshed items.
1396 + items.forEach(function (item) {
1397 + values[item.id] = labelForItem(item);
1398 + });
639 1399
640 - // Register Block.
641 - registerBlockType(
642 - 'convertkit/' + block.name,
643 - {
644 - title: block.title,
645 - description:block.description,
646 - category: block.category,
647 - icon: getIcon,
648 - keywords: block.keywords,
649 - attributes: block.attributes,
650 - supports: block.supports,
651 - example: {
652 - attributes: {
653 - is_gutenberg_example: true,
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];
654 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 + }),
655 1599 },
1600 + getFields(sidebar.fields)
1601 + );
1602 + };
656 1603
657 - // Editor.
658 - edit: editBlock,
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 + );
1615 +}
659 1616
660 - // Output.
661 - save: function ( props ) {
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;
662 1631
663 - // Deliberate; preview in the editor is determined by the return statement in `edit` above.
664 - // On the frontend site, the block's render() PHP class is always called, so we dynamically
665 - // fetch the content.
666 - return null;
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 + }));
667 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,
668 1678 },
669 - }
670 - );
1679 + rows
1680 + );
1681 + };
671 1682
672 - } (
673 - window.wp.blocks,
674 - window.wp.blockEditor,
1683 + // Register pre-publish actions
1684 + registerPlugin('convertkit-pre-publish-actions', {
1685 + render: RenderPanel,
1686 + });
1687 + })(
1688 + window.wp.plugins,
1689 + window.wp.editPost,
675 1690 window.wp.element,
676 - window.wp.components
677 - ) );
678 -
1691 + window.wp.components,
1692 + window.wp.data
1693 + );
679 1694 }
680 1695
681 1696 /**
682 1697 * Outputs a notice for the block. Typically used when a block's settings
@@ -685,21 +1700,45 @@
685 1700 * Form / Product block.
686 1701 *
687 1702 * @since 2.2.3
688 1703 *
689 - * @param string block_name Block Name.
690 - * @param string notice Notice to display.
691 - * @return object HTMLElement
1704 + * @param {string} block_name Block Name.
1705 + * @param {string} notice Notice to display.
1706 + * @return {Object} HTMLElement
692 1707 */
693 -function convertKitGutenbergDisplayBlockNotice( block_name, notice ) {
694 -
1708 +function convertKitGutenbergDisplayBlockNotice(block_name, notice) {
695 1709 return wp.element.createElement(
696 1710 'div',
697 1711 {
698 1712 // convertkit-no-content class allows resources/backend/css/gutenberg.css
699 1713 // to apply styling/branding to the block.
700 - className: 'convertkit-' + block_name + ' convertkit-no-content'
1714 + className: 'convertkit-' + block_name + ' convertkit-no-content',
701 1715 },
702 1716 notice
703 1717 );
1718 +}
704 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';
1730 +}
1731 +
1732 +/**
1733 + * Checks if the Gutenberg editor is loaded on screen.
1734 + *
1735 + * Returns true when editing a Post, Page or Custom Post Type in the block editor,
1736 + * or using the site editor.
1737 + *
1738 + * @since 3.0.8
1739 + *
1740 + * @return {boolean} Block editor is loaded
1741 + */
1742 +function convertKitGutenbergEnabled() {
1743 + return typeof wp !== 'undefined' && typeof wp.blockEditor !== 'undefined';
705 1744 }