diff --git a/includes/class-blocks.php b/includes/class-blocks.php index ab7244b91c..c8b774bdbc 100644 --- a/includes/class-blocks.php +++ b/includes/class-blocks.php @@ -9,6 +9,8 @@ defined( 'ABSPATH' ) || exit; +use Newspack\Optional_Modules\Collections; + /** * Newspack Blocks Class. */ @@ -26,8 +28,12 @@ public static function init() { if ( wp_is_block_theme() ) { require_once NEWSPACK_ABSPATH . 'src/blocks/avatar/class-avatar-block.php'; } + if ( Collections::is_module_active() ) { + require_once NEWSPACK_ABSPATH . 'src/blocks/collections/index.php'; + } \add_action( 'enqueue_block_editor_assets', [ __CLASS__, 'enqueue_block_editor_assets' ] ); + \add_action( 'wp_enqueue_scripts', [ __CLASS__, 'enqueue_frontend_assets' ] ); } /** @@ -57,6 +63,7 @@ public static function enqueue_block_editor_assets() { 'has_recaptcha' => Recaptcha::can_use_captcha(), 'recaptcha_url' => admin_url( 'admin.php?page=newspack-settings' ), 'corrections_enabled' => wp_is_block_theme() && class_exists( 'Newspack\Corrections' ), + 'collections_enabled' => Collections::is_module_active(), ] ); \wp_enqueue_style( @@ -66,5 +73,37 @@ public static function enqueue_block_editor_assets() { NEWSPACK_PLUGIN_VERSION ); } + + /** + * Enqueue blocks scripts and styles for frontend. + * Only load if we have blocks on the page that need these styles. + */ + public static function enqueue_frontend_assets() { + if ( self::should_load_block_assets() ) { + \wp_enqueue_style( + 'newspack-blocks-frontend', + Newspack::plugin_url() . '/dist/blocks.css', + [], + NEWSPACK_PLUGIN_VERSION + ); + } + } + + /** + * Check if we should load block assets on current page. + * + * @return bool Whether to load block assets. + */ + private static function should_load_block_assets() { + if ( + is_singular() && + Collections::is_module_active() && + has_block( \Newspack\Blocks\Collections\Collections_Block::BLOCK_NAME, get_the_ID() ) + ) { + return true; + } + + return false; + } } Blocks::init(); diff --git a/includes/collections/class-collection-meta.php b/includes/collections/class-collection-meta.php index e59b53422b..81e94efaa2 100644 --- a/includes/collections/class-collection-meta.php +++ b/includes/collections/class-collection-meta.php @@ -138,6 +138,7 @@ public static function get_meta_definitions() { */ public static function init() { add_action( 'init', [ __CLASS__, 'register_meta' ] ); + add_action( 'rest_api_init', [ __CLASS__, 'register_rest_fields' ] ); } /** @@ -208,4 +209,42 @@ private static function sanitize_single_cta( $cta ) { return $sanitized_cta; } + + /** + * Register custom REST API fields for collections. + */ + public static function register_rest_fields() { + register_rest_field( + Post_Type::get_post_type(), + 'ctas', + [ + 'get_callback' => [ __CLASS__, 'get_collection_ctas_for_rest' ], + 'schema' => [ + 'description' => __( 'Collection CTAs', 'newspack-plugin' ), + 'type' => 'array', + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'url' => [ 'type' => 'string' ], + 'label' => [ 'type' => 'string' ], + 'class' => [ 'type' => 'string' ], + ], + ], + 'context' => [ 'view', 'edit' ], + ], + ] + ); + } + + /** + * Get collection CTAs for REST API response. + * + * @param array $post Post data. + * @return array Array of processed CTAs. + * + * @see Query_Helper::get_ctas() + */ + public static function get_collection_ctas_for_rest( $post ) { + return Query_Helper::get_ctas( $post['id'] ); + } } diff --git a/includes/collections/class-query-helper.php b/includes/collections/class-query-helper.php index 6164d1af71..9bbed7a72b 100644 --- a/includes/collections/class-query-helper.php +++ b/includes/collections/class-query-helper.php @@ -541,4 +541,77 @@ function ( $post ) use ( $exclude ) { */ return apply_filters( 'newspack_collections_recent', array_slice( $filtered, 0, $limit ), $exclude, $limit ); } + + /** + * Build and run the collections query based on block attributes. + * + * @param array $attributes Block attributes (sanitized). + * @return array Array of WP_Post collection objects. + */ + public static function get_collections_by_attributes( $attributes ) { + $query_args = [ + 'post_type' => Post_Type::get_post_type(), + 'post_status' => 'publish', + 'posts_per_page' => $attributes['numberOfItems'], + 'orderby' => 'date', + 'order' => 'DESC', + 'offset' => $attributes['offset'], + ]; + + // Handle specific collections selection. + if ( 'specific' === ( $attributes['queryType'] ?? '' ) && ! empty( $attributes['selectedCollections'] ) ) { + $query_args['post__in'] = $attributes['selectedCollections']; + $query_args['orderby'] = 'post__in'; + unset( $query_args['offset'] ); // Offset doesn't apply to specific post selections. + } + + // Handle category filtering. + if ( ! empty( $attributes['includeCategories'] ) || ! empty( $attributes['excludeCategories'] ) ) { + $tax_query = []; + + if ( ! empty( $attributes['includeCategories'] ) ) { + $tax_query[] = [ + 'taxonomy' => Collection_Category_Taxonomy::get_taxonomy(), + 'field' => 'term_id', + 'terms' => $attributes['includeCategories'], + 'operator' => 'IN', + ]; + } + + if ( ! empty( $attributes['excludeCategories'] ) ) { + $tax_query[] = [ + 'taxonomy' => Collection_Category_Taxonomy::get_taxonomy(), + 'field' => 'term_id', + 'terms' => $attributes['excludeCategories'], + 'operator' => 'NOT IN', + ]; + } + + if ( count( $tax_query ) > 1 ) { + $tax_query['relation'] = 'AND'; + } + + $query_args['tax_query'] = $tax_query; // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query + } + + /** + * Generic filter for collections query args. + * + * @param array $query_args Query args. + * @param array $attributes Block attributes. + */ + $query_args = apply_filters( 'newspack_collections_query_args', $query_args, $attributes ); + + $query = new \WP_Query( $query_args ); + $posts = $query->posts; + + /** + * Filter the collections posts returned by the query. + * + * @param array $posts Array of WP_Post objects. + * @param array $query_args Final query args. + * @param array $attributes Block attributes. + */ + return apply_filters( 'newspack_collections_query_posts', $posts, $query_args, $attributes ); + } } diff --git a/packages/icons/index.js b/packages/icons/index.js index 59a6f45a8b..b7819aec75 100644 --- a/packages/icons/index.js +++ b/packages/icons/index.js @@ -5,6 +5,7 @@ export { default as aspectPortrait } from './src/aspect-portrait'; export { default as aspectSquare } from './src/aspect-square'; export { default as ballotBox } from './src/ballot-box'; export { default as broadcast } from './src/broadcast'; +export { default as collections } from './src/collections'; export { default as contentCarousel } from './src/content-carousel'; export { default as contentLoop } from './src/content-loop'; export { default as corrections } from './src/corrections'; diff --git a/packages/icons/src/collections.js b/packages/icons/src/collections.js new file mode 100644 index 0000000000..5a5fe203d0 --- /dev/null +++ b/packages/icons/src/collections.js @@ -0,0 +1,12 @@ +/** + * WordPress dependencies + */ +import { SVG, Path } from '@wordpress/primitives'; + +const collections = ( + + + +); + +export default collections; diff --git a/src/blocks/collections/block.json b/src/blocks/collections/block.json new file mode 100644 index 0000000000..4a146d0e09 --- /dev/null +++ b/src/blocks/collections/block.json @@ -0,0 +1,135 @@ +{ + "$schema": "https://schemas.wp.org/trunk/block.json", + "apiVersion": 3, + "name": "newspack/collections", + "version": "1.0.0", + "title": "Collections", + "category": "newspack", + "description": "An advanced block that allows displaying collections based on different parameters and visual configurations.", + "keywords": [ "collections", "issues", "publications", "magazines", "groups" ], + "supports": { + "html": false, + "align": [ "wide", "full" ], + "spacing": { + "margin": true, + "padding": true + } + }, + "attributes": { + "queryType": { + "type": "string", + "default": "recent" + }, + "numberOfItems": { + "type": "number", + "default": 4 + }, + "offset": { + "type": "number", + "default": 0 + }, + "selectedCollections": { + "type": "array", + "default": [] + }, + "includeCategories": { + "type": "array", + "default": [] + }, + "excludeCategories": { + "type": "array", + "default": [] + }, + "layout": { + "type": "string", + "default": "grid" + }, + "columns": { + "type": "number", + "default": 4 + }, + "imageAlignment": { + "type": "string", + "default": "left" + }, + "imageSize": { + "type": "string", + "default": "small" + }, + "showFeaturedImage": { + "type": "boolean", + "default": true + }, + "showTitle": { + "type": "boolean", + "default": true + }, + "showCategory": { + "type": "boolean", + "default": true + }, + "showExcerpt": { + "type": "boolean", + "default": false + }, + "showPeriod": { + "type": "boolean", + "default": true + }, + "showVolume": { + "type": "boolean", + "default": true + }, + "showNumber": { + "type": "boolean", + "default": true + }, + "showCTAs": { + "type": "boolean", + "default": true + }, + "numberOfCTAs": { + "type": "number", + "default": 1 + }, + "showSubscriptionUrl": { + "type": "boolean", + "default": true + }, + "showOrderUrl": { + "type": "boolean", + "default": true + }, + "specificCTAs": { + "type": "string", + "default": "" + }, + "showSeeAllLink": { + "type": "boolean", + "default": true + }, + "seeAllLinkText": { + "type": "string", + "default": "" + } + }, + "example": { + "attributes": { + "queryType": "recent", + "numberOfItems": 4, + "layout": "grid", + "columns": 4, + "showFeaturedImage": true, + "showTitle": true, + "showPeriod": true, + "showVolume": true, + "showNumber": true, + "showCTAs": true, + "numberOfCTAs": 1, + "showSeeAllLink": true, + "seeAllLinkText": "See all collections" + } + }, + "textdomain": "newspack-plugin", + "editorScript": "file:./index.js" +} diff --git a/src/blocks/collections/class-collections-block.php b/src/blocks/collections/class-collections-block.php new file mode 100644 index 0000000000..c52a985d5b --- /dev/null +++ b/src/blocks/collections/class-collections-block.php @@ -0,0 +1,405 @@ + 'recent', + 'numberOfItems' => 4, + 'offset' => 0, + 'selectedCollections' => [], + 'includeCategories' => [], + 'excludeCategories' => [], + 'layout' => 'grid', + 'columns' => 4, + 'imageAlignment' => 'top', + 'imageSize' => 'small', + 'showFeaturedImage' => true, + 'showCategory' => true, + 'showTitle' => true, + 'showExcerpt' => false, + 'showVolume' => true, + 'showNumber' => true, + 'showPeriod' => true, + 'showSubscriptionUrl' => true, + 'showOrderUrl' => true, + 'showCTAs' => true, + 'numberOfCTAs' => 1, + 'specificCTAs' => '', + 'showSeeAllLink' => true, + 'seeAllLinkText' => '', + ]; + + /** + * Initializes the block. + * + * @return void + */ + public static function init() { + add_action( 'init', [ __CLASS__, 'register_block' ] ); + } + + /** + * Register newspack collections block. + * + * @return void + */ + public static function register_block() { + register_block_type_from_metadata( + __DIR__ . '/block.json', + [ + 'render_callback' => [ __CLASS__, 'render_block' ], + ] + ); + } + + /** + * Block render callback. + * + * @param array $attributes The block attributes. + * + * @return string The block HTML. + */ + public static function render_block( array $attributes ) { + + $attributes = wp_parse_args( $attributes, self::DEFAULT_ATTRIBUTES ); + + // Sanitize and normalize attributes that are used in queries/output. + $attributes['numberOfItems'] = max( 1, absint( $attributes['numberOfItems'] ) ); + $attributes['offset'] = max( 0, absint( $attributes['offset'] ) ); + $attributes['columns'] = max( 1, absint( $attributes['columns'] ) ); + $attributes['numberOfCTAs'] = max( 1, absint( $attributes['numberOfCTAs'] ) ); + $attributes['selectedCollections'] = array_map( 'absint', (array) $attributes['selectedCollections'] ); + $attributes['includeCategories'] = array_map( 'absint', (array) $attributes['includeCategories'] ); + $attributes['excludeCategories'] = array_map( 'absint', (array) $attributes['excludeCategories'] ); + + $collections = Query_Helper::get_collections_by_attributes( $attributes ); + + if ( empty( $collections ) ) { + return '

' . esc_html__( 'No collections found.', 'newspack-plugin' ) . '

'; + } + + /** + * Filter the CSS classes for the collections block wrapper. + * + * @param string $classes Wrapper classes string. + * @param array $attributes Block attributes. + */ + $classes = apply_filters( 'newspack_collections_block_wrapper_classes', self::get_block_classes( $attributes ), $attributes ); + + $wrapper_attributes = get_block_wrapper_attributes( + [ + 'class' => esc_attr( $classes ), + ] + ); + + ob_start(); + ?> +
> + + + +
+ + + +
+ +
+ +
+ +
+ + ID, $collection_url, $image_size ) ); ?> + + + +
+ + +
+ + + + + +

+ + + +

+ + + + + +
+ +
+ + + + + +
+
+ +
+ + + + slug, $collections_archive_url ); ?> + + name ); ?> + + + +
+ ID, 'period' ); + if ( $period ) { + $meta_parts[] = esc_html( $period ); + } + } + + // Volume and number. + $vol_number = []; + + if ( $attributes['showVolume'] ) { + $volume = Collection_Meta::get( $collection->ID, 'volume' ); + if ( $volume ) { + /* translators: %s is the volume number of a collection */ + $vol_number[] = sprintf( _x( 'Vol. %s', 'collection volume number', 'newspack-plugin' ), esc_html( $volume ) ); + } + } + + if ( $attributes['showNumber'] ) { + $number = Collection_Meta::get( $collection->ID, 'number' ); + if ( $number ) { + /* translators: %s is the issue number of a collection */ + $vol_number[] = sprintf( _x( 'No. %s', 'collection issue number', 'newspack-plugin' ), esc_html( $number ) ); + } + } + + if ( $vol_number ) { + $meta_parts[] = implode( ' / ', $vol_number ); + } + + // Render meta text. + if ( ! empty( $meta_parts ) ) { + // Use different separator based on layout. + $separator = 'list' === $attributes['layout'] ? ' / ' : '
'; + $meta_text = implode( $separator, $meta_parts ); + ?> +
+ +
+ ID ); + + // Filter CTAs based on toggle settings. + $filtered_ctas = array_filter( + $all_ctas, + function ( $cta ) use ( $attributes ) { + $cta_label = $cta['label'] ?? ''; + + if ( __( 'Subscribe', 'newspack-plugin' ) === $cta_label && ! $attributes['showSubscriptionUrl'] ) { + return false; + } + + if ( __( 'Order', 'newspack-plugin' ) === $cta_label && ! $attributes['showOrderUrl'] ) { + return false; + } + + return true; + } + ); + + // Filter by specific labels if provided. + if ( ! empty( $attributes['specificCTAs'] ) ) { + $specific_labels = array_map( 'trim', explode( ',', $attributes['specificCTAs'] ) ); + $filtered_ctas = array_filter( + $filtered_ctas, + function ( $cta ) use ( $specific_labels ) { + $cta_label = $cta['label'] ?? ''; + return in_array( $cta_label, $specific_labels, true ); + } + ); + } + + // Limit to numberOfCTAs. + $max_ctas = $attributes['numberOfCTAs'] ?? 1; + $filtered_ctas = array_slice( $filtered_ctas, 0, $max_ctas ); + + /** + * Filter the CTAs rendered by the collections block for a given collection. + * + * @param array $filtered_ctas Filtered CTAs. + * @param array $all_ctas All available CTAs. + * @param \WP_Post $collection The collection post object. + * @param array $attributes Block attributes. + */ + $filtered_ctas = apply_filters( 'newspack_collections_block_ctas', $filtered_ctas, $all_ctas, $collection, $attributes ); + + // Render CTAs. + if ( ! empty( $filtered_ctas ) ) { + ?> +
+ + + +
+ { + const { + showFeaturedImage, + showCategory, + showTitle, + showExcerpt, + showPeriod, + showVolume, + showNumber, + showCTAs, + numberOfCTAs, + showSubscriptionUrl, + showOrderUrl, + specificCTAs, + layout, + } = attributes; + + // Prevent default navigation for non-navigable anchors in the editor. + const preventNav = usePreventNav(); + + // Decode once per render instead of on every usage. + const titleText = useMemo( () => decodeEntities( collection?.title?.rendered || '' ), [ collection?.title?.rendered ] ); + + // Get featured image from embedded data. + const featuredImage = useMemo( () => collection?._embedded?.[ 'wp:featuredmedia' ]?.[ 0 ], [ collection?._embedded ] ); + + // Get categories from embedded data. + const categories = useMemo( () => { + return collection?._embedded?.[ 'wp:term' ]?.find( terms => terms?.[ 0 ]?.taxonomy === 'newspack_collection_category' ) || []; + }, [ collection?._embedded ] ); + + // Collection metadata. + const meta = collection.meta || {}; + const volume = meta.newspack_collection_volume; + const number = meta.newspack_collection_number; + const period = meta.newspack_collection_period; + + // Build filtered CTAs with memoization. + const filteredCtas = useMemo( () => { + let next = ( collection.ctas || [] ).filter( cta => { + // Filter by subscription toggle. + if ( cta.label === __( 'Subscribe', 'newspack-plugin' ) && ! showSubscriptionUrl ) { + return false; + } + // Filter by order toggle. + if ( cta.label === __( 'Order', 'newspack-plugin' ) && ! showOrderUrl ) { + return false; + } + return true; + } ); + + // Filter by specific labels if provided (case-insensitive, trims whitespace). + if ( specificCTAs && specificCTAs.trim() ) { + const specificLabels = new Set( specificCTAs.split( ',' ).map( label => label.trim().toLowerCase() ) ); + next = next.filter( cta => specificLabels.has( String( cta.label ).toLowerCase() ) ); + } + + // Limit number of CTAs. + return next.slice( 0, numberOfCTAs || 1 ); + }, [ collection.ctas, showSubscriptionUrl, showOrderUrl, specificCTAs, numberOfCTAs ] ); + + // Build meta text, memoized. + const metaText = useMemo( () => { + const metaParts = []; + + // Add period first. + if ( showPeriod && period ) { + metaParts.push( period ); + } + + const volNumber = []; + + if ( showVolume && volume ) { + volNumber.push( `Vol. ${ volume }` ); + } + + if ( showNumber && number ) { + volNumber.push( `No. ${ number }` ); + } + + if ( volNumber.length ) { + metaParts.push( volNumber.join( ' / ' ) ); + } + + if ( metaParts.length ) { + // Use different separator based on layout. + const separator = layout === 'list' ? ' / ' : '
'; + return metaParts.join( separator ); + } + + return ''; + }, [ showPeriod, period, showVolume, volume, showNumber, number, layout ] ); + + return ( +
+ { showFeaturedImage && ( +
+ { featuredImage ? ( + + { + + ) : ( + + ) } + +
+ { showCategory && categories.length > 0 && ( +
+ { categories.map( category => ( + + { decodeEntities( category.name ) } + + ) ) } +
+ ) } + + { showTitle && ( +

+ + { titleText } + +

+ ) } + + { metaText && ( +
+ { metaText } +
+ ) } + + { showExcerpt && ( collection.excerpt?.rendered || collection.content?.rendered ) && ( +
+ { collection.excerpt?.rendered || collection.content?.rendered } +
+ ) } + + { showCTAs && filteredCtas.length > 0 && ( +
+ { filteredCtas.map( ( cta, index ) => ( +
+ + { cta.label } + +
+ ) ) } +
+ ) } +
+
+ ); +}; + +export default CollectionItem; diff --git a/src/blocks/collections/components/InspectorPanel.jsx b/src/blocks/collections/components/InspectorPanel.jsx new file mode 100644 index 0000000000..252aa2640a --- /dev/null +++ b/src/blocks/collections/components/InspectorPanel.jsx @@ -0,0 +1,320 @@ +import { __ } from '@wordpress/i18n'; +import { InspectorControls } from '@wordpress/block-editor'; +import { + BaseControl, + PanelBody, + RangeControl, + ToggleControl, + // eslint-disable-next-line @wordpress/no-unsafe-wp-apis + __experimentalToggleGroupControl as ToggleGroupControl, + // eslint-disable-next-line @wordpress/no-unsafe-wp-apis + __experimentalToggleGroupControlOption as ToggleGroupControlOption, +} from '@wordpress/components'; +import { useCallback } from '@wordpress/element'; + +import { AutocompleteTokenField } from '../../../components/src'; +import { + fetchCategorySuggestions as fetchCategorySuggestionsRaw, + fetchSavedCategories as fetchSavedCategoriesRaw, + fetchCollectionSuggestions as fetchCollectionSuggestionsRaw, + fetchSavedCollections as fetchSavedCollectionsRaw, +} from '../utils/api'; + +const InspectorPanel = ( { attributes, setAttributes } ) => { + const { + queryType, + numberOfItems, + offset, + selectedCollections, + includeCategories, + excludeCategories, + layout, + columns, + imageAlignment, + imageSize, + showFeaturedImage, + showCategory, + showTitle, + showExcerpt, + showVolume, + showNumber, + showPeriod, + showSubscriptionUrl, + showOrderUrl, + showCTAs, + numberOfCTAs, + specificCTAs, + showSeeAllLink, + seeAllLinkText, + } = attributes; + + // Category suggestions. + const fetchCategorySuggestions = useCallback( search => fetchCategorySuggestionsRaw( search ), [] ); + + // Saved categories. + const fetchSavedCategories = useCallback( categoryIDs => fetchSavedCategoriesRaw( categoryIDs ), [] ); + + // Collection suggestions. + const fetchCollectionSuggestions = useCallback( search => fetchCollectionSuggestionsRaw( search ), [] ); + + // Saved collections. + const fetchSavedCollections = useCallback( collectionIDs => fetchSavedCollectionsRaw( collectionIDs ), [] ); + + return ( + + + setAttributes( { queryType: value } ) } + isBlock + help={ + queryType === 'recent' + ? __( 'The block will display the most recent collection(s).', 'newspack-plugin' ) + : __( 'The block will display only the specifically selected collection(s).', 'newspack-plugin' ) + } + __next40pxDefaultSize + > + + + + + { queryType === 'specific' && ( + setAttributes( { selectedCollections: value } ) } + fetchSuggestions={ fetchCollectionSuggestions } + fetchSavedInfo={ fetchSavedCollections } + label={ __( 'Collections', 'newspack-plugin' ) } + help={ __( 'Begin typing any word in a collection title. Click on an autocomplete result to select it.', 'newspack-plugin' ) } + __next40pxDefaultSize + /> + ) } + + { queryType === 'recent' && ( + <> + setAttributes( { numberOfItems: value } ) } + min={ 1 } + max={ 24 } + __next40pxDefaultSize + /> + + setAttributes( { offset: value } ) } + min={ 0 } + max={ 50 } + help={ __( 'Number of collections to skip from the beginning', 'newspack-plugin' ) } + __next40pxDefaultSize + /> + + setAttributes( { includeCategories: value } ) } + fetchSuggestions={ fetchCategorySuggestions } + fetchSavedInfo={ fetchSavedCategories } + label={ __( 'Included categories', 'newspack-plugin' ) } + style={ { marginBottom: '16px' } } + __next40pxDefaultSize + /> + + setAttributes( { excludeCategories: value } ) } + fetchSuggestions={ fetchCategorySuggestions } + fetchSavedInfo={ fetchSavedCategories } + label={ __( 'Excluded categories', 'newspack-plugin' ) } + style={ { marginBottom: '16px' } } + __next40pxDefaultSize + /> + + ) } + + setAttributes( { showSeeAllLink: value } ) } + /> + + { showSeeAllLink && ( + + setAttributes( { seeAllLinkText: e.target.value } ) } + placeholder={ __( 'See all', 'newspack-plugin' ) } + className="components-text-control__input" + style={ { height: '40px' } } + /> + + ) } + + + { layout === 'grid' && ( + + setAttributes( { columns: value } ) } + min={ 1 } + max={ 6 } + __next40pxDefaultSize + /> + + ) } + + + setAttributes( { showFeaturedImage: value } ) } + /> + { layout === 'list' && showFeaturedImage && ( + <> + setAttributes( { imageAlignment: value } ) } + isBlock + __next40pxDefaultSize + > + + + + + + setAttributes( { imageSize: value } ) } + isBlock + __next40pxDefaultSize + > + + + + + + ) } + + + + setAttributes( { showTitle: value } ) } + /> + + setAttributes( { showCategory: value } ) } + /> + + setAttributes( { showExcerpt: value } ) } + /> + + setAttributes( { showPeriod: value } ) } + /> + + setAttributes( { showVolume: value } ) } + /> + + setAttributes( { showNumber: value } ) } + /> + + setAttributes( { showCTAs: value } ) } + /> + + { showCTAs && ( + <> + setAttributes( { numberOfCTAs: value } ) } + min={ 1 } + max={ 5 } + help={ __( 'Maximum number of CTAs to display', 'newspack-plugin' ) } + __next40pxDefaultSize + /> + + setAttributes( { showSubscriptionUrl: value } ) } + /> + + setAttributes( { showOrderUrl: value } ) } + /> + + + setAttributes( { specificCTAs: e.target.value } ) } + placeholder={ __( '', 'newspack-plugin' ) } + className="components-text-control__input" + style={ { height: '40px' } } + /> + + + ) } + + + ); +}; + +export default InspectorPanel; diff --git a/src/blocks/collections/edit.jsx b/src/blocks/collections/edit.jsx new file mode 100644 index 0000000000..60a73732fc --- /dev/null +++ b/src/blocks/collections/edit.jsx @@ -0,0 +1,155 @@ +import { __ } from '@wordpress/i18n'; +import { useBlockProps, BlockControls, RichText } from '@wordpress/block-editor'; +import { ToolbarGroup, ToolbarButton, Placeholder, Spinner } from '@wordpress/components'; +import { caution, list, grid, pullLeft, pullRight, postFeaturedImage } from '@wordpress/icons'; +import { useMemo } from '@wordpress/element'; +import classnames from 'classnames'; + +import { useCollections } from './hooks/useCollections'; +import CollectionItem from './components/CollectionItem'; +import usePreventNav from './hooks/usePreventNav'; +import InspectorPanel from './components/InspectorPanel'; + +const Edit = ( { attributes, setAttributes } ) => { + const { layout, columns, imageAlignment, imageSize, showFeaturedImage, showSeeAllLink, seeAllLinkText } = attributes; + + const preventNav = usePreventNav(); + + // Fetch collections data. + const { collections, isLoading, hasCollections } = useCollections( attributes ); + + // Toolbar controls. + const layoutControls = useMemo( + () => [ + { + icon: list, + title: __( 'List view', 'newspack-plugin' ), + onClick: () => setAttributes( { layout: 'list' } ), + isActive: layout === 'list', + }, + { + icon: grid, + title: __( 'Grid view', 'newspack-plugin' ), + onClick: () => setAttributes( { layout: 'grid' } ), + isActive: layout === 'grid', + }, + ], + [ layout, setAttributes ] + ); + + const imageAlignmentControls = useMemo( + () => [ + { + icon: postFeaturedImage, + title: __( 'Show image on top', 'newspack-plugin' ), + isActive: imageAlignment === 'top', + onClick: () => setAttributes( { imageAlignment: 'top' } ), + }, + { + icon: pullLeft, + title: __( 'Show image on left', 'newspack-plugin' ), + isActive: imageAlignment === 'left', + onClick: () => setAttributes( { imageAlignment: 'left' } ), + }, + { + icon: pullRight, + title: __( 'Show image on right', 'newspack-plugin' ), + isActive: imageAlignment === 'right', + onClick: () => setAttributes( { imageAlignment: 'right' } ), + }, + ], + [ imageAlignment, setAttributes ] + ); + + const wrapperClassName = useMemo( + () => + classnames( + 'wp-block-newspack-collections', + `layout-${ layout }`, + layout === 'grid' && `columns-${ columns }`, + `image-${ imageAlignment }`, + layout === 'list' && `image-size-${ imageSize }` + ), + [ layout, columns, imageAlignment, imageSize ] + ); + + return ( + <> + + + { layoutControls.map( control => ( + + ) ) } + + { layout === 'list' && showFeaturedImage && ( + + { imageAlignmentControls.map( control => ( + + ) ) } + + ) } + + + + +
+ { /* Loading state */ } + { isLoading && ( + } + label={ __( 'Loading collections…', 'newspack-plugin' ) } + className="collections-loading" + /> + ) } + + { /* No results */ } + { ! isLoading && ! hasCollections && ( + + ) } + + { /* Collections display */ } + { ! isLoading && hasCollections && ( + <> + { collections.map( collection => ( + + ) ) } + + ) } + + { /* See all link */ } + { showSeeAllLink && hasCollections && ( +
+ setAttributes( { seeAllLinkText: value } ) } + href="#" + onClick={ preventNav } + allowedFormats={ [] } + placeholder={ __( 'See all', 'newspack-plugin' ) } + /> +
+ ) } +
+ + ); +}; + +export default Edit; diff --git a/src/blocks/collections/hooks/useCollections.js b/src/blocks/collections/hooks/useCollections.js new file mode 100644 index 0000000000..1c648c7723 --- /dev/null +++ b/src/blocks/collections/hooks/useCollections.js @@ -0,0 +1,73 @@ +import { useSelect } from '@wordpress/data'; +import { useMemo } from '@wordpress/element'; + +/** + * Custom hook to fetch collections using WordPress core data. + * + * @param {Object} attributes Block attributes. + * @return {Object} Object containing collections data and loading state. + */ +export const useCollections = attributes => { + const { queryType, numberOfItems, offset = 0, selectedCollections = [], includeCategories = [], excludeCategories = [] } = attributes; + + // Normalize and guard common inputs. + const isSpecific = queryType === 'specific'; + const perPage = Number( numberOfItems ); + const hasPerPage = Number.isFinite( perPage ) && perPage > 0; + const isDisabledQuery = ( isSpecific && selectedCollections.length === 0 ) || ! hasPerPage; + + // Memoize query object so selectors aren't re-run due to new object identities. + const query = useMemo( () => { + if ( isDisabledQuery ) { + return null; + } + + const q = { + per_page: perPage, + orderby: 'date', + order: 'desc', + _embed: true, + status: 'publish', + }; + + // Add offset for recent collections only. + if ( ! isSpecific && offset > 0 ) { + q.offset = offset; + } + + if ( isSpecific ) { + // Specific collections mode ignores category filters. + q.include = selectedCollections; + } else { + // Category filtering for recent collections. + if ( includeCategories.length ) { + q.newspack_collection_category = includeCategories; + } + if ( excludeCategories.length ) { + q.newspack_collection_category_exclude = excludeCategories; + } + } + + return q; + }, [ isDisabledQuery, isSpecific, perPage, offset, selectedCollections, includeCategories, excludeCategories ] ); + + return useSelect( + select => { + // If query is disabled (e.g., columns = 0 or specific with no selection), don't hit the store. + if ( ! query ) { + return { collections: [], isLoading: false, hasCollections: false }; + } + + const { getEntityRecords, isResolving } = select( 'core' ); + const collections = getEntityRecords( 'postType', 'newspack_collection', query ); + const isLoading = isResolving( 'getEntityRecords', [ 'postType', 'newspack_collection', query ] ); + + return { + collections: collections || [], + isLoading, + hasCollections: Array.isArray( collections ) && collections.length > 0, + }; + }, + [ query ] + ); +}; diff --git a/src/blocks/collections/hooks/usePreventNav.js b/src/blocks/collections/hooks/usePreventNav.js new file mode 100644 index 0000000000..04969c78e0 --- /dev/null +++ b/src/blocks/collections/hooks/usePreventNav.js @@ -0,0 +1,15 @@ +import { useCallback } from '@wordpress/element'; + +/** + * Hook returning a stable handler that prevents default navigation. + * Use for dummy anchor tags in editor preview. + * + * @return {(e: Event) => void} Stable callback preventing default. + */ +const usePreventNav = () => { + return useCallback( e => { + e.preventDefault(); + }, [] ); +}; + +export default usePreventNav; diff --git a/src/blocks/collections/index.js b/src/blocks/collections/index.js new file mode 100644 index 0000000000..425d3fe3ac --- /dev/null +++ b/src/blocks/collections/index.js @@ -0,0 +1,44 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import metadata from './block.json'; +import Edit from './edit'; +import { collections as icon } from '../../../packages/icons'; +import colors from '../../../packages/colors/colors.module.scss'; +import './style.scss'; + +export const title = __( 'Collections', 'newspack-plugin' ); + +const { name } = metadata; + +export { metadata, name }; + +export const settings = { + title, + icon: { + src: icon, + foreground: colors[ 'primary-400' ], + }, + keywords: [ + __( 'collections', 'newspack-plugin' ), + __( 'issues', 'newspack-plugin' ), + __( 'magazine', 'newspack-plugin' ), + __( 'publications', 'newspack-plugin' ), + __( 'content', 'newspack-plugin' ), + __( 'loop', 'newspack-plugin' ), + __( 'query', 'newspack-plugin' ), + __( 'latest', 'newspack-plugin' ), + __( 'newspack', 'newspack-plugin' ), + ], + description: __( + 'An advanced block that allows displaying collections based on different parameters and visual configurations.', + 'newspack-plugin' + ), + edit: Edit, + save: () => null, // Server-side rendered block. +}; diff --git a/src/blocks/collections/index.php b/src/blocks/collections/index.php new file mode 100644 index 0000000000..7114513c50 --- /dev/null +++ b/src/blocks/collections/index.php @@ -0,0 +1,11 @@ + { + return apiFetch( { + path: addQueryArgs( ENDPOINTS.categories, { + search, + per_page: 20, + _fields: 'id,name,parent', + orderby: 'count', + order: 'desc', + } ), + } ).then( categories => + Promise.all( + categories.map( category => { + if ( category.parent > 0 ) { + return apiFetch( { + path: addQueryArgs( `${ ENDPOINTS.categories }/${ category.parent }`, { + _fields: 'name', + } ), + } ).then( parentCategory => ( { + value: category.id, + label: `${ decodeEntities( category.name ) } – ${ decodeEntities( parentCategory.name ) }`, + } ) ); + } + return Promise.resolve( { + value: category.id, + label: decodeEntities( category.name ), + } ); + } ) + ) + ); +}; + +export const fetchSavedCategories = categoryIDs => { + if ( ! categoryIDs.length ) { + return Promise.resolve( [] ); + } + + return apiFetch( { + path: addQueryArgs( ENDPOINTS.categories, { + per_page: 100, + _fields: 'id,name', + include: categoryIDs.join( ',' ), + } ), + } ).then( function ( categories ) { + const allCats = categories.map( category => ( { + value: category.id, + label: decodeEntities( category.name ), + } ) ); + + categoryIDs.forEach( catID => { + if ( ! allCats.find( cat => cat.value === parseInt( catID ) ) ) { + allCats.push( { + value: parseInt( catID ), + label: `(Deleted category - ID: ${ catID })`, + } ); + } + } ); + + return allCats; + } ); +}; + +export const fetchCollectionSuggestions = search => { + return apiFetch( { + path: addQueryArgs( ENDPOINTS.collections, { + search, + per_page: 20, + _fields: 'id,title', + orderby: 'title', + order: 'asc', + status: 'publish', + } ), + } ).then( collections => + collections.map( collection => ( { + value: collection.id, + label: decodeEntities( collection.title.rendered ), + } ) ) + ); +}; + +export const fetchSavedCollections = collectionIDs => { + if ( ! collectionIDs.length ) { + return Promise.resolve( [] ); + } + + return apiFetch( { + path: addQueryArgs( ENDPOINTS.collections, { + per_page: 100, + _fields: 'id,title', + include: collectionIDs.join( ',' ), + status: 'publish', + } ), + } ).then( collections => { + const allCollections = collections.map( collection => ( { + value: collection.id, + label: decodeEntities( collection.title.rendered ), + } ) ); + + collectionIDs.forEach( collectionID => { + if ( ! allCollections.find( collection => collection.value === parseInt( collectionID ) ) ) { + allCollections.push( { + value: parseInt( collectionID ), + label: `(Deleted collection - ID: ${ collectionID })`, + } ); + } + } ); + + return allCollections; + } ); +}; diff --git a/src/blocks/index.js b/src/blocks/index.js index 50bd7a2e33..020b425f0e 100644 --- a/src/blocks/index.js +++ b/src/blocks/index.js @@ -12,16 +12,18 @@ import * as readerRegistration from './reader-registration'; import * as correctionBox from './correction-box'; import * as correctionItem from './correction-item'; import * as avatar from './avatar'; +import * as collections from './collections'; /** * Block Scripts */ import './core-image'; -export const blocks = [ readerRegistration, correctionBox, correctionItem, avatar ]; +export const blocks = [ readerRegistration, correctionBox, correctionItem, avatar, collections ]; const readerActivationBlocks = [ 'newspack/reader-registration' ]; const correctionBlocks = [ 'newspack/correction-box', 'newspack/correction-item' ]; +const collectionsBlocks = [ 'newspack/collections' ]; /** * Function to register an individual block. @@ -45,6 +47,11 @@ const registerBlock = block => { return; } + /** Do not register collections blocks if Collections module is disabled. */ + if ( collectionsBlocks.includes( name ) && ! newspack_blocks.collections_enabled ) { + return; + } + registerBlockType( { name, ...metadata }, settings ); }; diff --git a/src/components/src/autocomplete-tokenfield/index.js b/src/components/src/autocomplete-tokenfield/index.js index 4acb8163ca..695238edce 100644 --- a/src/components/src/autocomplete-tokenfield/index.js +++ b/src/components/src/autocomplete-tokenfield/index.js @@ -207,7 +207,7 @@ class AutocompleteTokenField extends Component { * Render. */ render() { - const { help, label = '', placeholder = '', maxLength } = this.props; + const { help, label = '', placeholder = '', maxLength, style, __next40pxDefaultSize } = this.props; const { suggestions, loading } = this.state; const classNames = [ 'newspack-autocomplete-tokenfield__input-container' ]; @@ -217,7 +217,7 @@ class AutocompleteTokenField extends Component { } return ( -
+
{ loading && }