PluginProbe
Flex Posts – Responsive Posts Block / 2.0.0
Flex Posts – Responsive Posts Block v2.0.0
2.1.0 trunk 1.12.0 2.0.0
flex-posts / blocks / helpers.js

helpers.js in Flex Posts – Responsive Posts Block 2.0.0, at blocks/helpers.js

573 lines 21.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * Flex Posts - Block Helpers
3 *
4 * This file exports reusable helper functions for creating block controls.
5 */
6 ( function( wp ) {
7 'use strict';
8
9 const { __ } = wp.i18n;
10
11 /**
12 * Create a text control element
13 *
14 * @param {wp.element.createElement} el - createElement function
15 * @param {string} label - Pre-translated label text
16 * @param {string} attrKey - Attribute key to update
17 * @param {Object} attr - Current attributes object
18 * @param {Function} setAttributes - Function to update attributes
19 * @return {JSX.Element} TextControl element
20 */
21 const createTextControl = ( el, label, attrKey, attr, setAttributes ) =>
22 el( wp.components.TextControl, {
23 type: 'text',
24 label: label,
25 value: attr[ attrKey ],
26 onChange: ( val ) => setAttributes( { [ attrKey ]: val } ),
27 __nextHasNoMarginBottom: true,
28 __next40pxDefaultSize: true
29 } );
30
31 /**
32 * Create a select control element
33 *
34 * @param {wp.element.createElement} el - createElement function
35 * @param {string} label - Pre-translated label text
36 * @param {string} attrKey - Attribute key to update
37 * @param {Object} attr - Current attributes object
38 * @param {Function} setAttributes - Function to update attributes
39 * @param {Array} options - Select options array
40 * @param {Function|null} parser - Optional parser function for the value
41 * @return {JSX.Element} SelectControl element
42 */
43 const createSelectControl = ( el, label, attrKey, attr, setAttributes, options, parser = null ) =>
44 el( wp.components.SelectControl, {
45 label: label,
46 value: attr[ attrKey ],
47 options: options,
48 onChange: ( val ) => setAttributes( { [ attrKey ]: parser ? parser( val ) : val } ),
49 __nextHasNoMarginBottom: true,
50 __next40pxDefaultSize: true
51 } );
52
53 /**
54 * Create a range control element
55 *
56 * @param {wp.element.createElement} el - createElement function
57 * @param {string} label - Pre-translated label text
58 * @param {string} attrKey - Attribute key to update
59 * @param {Object} attr - Current attributes object
60 * @param {Function} setAttributes - Function to update attributes
61 * @param {number} min - Minimum value (default: 0)
62 * @param {number} max - Maximum value (default: 100)
63 * @return {JSX.Element} RangeControl element
64 */
65 const createRangeControl = ( el, label, attrKey, attr, setAttributes, min = 0, max = 100 ) =>
66 el( wp.components.RangeControl, {
67 label: label,
68 value: attr[ attrKey ],
69 min: min,
70 max: max,
71 onChange: ( val ) => setAttributes( { [ attrKey ]: val } ),
72 __nextHasNoMarginBottom: true,
73 __next40pxDefaultSize: true
74 } );
75
76 /**
77 * Create a checkbox control element
78 *
79 * @param {wp.element.createElement} el - createElement function
80 * @param {string} label - Pre-translated label text
81 * @param {string} attrKey - Attribute key to update
82 * @param {Object} attr - Current attributes object
83 * @param {Function} setAttributes - Function to update attributes
84 * @return {JSX.Element} CheckboxControl element
85 */
86 const createCheckboxControl = ( el, label, attrKey, attr, setAttributes ) =>
87 el( wp.components.CheckboxControl, {
88 label: label,
89 checked: attr[ attrKey ],
90 onChange: ( val ) => setAttributes( { [ attrKey ]: val } ),
91 __nextHasNoMarginBottom: true
92 } );
93
94 /**
95 * Create a panel body with controls
96 *
97 * @param {wp.element.createElement} el - createElement function
98 * @param {string} title - Pre-translated panel title
99 * @param {boolean} initialOpen - Whether panel starts open
100 * @param {Array} controls - Array of control elements
101 * @return {JSX.Element} PanelBody element
102 */
103 const createPanelBody = ( el, title, initialOpen, controls ) =>
104 el( wp.components.PanelBody, { title: title, initialOpen: initialOpen }, ...controls );
105
106 /**
107 * Create a visual layout selector component
108 *
109 * @param {wp.element.createElement} el - createElement function
110 * @param {string} label - Pre-translated label text
111 * @param {string} attrKey - Attribute key to update
112 * @param {Object} attr - Current attributes object
113 * @param {Function} setAttributes - Function to update attributes
114 * @param {Object} layoutSVGs - Object mapping layout numbers to SVG strings
115 * @param {string} more - more text
116 * @return {JSX.Element} Layout selector component
117 */
118 const createLayoutSelector = ( el, label, attrKey, attr, setAttributes, layoutSVGs, more = '' ) => {
119 const layoutItems = [];
120 const layoutCount = Object.keys( layoutSVGs ).length;
121
122 for ( let i = 1; i <= layoutCount; i++ ) {
123 const isSelected = parseInt( attr[ attrKey ] ) === i;
124 const svgString = layoutSVGs[ i ];
125
126 layoutItems.push(
127 el( 'button', {
128 key: `layout-${ i }`,
129 type: 'button',
130 className: `flex-posts-layout-btn${ isSelected ? ' is-selected' : '' }`,
131 onClick: () => setAttributes( { [ attrKey ]: i } ),
132 'aria-label': __( 'Layout', 'flex-posts' ) + ' ' + i,
133 'aria-pressed': isSelected
134 },
135 el( 'span', { className: 'flex-posts-layout-preview', dangerouslySetInnerHTML: { __html: svgString } } )
136 )
137 );
138 }
139
140 return el( 'div', { className: 'flex-posts-layout-selector' },
141 el( 'label', { className: 'flex-posts-layout-selector__label' }, label ),
142 el( 'div', { className: 'flex-posts-layout-selector__grid' }, ...layoutItems ),
143 flex_posts.more_url && el( 'a', {
144 className: 'flex-posts-layout-selector__more-link',
145 href: flex_posts.more_url,
146 target: '_blank',
147 rel: 'noopener noreferrer'
148 }, more )
149 );
150 };
151
152 /**
153 * Decode HTML entities in a string
154 *
155 * @param {string} str String with HTML entities
156 * @return {string} Decoded string
157 */
158 const decodeHtmlEntities = ( str ) => {
159 const textarea = document.createElement( 'textarea' );
160 textarea.innerHTML = str;
161 return textarea.value;
162 };
163
164 /**
165 * Hook to fetch and format categories from WordPress.
166 *
167 * @return {Array} Formatted categories array with label/value pairs
168 */
169 const useCategories = () => {
170 const { useSelect } = wp.data;
171 const { useMemo } = wp.element;
172
173 // Fetch raw categories
174 const rawCategories = useSelect( ( select ) => {
175 return select( 'core' ).getEntityRecords( 'taxonomy', 'category', { per_page: -1 } );
176 }, [] );
177
178 // Format categories with memoization
179 const categories = useMemo( () => {
180 const formattedCategories = [
181 {
182 label: __( 'All Categories', 'flex-posts' ),
183 value: '',
184 },
185 ];
186 if ( rawCategories && rawCategories.length > 0 ) {
187 rawCategories.forEach( ( cat ) => {
188 formattedCategories.push( {
189 label: decodeHtmlEntities( cat.name ),
190 value: cat.id,
191 } );
192 } );
193 }
194 return formattedCategories;
195 }, [ rawCategories ] );
196
197 return categories;
198 };
199
200 /**
201 * Hook to fetch post types and their taxonomies directly from the editor data store.
202 *
203 * - options: [{ label, value }] with Post and Page first, public custom
204 * post types in the middle, and an "Any" entry last.
205 * - taxonomies: { [postTypeSlug]: string[] } map used to decide whether the
206 * Category / Tag controls apply to the selected post type.
207 * - hasResolved: false until the REST request has returned, so callers can
208 * avoid acting on an empty taxonomy map while it loads.
209 *
210 * @return {{ options: Array, taxonomies: Object, hasResolved: boolean }}
211 */
212 const usePostTypes = () => {
213 const { useSelect } = wp.data;
214 const { useMemo } = wp.element;
215
216 // Edit context is required to read `viewable` and `labels.singular_name`.
217 const rawPostTypes = useSelect( ( select ) => {
218 return select( 'core' ).getPostTypes( { per_page: -1, context: 'edit' } );
219 }, [] );
220
221 return useMemo( () => {
222 // Post and Page are always offered first, matching the previous PHP output.
223 const options = [
224 { label: __( 'Post', 'flex-posts' ), value: 'post' },
225 { label: __( 'Page', 'flex-posts' ), value: 'page' },
226 ];
227 const taxonomies = {};
228
229 // Post types that exist in WordPress but should never be selectable here.
230 const reserved = [ 'post', 'page', 'attachment' ];
231
232 if ( rawPostTypes && rawPostTypes.length > 0 ) {
233 rawPostTypes.forEach( ( type ) => {
234 if ( Array.isArray( type.taxonomies ) ) {
235 taxonomies[ type.slug ] = type.taxonomies;
236 }
237
238 // Skip built-ins and non-public types to mirror
239 // get_post_types( array( 'public' => true, '_builtin' => false ) ).
240 if ( reserved.indexOf( type.slug ) !== -1 || ! type.viewable ) {
241 return;
242 }
243
244 const label = ( type.labels && type.labels.singular_name )
245 ? type.labels.singular_name
246 : type.name;
247 options.push( { label: label, value: type.slug } );
248 } );
249 }
250
251 options.push( { label: __( 'Any', 'flex-posts' ), value: 'any' } );
252
253 return {
254 options: options,
255 taxonomies: taxonomies,
256 hasResolved: Array.isArray( rawPostTypes ),
257 };
258 }, [ rawPostTypes ] );
259 };
260
261 /**
262 * Clear invalid taxonomy values based on available options
263 *
264 * @param {boolean} hasCategoryOption - Whether categories are supported for the current post type
265 * @param {boolean} hasPostTagOption - Whether post tags are supported for the current post type
266 * @param {Object} attr - Current block attributes
267 * @return {Object} Updates object with cleared invalid values
268 */
269 const clearInvalidTaxonomies = ( hasCategoryOption, hasPostTagOption, attr ) => {
270 const updates = {};
271 if ( ! hasCategoryOption && attr.cat ) {
272 updates.cat = '';
273 }
274 if ( ! hasPostTagOption && attr.tag ) {
275 updates.tag = '';
276 }
277 return updates;
278 };
279
280 /**
281 * Shared label for a known checkbox key. Returns undefined for unknown keys
282 * so callers can fall back to an explicit label.
283 */
284 const sharedCheckboxLabel = ( key ) => ( {
285 show_title: __( 'Show post title', 'flex-posts' ),
286 show_categories: __( 'Show categories', 'flex-posts' ),
287 show_author: __( 'Show author', 'flex-posts' ),
288 show_avatar: __( 'Show author image', 'flex-posts' ),
289 show_date: __( 'Show date', 'flex-posts' ),
290 show_comments: __( 'Show comments number', 'flex-posts' )
291 }[ key ] );
292
293 /**
294 * Render the Layout panel.
295 */
296 const renderLayoutPanel = ( el, attr, setAttributes, layoutSVGs ) =>
297 createPanelBody( el, __( 'Layout', 'flex-posts' ), true, [
298 createLayoutSelector( el, __( 'Choose a layout', 'flex-posts' ), 'layout', attr, setAttributes, layoutSVGs, __( 'More layouts', 'flex-posts' ) )
299 ] );
300
301 /**
302 * Render the Heading panel.
303 */
304 const renderHeadingPanel = ( el, attr, setAttributes ) => {
305 const controls = [
306 createCheckboxControl( el, __( 'Use category title', 'flex-posts' ), 'title_cat', attr, setAttributes )
307 ];
308
309 if ( ! attr.title_cat ) {
310 controls.push( createTextControl( el, __( 'Title', 'flex-posts' ), 'title', attr, setAttributes ) );
311 }
312
313 controls.push( createCheckboxControl( el, __( 'Use category URL', 'flex-posts' ), 'title_url_cat', attr, setAttributes ) );
314
315 if ( ! attr.title_url_cat ) {
316 controls.push( createTextControl( el, __( 'Title URL', 'flex-posts' ), 'title_url', attr, setAttributes ) );
317 }
318
319 return createPanelBody( el, __( 'Heading', 'flex-posts' ), false, controls );
320 };
321
322 /**
323 * Render the Query panel.
324 *
325 * @param {Object} ctx { hasCategoryOption, hasPostTagOption, categories, postTypeOptions }
326 * @param {Object} options { numberOfPosts: bool }
327 * @param {Function} [options.extraControls] ( el, attr, setAttributes ) => control[] appended at the end
328 */
329 const renderQueryPanel = ( el, attr, setAttributes, ctx, options ) => {
330 const controls = [
331 createSelectControl( el, __( 'Post Type', 'flex-posts' ), 'post_type', attr, setAttributes, ctx.postTypeOptions )
332 ];
333
334 if ( ctx.hasCategoryOption ) {
335 controls.push( createSelectControl( el, __( 'Category', 'flex-posts' ), 'cat', attr, setAttributes, ctx.categories ) );
336 }
337
338 if ( ctx.hasPostTagOption ) {
339 controls.push( createTextControl( el, __( 'Tag(s)', 'flex-posts' ), 'tag', attr, setAttributes ) );
340 }
341
342 controls.push( createSelectControl( el, __( 'Order by', 'flex-posts' ), 'order_by', attr, setAttributes, flex_posts.order_by ) );
343
344 if ( options.numberOfPosts ) {
345 controls.push( createRangeControl( el, __( 'Number of posts to show', 'flex-posts' ), 'number', attr, setAttributes, 1 ) );
346 }
347
348 controls.push(
349 createRangeControl( el, __( 'Number of posts to skip', 'flex-posts' ), 'skip', attr, setAttributes, 0 ),
350 createCheckboxControl( el, __( 'Exclude current post', 'flex-posts' ), 'exclude_current', attr, setAttributes )
351 );
352
353 if ( options.extraControls ) {
354 controls.push( ...options.extraControls( el, attr, setAttributes ) );
355 }
356
357 return createPanelBody( el, __( 'Query', 'flex-posts' ), false, controls );
358 };
359
360 /**
361 * Render the Display panel.
362 *
363 * @param {Object} options
364 * showImage bool — adds show_image select + conditional image_size selects
365 * numericRanges [{ key, label, min, max }] — range controls before the checkboxes
366 * checkboxes [{ key, label? }] — label is optional; falls back to sharedCheckboxLabel(key)
367 * excerpt bool — adds show_excerpt checkbox + conditional excerpt_length range
368 * readmore bool — adds show_readmore checkbox + conditional readmore_text input
369 * pagination bool — adds the pagination checkbox at the end
370 * extraControls Function — ( el, attr, setAttributes ) => control[] appended at the very end
371 */
372 const renderDisplayPanel = ( el, attr, setAttributes, options ) => {
373 const controls = [];
374
375 if ( options.showImage ) {
376 const showImageOptions = [
377 { value: 'all', label: __( 'All posts', 'flex-posts' ) },
378 { value: 'first', label: __( 'First post only', 'flex-posts' ) },
379 { value: 'none', label: __( 'None', 'flex-posts' ) }
380 ];
381 controls.push( createSelectControl( el, __( 'Show image on', 'flex-posts' ), 'show_image', attr, setAttributes, showImageOptions ) );
382
383 if ( attr.show_image !== 'none' && attr.layout !== 1 ) {
384 controls.push( createSelectControl( el, __( 'Image size', 'flex-posts' ), 'image_size2', attr, setAttributes, flex_posts.image_sizes ) );
385 }
386
387 if ( attr.show_image !== 'none' && ( attr.layout === 1 || attr.layout === 3 ) ) {
388 controls.push( createSelectControl( el, __( 'Thumbnail image size', 'flex-posts' ), 'image_size', attr, setAttributes, flex_posts.image_sizes ) );
389 }
390 }
391
392 ( options.numericRanges || [] ).forEach( ( { key, label, min = 0, max = 100 } ) => {
393 controls.push( createRangeControl( el, label, key, attr, setAttributes, min, max ) );
394 } );
395
396 ( options.checkboxes || [] ).forEach( ( { key, label } ) => {
397 controls.push( createCheckboxControl( el, label || sharedCheckboxLabel( key ) || key, key, attr, setAttributes ) );
398 } );
399
400 if ( options.excerpt ) {
401 controls.push( createCheckboxControl( el, __( 'Show excerpt', 'flex-posts' ), 'show_excerpt', attr, setAttributes ) );
402 if ( attr.show_excerpt ) {
403 controls.push( createRangeControl( el, __( 'Excerpt length', 'flex-posts' ), 'excerpt_length', attr, setAttributes, 1 ) );
404 }
405 }
406
407 if ( options.readmore ) {
408 controls.push( createCheckboxControl( el, __( 'Show read more link', 'flex-posts' ), 'show_readmore', attr, setAttributes ) );
409 if ( attr.show_readmore ) {
410 controls.push( createTextControl( el, __( 'Read more text', 'flex-posts' ), 'readmore_text', attr, setAttributes ) );
411 }
412 }
413
414 if ( options.pagination ) {
415 controls.push( createCheckboxControl( el, __( 'Show pagination', 'flex-posts' ), 'pagination', attr, setAttributes ) );
416 }
417
418 if ( options.extraControls ) {
419 controls.push( ...options.extraControls( el, attr, setAttributes ) );
420 }
421
422 return createPanelBody( el, __( 'Display', 'flex-posts' ), false, controls );
423 };
424
425 /**
426 * Render Inspector Advanced Controls (Block / Post title HTML element selectors).
427 */
428 const renderAdvancedPanels = ( el, attr, setAttributes ) => {
429 const { InspectorAdvancedControls } = wp.blockEditor;
430 return [
431 el( InspectorAdvancedControls, { key: 'inspector-advanced1' },
432 createSelectControl( el, __( 'Block Title HTML element', 'flex-posts' ), 'block_title_el', attr, setAttributes, flex_posts.title_el )
433 ),
434 el( InspectorAdvancedControls, { key: 'inspector-advanced2' },
435 createSelectControl( el, __( 'Post Title HTML element', 'flex-posts' ), 'post_title_el', attr, setAttributes, flex_posts.title_el )
436 )
437 ];
438 };
439
440 /**
441 * Resolve the ServerSideRender component across WP versions.
442 */
443 const resolveServerSideRender = () => {
444 if ( typeof wp.serverSideRender !== 'undefined' ) {
445 return ( typeof wp.serverSideRender.ServerSideRender !== 'undefined' )
446 ? wp.serverSideRender.ServerSideRender
447 : wp.serverSideRender;
448 }
449 return wp.components.ServerSideRender;
450 };
451
452 /**
453 * Register a Flex Posts-style block.
454 *
455 * Owns the boilerplate, edit() function, and registerBlockType call.
456 * Each block.js declares only what differs.
457 *
458 * @param {Object} config
459 * blockName string — e.g. 'flex-posts/list'
460 * title string — pre-translated title
461 * icon string — dashicon name (default 'grid-view')
462 * attributes Object — block attributes definition
463 * layoutSVGs Object — map of layout number → SVG string
464 * supports Object — extra block supports merged over the defaults (e.g. color, typography)
465 * query Object — { numberOfPosts: bool }
466 * display Object — see renderDisplayPanel options
467 * extraPanels Function — ( el, attr, setAttributes ) => panel[] appended after the Display panel
468 */
469 const registerFlexBlock = ( config ) => {
470 const {
471 blockName,
472 title,
473 icon = 'grid-view',
474 category,
475 attributes,
476 layoutSVGs,
477 supports = {},
478 query: queryOptions = {},
479 display: displayOptions = {},
480 extraPanels
481 } = config;
482
483 const blockCategory = category
484 || ( ( typeof flex_posts !== 'undefined' && flex_posts.category ) ? flex_posts.category : 'widgets' );
485
486 const ServerSideRender = resolveServerSideRender();
487 const { createElement: el, Fragment, useEffect } = wp.element;
488 const { useBlockProps, InspectorControls } = wp.blockEditor;
489 const { Disabled } = wp.components;
490
491 wp.blocks.registerBlockType( blockName, {
492 apiVersion: 3,
493 title: title,
494 icon: icon,
495 category: blockCategory,
496 supports: {
497 align: [ 'wide', 'full' ],
498 html: false,
499 ...supports
500 },
501 attributes: attributes,
502
503 edit: function( props ) {
504 const { attributes: attr, setAttributes } = props;
505
506 const categories = useCategories();
507 const { options: postTypeOptions, taxonomies, hasResolved: postTypesResolved } = usePostTypes();
508
509 let hasCategoryOption = false;
510 let hasPostTagOption = false;
511 const currentTaxonomies = taxonomies[ attr.post_type ];
512 if ( typeof currentTaxonomies !== 'undefined' ) {
513 hasCategoryOption = currentTaxonomies.indexOf( 'category' ) !== -1;
514 hasPostTagOption = currentTaxonomies.indexOf( 'post_tag' ) !== -1;
515 }
516
517 useEffect( () => {
518 if ( ! postTypesResolved ) {
519 return;
520 }
521 const updates = clearInvalidTaxonomies( hasCategoryOption, hasPostTagOption, attr );
522 if ( Object.keys( updates ).length > 0 ) {
523 setAttributes( updates );
524 }
525 }, [ attr.post_type, hasCategoryOption, hasPostTagOption, postTypesResolved ] );
526
527 return el( Fragment, null,
528 el( 'div', useBlockProps(),
529 el( Disabled, null,
530 el( ServerSideRender, {
531 skipBlockSupportAttributes: true,
532 block: blockName,
533 attributes: attr,
534 key: 'server-render'
535 } )
536 )
537 ),
538 el( InspectorControls, { key: 'inspector' },
539 renderLayoutPanel( el, attr, setAttributes, layoutSVGs ),
540 renderHeadingPanel( el, attr, setAttributes ),
541 renderQueryPanel( el, attr, setAttributes,
542 { hasCategoryOption, hasPostTagOption, categories, postTypeOptions },
543 queryOptions
544 ),
545 renderDisplayPanel( el, attr, setAttributes, displayOptions ),
546 ...( extraPanels ? extraPanels( el, attr, setAttributes ) : [] )
547 ),
548 ...renderAdvancedPanels( el, attr, setAttributes )
549 );
550 },
551
552 save: function() {
553 return null;
554 }
555 } );
556 };
557
558 // Export helpers to global namespace
559 window.flexPostsHelpers = {
560 createTextControl,
561 createSelectControl,
562 createRangeControl,
563 createCheckboxControl,
564 createPanelBody,
565 createLayoutSelector,
566 decodeHtmlEntities,
567 useCategories,
568 usePostTypes,
569 clearInvalidTaxonomies,
570 registerFlexBlock
571 };
572
573 } )( window.wp );