From 9be6e7ebae9028407d67071e13857ab7827deff9 Mon Sep 17 00:00:00 2001 From: Derrick Koo Date: Tue, 27 Oct 2020 11:58:43 -0600 Subject: [PATCH] feat: new Curated List block, block pattern, and map functionality (#3) * feat: initial post type and block setup Sets up custom post types and Listing block prototype. * feat: improvements to block structure and meta Instead of just registering a single Listing block and selecting the type in the parent post, this registers a separate block for each listing type (all of which use the same code). * fix: error after fetching post * feat: handle meta programmatically, add permalink prefix * fix: pull request feedback; refactor post types config Addresses pull request feedback and refactors post types config into a more DRY treatment. * chore: use permalink slugs defined in constant; spaces in JSON * feat: add Curated List block; remove Curated List CPT This removes the Curated List CPT in favor of a wrapper block that can be inserted into any other post or page. This block can contain Listing blocks to create curated lists anywhere, and can be converted to a Reusable Block if it needs to be reusable. * chore: update class docblocks, move blocks to newspack category * chore: remove unused block_categories filter * fix: remove fse action * feat: listing InnerBlocks inherit parent Curated List attributes Lays out a method of passing attributes data from the Curated List block to its child Listing inner blocks, without relying on contexts. We basically duplicate parent block attributes on each child block and sync the values of those attributes. * fix: wrong selector in sidebar JS * feat: change curated list to dynamic render * feat: add "business" block pattern for Marketplace listings This establishes a pattern for providing block patterns for particular listings CPTs. This might let us provide structured content without having to register metadata fields for listing content. * feat: map functionality via Jetpack Maps block Adds support for location-based data using the jetpack/maps block. If a listing CPT of any type contains a jetpack/map block with location data, the location data is used to show the listing on a map for any Curated List blocks containing the listing post. * feat: check for jetpack/maps block before using * feat: add Jetpack Contact Info block to Business Listing block pattern * feat: sync block content to post meta Sets up a sync from certain content blocks to post meta fields. This will help us more easily implement search/filter functionality for listing posts on a large scale. * fix: use listing post meta to get locations in curated list block * chore: phpdoc types should be lowercase * feat: don't register Curated List block in listings CPTs We want to avoid letting editors nest Curated Lists inside list items which could be nested inside Curated Lists which could be inside list items which could be... etc. * fix: css fixes for compatibility with core block editor (sans plugin) * fix: listing block parent should be list container, not curated list * fix: callable for settings fields * fix: block patterns for core WP Block patterns and block pattern categories can only be registered on init or admin_init hooks in WP 5.5. * chore: remove redundant docblock comment * fix: flush permalinks only after registering CPTs --- includes/class-newspack-listings-api.php | 43 +- includes/class-newspack-listings-blocks.php | 135 +++-- includes/class-newspack-listings-core.php | 474 ++++++++++++------ includes/class-newspack-listings-settings.php | 10 +- includes/newspack-listings-utils.php | 103 +++- newspack-listings.php | 8 +- package-lock.json | 142 +++--- package.json | 2 +- src/blocks/category.js | 30 ++ src/blocks/curated-list/block.json | 85 ++++ src/blocks/curated-list/edit.js | 332 ++++++++++++ src/blocks/curated-list/editor.scss | 36 ++ src/blocks/curated-list/index.js | 34 ++ src/blocks/curated-list/save.js | 34 ++ src/blocks/index.js | 3 + src/blocks/list-container/edit.js | 32 ++ src/blocks/list-container/index.js | 36 ++ src/blocks/list-container/view.php | 60 +++ src/blocks/listing/block.json | 69 +-- src/blocks/listing/edit.js | 284 +++-------- src/blocks/listing/editor.scss | 21 +- src/blocks/listing/index.js | 7 +- src/blocks/listing/view.php | 63 ++- src/components/autocomplete-tokenfield.js | 2 +- src/components/index.js | 1 + src/components/newspack-logo.js | 17 + src/editor/index.js | 32 +- src/editor/sidebar/index.js | 4 +- src/editor/style.scss | 7 +- src/editor/utils.js | 24 + src/listing-styles/index.js | 4 + src/listing-styles/listings/curated-list.scss | 13 + src/listing-styles/listings/listing.scss | 11 + src/listing-styles/listings/view.scss | 2 + webpack.config.js | 3 +- 35 files changed, 1524 insertions(+), 639 deletions(-) create mode 100644 src/blocks/category.js create mode 100644 src/blocks/curated-list/block.json create mode 100644 src/blocks/curated-list/edit.js create mode 100644 src/blocks/curated-list/editor.scss create mode 100644 src/blocks/curated-list/index.js create mode 100644 src/blocks/curated-list/save.js create mode 100644 src/blocks/list-container/edit.js create mode 100644 src/blocks/list-container/index.js create mode 100644 src/blocks/list-container/view.php create mode 100644 src/components/newspack-logo.js create mode 100644 src/editor/utils.js create mode 100644 src/listing-styles/index.js create mode 100644 src/listing-styles/listings/curated-list.scss create mode 100644 src/listing-styles/listings/listing.scss create mode 100644 src/listing-styles/listings/view.scss diff --git a/includes/class-newspack-listings-api.php b/includes/class-newspack-listings-api.php index 8b980704..756f981d 100644 --- a/includes/class-newspack-listings-api.php +++ b/includes/class-newspack-listings-api.php @@ -1,8 +1,8 @@ $post->ID, + 'title' => $post->post_title, + ]; - // If $fields includes meta, get all Newspack Listings meta fields. - $post_meta = []; + // If $fields includes excerpt, get the post excerpt. + if ( in_array( 'excerpt', $fields ) ) { + $response['excerpt'] = wpautop( get_the_excerpt( $post->ID ) ); + } + + // If $fields includes media, get the featured image + caption. + if ( in_array( 'media', $fields ) ) { + $response['media'] = [ + 'image' => get_the_post_thumbnail_url( $post->ID, 'medium' ), + 'caption' => get_the_post_thumbnail_caption( $post->ID ), + ]; + } + // If $fields includes meta, get all Newspack Listings meta fields. if ( in_array( 'meta', $fields ) ) { - $post_meta = array_filter( - get_post_meta( $post->ID ), - function( $key ) { - return is_numeric( strpos( $key, 'newspack_listings_' ) ); - }, - ARRAY_FILTER_USE_KEY - ); + $post_meta = Core::get_meta_values( $post->ID, $post->post_type ); + + if ( ! empty( $post_meta ) ) { + $response['meta'] = $post_meta; + } } - return [ - 'id' => $post->ID, - 'title' => $post->post_title, - 'content' => wpautop( get_the_excerpt( $post->ID ) ), - 'meta' => $post_meta, - ]; + return $response; }, $query->posts ), diff --git a/includes/class-newspack-listings-blocks.php b/includes/class-newspack-listings-blocks.php index 9e132521..29f9fddb 100644 --- a/includes/class-newspack-listings-blocks.php +++ b/includes/class-newspack-listings-blocks.php @@ -1,8 +1,8 @@ get_post_type_object( $post_type )->labels->singular_name, - 'post_types' => Core::NEWSPACK_LISTINGS_POST_TYPES, - 'meta_fields' => Core::get_meta_fields( $post_type ), - ] - ); - - wp_register_style( - 'newspack-listings-editor', - plugins_url( '../dist/editor.css', __FILE__ ), - [], - NEWSPACK_LISTINGS_VERSION - ); - wp_style_add_data( 'newspack-listings-editor', 'rtl', 'replace' ); - wp_enqueue_style( 'newspack-listings-editor' ); - } + wp_enqueue_script( + 'newspack-listings-editor', + NEWSPACK_LISTINGS_URL . 'dist/editor.js', + [], + NEWSPACK_LISTINGS_VERSION, + true + ); + + $post_type = get_post_type(); + + wp_localize_script( + 'newspack-listings-editor', + 'newspack_listings_data', + [ + 'post_type_label' => get_post_type_object( $post_type )->labels->singular_name, + 'post_type' => $post_type, + 'post_types' => Core::NEWSPACK_LISTINGS_POST_TYPES, + 'meta_fields' => Core::get_meta_fields( $post_type ), + ] + ); + + wp_register_style( + 'newspack-listings-editor', + plugins_url( '../dist/editor.css', __FILE__ ), + [], + NEWSPACK_LISTINGS_VERSION + ); + wp_style_add_data( 'newspack-listings-editor', 'rtl', 'replace' ); + wp_enqueue_style( 'newspack-listings-editor' ); } /** * Enqueue front-end assets. */ public static function manage_view_assets() { + // Do nothing in editor environment. if ( is_admin() ) { - // In editor environment, do nothing. return; } @@ -127,22 +131,59 @@ public static function manage_view_assets() { } /** - * Add custom block category. - * - * @param array $categories Default Gutenberg categories. - * @return array + * Register custom block pattern category for Newspack Listings. */ - public static function update_block_categories( $categories ) { - return array_merge( - $categories, - [ - [ - 'slug' => 'newspack-listings', - 'title' => __( 'Newspack Listings', 'newspack-listings' ), - ], - ] + public static function register_block_pattern_category() { + return register_block_pattern_category( + self::NEWSPACK_LISTINGS_BLOCK_PATTERN_CATEGORY, + [ 'label' => __( 'Newspack Listings', 'newspack-listings' ) ] ); } + + /** + * Register custom block patterns for Newspack Listings. + * These patterns should only be available for certain CPTs. + */ + public static function register_block_patterns() { + // Block pattern config. + $block_patterns = [ + 'business' => [ + 'post_types' => [ + Core::NEWSPACK_LISTINGS_POST_TYPES['marketplace'], + Core::NEWSPACK_LISTINGS_POST_TYPES['place'], + ], + 'settings' => [ + 'title' => __( 'Business Listing', 'newspack-listings' ), + 'categories' => [ self::NEWSPACK_LISTINGS_BLOCK_PATTERN_CATEGORY ], + 'description' => _x( + 'Business description, website and social media links, and hours of operation.', + 'Block pattern description', + 'newspack-listings' + ), + 'content' => '

Consectetur a urna hendrerit scelerisque suspendisse inceptos scelerisque neque parturient a mi adipiscing euismod mus. Ad felis morbi magna augue consectetur eleifend sit sem habitant suspendisse posuere amet felis adipiscing a himenaeos ipsum vivamus dictum vestibulum lacus consectetur vestibulum erat dignissim per sem integer. Cras class ac adipiscing inceptos a enim porta a elit scelerisque tincidunt hac ad netus accumsan parturient conubia vestibulum nec quisque parturient interdum fringilla curabitur cras sociosqu interdum. Porta aenean id a mus consectetur lacus lacus ut parturient sapien ut a sociosqu potenti ridiculus non tristique cursus a at parturient condimentum a duis convallis per. Dictum elementum ultricies ac risus vestibulum adipiscing placerat imperdiet malesuada scelerisque dictum mus adipiscing a at at fermentum scelerisque nisl a dignissim suscipit sapien taciti nulla curabitur vestibulum.

+
+

Hours of Operation

', + ], + ], + ]; + + /** + * Register block patterns for particular post types. We need to get the post type using the + * post ID from $_REQUEST since the global $post is not available inside the admin_init hook. + * If we can't determine the current post type, just register the patterns anyway. + */ + $post_id = isset( $_REQUEST['post'] ) ? sanitize_text_field( $_REQUEST['post'] ) : null; // phpcs:ignore WordPress.Security.NonceVerification.Recommended + $current_post_type = ! empty( $post_id ) && function_exists( 'get_post_type' ) ? get_post_type( $post_id ) : null; + + foreach ( $block_patterns as $pattern_name => $config ) { + if ( empty( $current_post_type ) || in_array( $current_post_type, $config['post_types'] ) ) { + $pattern = register_block_pattern( + 'newspack-listings/' . $pattern_name, + $config['settings'] + ); + } + } + } } Newspack_Listings_Blocks::instance(); diff --git a/includes/class-newspack-listings-core.php b/includes/class-newspack-listings-core.php index 4c0caad6..b2ec12dd 100644 --- a/includes/class-newspack-listings-core.php +++ b/includes/class-newspack-listings-core.php @@ -2,7 +2,7 @@ /** * Newspack Listings Core. * - * Registers custom post types and taxonomies. + * Registers custom post types and metadata. * * @package Newspack_Listings */ @@ -10,6 +10,7 @@ namespace Newspack_Listings; use \Newspack_Listings\Newspack_Listings_Settings as Settings; +use \Newspack_Listings\Utils as Utils; defined( 'ABSPATH' ) || exit; @@ -23,11 +24,10 @@ final class Newspack_Listings_Core { * Custom post type slugs for Newspack Listings. */ const NEWSPACK_LISTINGS_POST_TYPES = [ - 'curated_list' => 'newspack_lst_curated', - 'event' => 'newspack_lst_event', - 'generic' => 'newspack_lst_generic', - 'marketplace' => 'newspack_lst_mktplce', - 'place' => 'newspack_lst_place', + 'event' => 'newspack_lst_event', + 'generic' => 'newspack_lst_generic', + 'marketplace' => 'newspack_lst_mktplce', + 'place' => 'newspack_lst_place', ]; @@ -35,11 +35,10 @@ final class Newspack_Listings_Core { * Permalink slugs for Newspack Listings CPTs. */ const NEWSPACK_LISTINGS_PERMALINK_SLUGS = [ - 'curated_list' => 'lists', - 'event' => 'events', - 'generic' => 'items', - 'marketplace' => 'markeptlace', - 'place' => 'places', + 'event' => 'events', + 'generic' => 'items', + 'marketplace' => 'marketplace', + 'place' => 'places', ]; @@ -69,8 +68,10 @@ public static function instance() { public function __construct() { add_action( 'admin_menu', [ __CLASS__, 'add_plugin_page' ] ); add_action( 'init', [ __CLASS__, 'register_post_types' ] ); - add_filter( 'allowed_block_types', [ __CLASS__, 'restrict_block_types' ] ); - add_filter( 'the_content', [ __CLASS__, 'add_list_wrapper_tags' ], 20 ); + add_action( 'wp_enqueue_scripts', [ __CLASS__, 'custom_styles' ] ); + add_filter( 'single_template', [ __CLASS__, 'set_default_template' ] ); + add_action( 'save_post', [ __CLASS__, 'sync_post_meta' ], 10, 2 ); + register_activation_hook( NEWSPACK_LISTINGS_FILE, [ __CLASS__, 'activation_hook' ] ); } /** @@ -92,30 +93,23 @@ public static function add_plugin_page() { __( 'Settings', 'newspack-listings' ), 'manage_options', 'newspack-listings-settings-admin', - [ __CLASS__, 'create_admin_page' ] + [ '\Newspack_Listings\Newspack_Listings_Settings', 'create_admin_page' ] ); } - /** - * Is the current post a curated list post type? - * - * @return Boolean Whehter or not the post is a Curated List. - */ - public static function is_curated_list() { - return get_post_type() === self::NEWSPACK_LISTINGS_POST_TYPES['curated_list']; - } - /** * Is the current post a listings post type? * + * @param string|null $post_type (Optional) Post type to check. If not given, will use the current global post object. + * * @returns Boolean Whether or not the current post type matches one of the listings CPTs. */ - public static function is_listing() { - if ( self::is_curated_list() ) { - return false; + public static function is_listing( $post_type = null ) { + if ( null === $post_type ) { + $post_type = get_post_type(); } - if ( in_array( get_post_type(), self::NEWSPACK_LISTINGS_POST_TYPES ) ) { + if ( in_array( $post_type, self::NEWSPACK_LISTINGS_POST_TYPES ) ) { return true; } @@ -135,26 +129,7 @@ public static function register_post_types() { 'supports' => [ 'editor', 'excerpt', 'title', 'custom-fields', 'thumbnail' ], ]; $post_types_config = [ - 'curated_list' => [ - 'labels' => [ - 'name' => _x( 'Curated Lists', 'post type general name', 'newspack-listings' ), - 'singular_name' => _x( 'Curated List', 'post type singular name', 'newspack-listings' ), - 'menu_name' => _x( 'Curated Lists', 'admin menu', 'newspack-listings' ), - 'name_admin_bar' => _x( 'Curated List', 'add new on admin bar', 'newspack-listings' ), - 'add_new' => _x( 'Add New', 'popup', 'newspack-listings' ), - 'add_new_item' => __( 'Add New Curated List', 'newspack-listings' ), - 'new_item' => __( 'New Curated List', 'newspack-listings' ), - 'edit_item' => __( 'Edit Curated List', 'newspack-listings' ), - 'view_item' => __( 'View Curated List', 'newspack-listings' ), - 'all_items' => __( 'Curated Lists', 'newspack-listings' ), - 'search_items' => __( 'Search Curated Lists', 'newspack-listings' ), - 'parent_item_colon' => __( 'Parent curated list:', 'newspack-listings' ), - 'not_found' => __( 'No curated lists found.', 'newspack-listings' ), - 'not_found_in_trash' => __( 'No curated lists found in Trash.', 'newspack-listings' ), - ], - 'rewrite' => [ 'slug' => $prefix . '/' . self::NEWSPACK_LISTINGS_PERMALINK_SLUGS['curated_list'] ], - ], - 'event' => [ + 'event' => [ 'labels' => [ 'name' => _x( 'Events', 'post type general name', 'newspack-listings' ), 'singular_name' => _x( 'Event', 'post type singular name', 'newspack-listings' ), @@ -173,7 +148,7 @@ public static function register_post_types() { ], 'rewrite' => [ 'slug' => $prefix . '/' . self::NEWSPACK_LISTINGS_PERMALINK_SLUGS['event'] ], ], - 'generic' => [ + 'generic' => [ 'labels' => [ 'name' => _x( 'Generic Listings', 'post type general name', 'newspack-listings' ), 'singular_name' => _x( 'Listing', 'post type singular name', 'newspack-listings' ), @@ -192,7 +167,7 @@ public static function register_post_types() { ], 'rewrite' => [ 'slug' => $prefix . '/' . self::NEWSPACK_LISTINGS_PERMALINK_SLUGS['generic'] ], ], - 'marketplace' => [ + 'marketplace' => [ 'labels' => [ 'name' => _x( 'Marketplace', 'post type general name', 'newspack-listings' ), 'singular_name' => _x( 'Marketplace Listing', 'post type singular name', 'newspack-listings' ), @@ -211,7 +186,7 @@ public static function register_post_types() { ], 'rewrite' => [ 'slug' => $prefix . '/' . self::NEWSPACK_LISTINGS_PERMALINK_SLUGS['marketplace'] ], ], - 'place' => [ + 'place' => [ 'labels' => [ 'name' => _x( 'Places', 'post type general name', 'newspack-listings' ), 'singular_name' => _x( 'Place', 'post type singular name', 'newspack-listings' ), @@ -241,10 +216,10 @@ public static function register_post_types() { // Register meta fields for this post type. $meta_fields = self::get_meta_fields( $post_type ); - foreach ( $meta_fields as $name => $meta_field ) { + foreach ( $meta_fields as $field_name => $meta_field ) { register_meta( 'post', - $name, + $field_name, $meta_field['settings'] ); } @@ -254,135 +229,236 @@ public static function register_post_types() { } } - /** - * Restrict block types allowed for Curated Lists. - * - * @param Array|Boolean $allowed_blocks Array of allowed block types, or true for all core blocks. - * @return Array Array of only the allowed blocks for this post type. - */ - public static function restrict_block_types( $allowed_blocks ) { - if ( self::is_curated_list() ) { - return [ - 'newspack-listings/event', - 'newspack-listings/generic', - 'newspack-listings/marketplace', - 'newspack-listings/place', - ]; - } - - return $allowed_blocks; - } - /** * Define and return meta fields for any Newspack Listings CPTs. * - * @param String $post_type Post type to get corresponding meta fields. - * @return Array Array of meta fields for the given $post_type. + * @param string $post_type Post type to get corresponding meta fields. + * @param boolean $field_names_only (Optional) If true, return an array of just the field names without config. + * @return array Array of meta fields for the given $post_type. */ - public static function get_meta_fields( $post_type = null ) { + public static function get_meta_fields( $post_type = null, $field_names_only = false ) { if ( empty( $post_type ) ) { return []; } $all_meta_fields = [ - /** - * Curated List metadata. - */ - 'newspack_listings_show_numbers' => [ - 'post_types' => [ self::NEWSPACK_LISTINGS_POST_TYPES['curated_list'] ], - 'label' => __( 'Show numbers?', 'newspack-listings' ), - 'type' => 'toggle', + 'newspack_listings_contact_email' => [ + 'post_types' => [ + self::NEWSPACK_LISTINGS_POST_TYPES['event'], + self::NEWSPACK_LISTINGS_POST_TYPES['generic'], + self::NEWSPACK_LISTINGS_POST_TYPES['marketplace'], + self::NEWSPACK_LISTINGS_POST_TYPES['place'], + ], + 'label' => __( 'Contact email address', 'newspack-listings' ), + 'type' => 'input', + 'source' => [ + 'blockName' => 'jetpack/email', + 'attr' => 'email', + ], 'settings' => [ 'object_subtype' => $post_type, - 'default' => true, - 'description' => __( 'Display numbers for the items in this list.', 'newspack-listings' ), - 'type' => 'boolean', - 'sanitize_callback' => 'rest_sanitize_boolean', - 'single' => true, - 'show_in_rest' => true, + 'default' => '', + 'description' => __( 'Email address to contact for this listing.', 'newspack-listings' ), + 'type' => 'array', + 'sanitize_callback' => 'Utils\sanitize_array', + 'single' => false, + 'show_in_rest' => [ + 'schema' => [ + 'type' => 'array', + 'items' => [ + 'type' => 'string', + ], + ], + ], 'auth_callback' => function() { return current_user_can( 'edit_posts' ); }, ], ], - 'newspack_listings_show_map' => [ - 'post_types' => [ self::NEWSPACK_LISTINGS_POST_TYPES['curated_list'] ], - 'label' => __( 'Show map?', 'newspack-listings' ), - 'type' => 'toggle', + 'newspack_listings_contact_phone' => [ + 'post_types' => [ + self::NEWSPACK_LISTINGS_POST_TYPES['event'], + self::NEWSPACK_LISTINGS_POST_TYPES['generic'], + self::NEWSPACK_LISTINGS_POST_TYPES['marketplace'], + self::NEWSPACK_LISTINGS_POST_TYPES['place'], + ], + 'label' => __( 'Contact phone number', 'newspack-listings' ), + 'type' => 'input', + 'source' => [ + 'blockName' => 'jetpack/phone', + 'attr' => 'phone', + ], 'settings' => [ 'object_subtype' => $post_type, - 'default' => true, - 'description' => __( 'Display a map with this list if at least one listing has geolocation data.', 'newspack-listings' ), - 'type' => 'boolean', - 'sanitize_callback' => 'rest_sanitize_boolean', - 'single' => true, - 'show_in_rest' => true, + 'default' => '', + 'description' => __( 'Phone number to contact for this listing.', 'newspack-listings' ), + 'type' => 'array', + 'sanitize_callback' => 'Utils\sanitize_array', + 'single' => false, + 'show_in_rest' => [ + 'schema' => [ + 'type' => 'array', + 'items' => [ + 'type' => 'string', + ], + ], + ], 'auth_callback' => function() { return current_user_can( 'edit_posts' ); }, ], - 'type' => 'toggle', ], - 'newspack_listings_show_sort_by_date' => [ - 'post_types' => [ self::NEWSPACK_LISTINGS_POST_TYPES['curated_list'] ], - 'label' => __( 'Show sort-by-date UI?', 'newspack-listings' ), - 'type' => 'toggle', + 'newspack_listings_contact_address' => [ + 'post_types' => [ + self::NEWSPACK_LISTINGS_POST_TYPES['event'], + self::NEWSPACK_LISTINGS_POST_TYPES['generic'], + self::NEWSPACK_LISTINGS_POST_TYPES['marketplace'], + self::NEWSPACK_LISTINGS_POST_TYPES['place'], + ], + 'label' => __( 'Contact Address', 'newspack-listings' ), + 'type' => 'input', + 'source' => [ 'blockName' => 'jetpack/address' ], 'settings' => [ 'object_subtype' => $post_type, - 'default' => false, - 'description' => __( 'Display sort-by-date controls (only applicable to lists of events).', 'newspack-listings' ), - 'type' => 'boolean', - 'sanitize_callback' => 'rest_sanitize_boolean', - 'single' => true, - 'show_in_rest' => true, - 'auth_callback' => function() { - return current_user_can( 'edit_posts' ); - }, + 'default' => '', + 'description' => __( 'Contact address for this listing.', 'newspack-listings' ), + 'type' => 'array', + 'sanitize_callback' => 'Utils\sanitize_array', + 'single' => false, + 'show_in_rest' => [ + 'schema' => [ + 'type' => 'array', + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'address' => [ + 'type' => 'string', + ], + 'addressLine2' => [ + 'type' => 'string', + ], + 'addressLine3' => [ + 'type' => 'string', + ], + 'city' => [ + 'type' => 'string', + ], + 'region' => [ + 'type' => 'string', + ], + 'postal' => [ + 'type' => 'string', + ], + 'country' => [ + 'type' => 'string', + ], + ], + ], + ], + ], ], ], - - /** - * Metadata for various listing types. - */ - 'newspack_listings_contact_email' => [ + 'newspack_listings_business_hours' => [ 'post_types' => [ self::NEWSPACK_LISTINGS_POST_TYPES['event'], - self::NEWSPACK_LISTINGS_POST_TYPES['generic'], self::NEWSPACK_LISTINGS_POST_TYPES['marketplace'], self::NEWSPACK_LISTINGS_POST_TYPES['place'], ], - 'label' => __( 'Email address', 'newspack-listings' ), + 'label' => __( 'Hours of Operation', 'newspack-listings' ), 'type' => 'input', + 'source' => [ + 'blockName' => 'jetpack/business-hours', + 'attr' => 'days', + ], 'settings' => [ 'object_subtype' => $post_type, 'default' => '', - 'description' => __( 'Email address to contact for this listing.', 'newspack-listings' ), - 'type' => 'string', - 'sanitize_callback' => 'sanitize_text_field', - 'single' => true, - 'show_in_rest' => true, - 'auth_callback' => function() { - return current_user_can( 'edit_posts' ); - }, + 'description' => __( 'Hours of operation for this listing.', 'newspack-listings' ), + 'type' => 'array', + 'sanitize_callback' => 'Utils\sanitize_array', + 'single' => false, + 'show_in_rest' => [ + 'schema' => [ + 'type' => 'array', + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'name' => [ + 'type' => 'string', + ], + 'hours' => [ + 'type' => 'array', + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'opening' => [ + 'type' => 'string', + ], + 'closing' => [ + 'type' => 'string', + ], + ], + ], + ], + ], + ], + ], + ], ], ], - 'newspack_listings_contact_phone' => [ + 'newspack_listings_locations' => [ 'post_types' => [ self::NEWSPACK_LISTINGS_POST_TYPES['event'], self::NEWSPACK_LISTINGS_POST_TYPES['generic'], self::NEWSPACK_LISTINGS_POST_TYPES['marketplace'], self::NEWSPACK_LISTINGS_POST_TYPES['place'], ], - 'label' => __( 'Phone number', 'newspack-listings' ), + 'label' => __( 'Locations', 'newspack-listings' ), 'type' => 'input', + 'source' => [ + 'blockName' => 'jetpack/map', + 'attr' => 'points', + ], 'settings' => [ 'object_subtype' => $post_type, 'default' => '', - 'description' => __( 'Phone number to contact for this listing.', 'newspack-listings' ), - 'type' => 'string', - 'sanitize_callback' => 'sanitize_text_field', - 'single' => true, - 'show_in_rest' => true, + 'description' => __( 'Geolocation data for this listing.', 'newspack-listings' ), + 'type' => 'array', + 'sanitize_callback' => 'Utils\sanitize_array', + 'single' => false, + 'show_in_rest' => [ + 'schema' => [ + 'type' => 'array', + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'placeTitle' => [ + 'type' => 'string', + ], + 'title' => [ + 'type' => 'string', + ], + 'caption' => [ + 'type' => 'string', + ], + 'id' => [ + 'type' => 'string', + ], + 'coordinates' => [ + 'type' => 'object', + 'properties' => [ + 'latitude' => [ + 'type' => 'number', + ], + 'longitude' => [ + 'type' => 'number', + ], + ], + ], + ], + ], + ], + ], 'auth_callback' => function() { return current_user_can( 'edit_posts' ); }, @@ -391,39 +467,137 @@ public static function get_meta_fields( $post_type = null ) { ]; // Return only the fields that are associated with the given $post_type. - return array_filter( + $matching_fields = array_filter( $all_meta_fields, function( $meta_field ) use ( $post_type ) { return in_array( $post_type, $meta_field['post_types'] ); } ); + + if ( false === $field_names_only ) { + return $matching_fields; + } else { + return array_keys( $matching_fields ); + } } /** - * Wrap post content with
    tags. + * Given a post ID and post type, get values for all corresponding Listings meta fields. + * + * @param int|null $post_id (Optional) ID for the listing post. + * @param string|null $post_type (Optional) Post type. * - * @param string $content Content to be filtered. - * @return string Filtered content. + * @return array|boolean Post meta data, or false if post given is not a listing. */ - public static function add_list_wrapper_tags( $content ) { - if ( self::is_curated_list() && is_singular() && in_the_loop() && is_main_query() ) { - $post_id = get_the_ID(); - $show_map = get_post_meta( $post_id, 'newspack_listings_show_map', true ); - $show_numbers = get_post_meta( $post_id, 'newspack_listings_show_numbers', true ); - $classes = 'newspack-listings__curated-list'; - - if ( ! empty( $show_map ) ) { - $classes .= ' newspack-listings__show-map'; + public static function get_meta_values( $post_id = null, $post_type = null ) { + if ( null === $post_id ) { + $post_id = get_the_ID(); + } + + if ( null === $post_type ) { + $post_type = get_post_type( $post_id ); + } + + if ( ! self::is_listing( $post_type ) ) { + return false; + } + + $meta_fields = self::get_meta_fields( $post_type, true ); + $meta_values = []; + + foreach ( $meta_fields as $meta_field ) { + $data = get_post_meta( $post_id, $meta_field, true ); + + if ( ! empty( $data ) ) { + $meta_values[ $meta_field ] = $data; } + } + + return $meta_values; + } - if ( ! empty( $show_numbers ) ) { - $classes .= ' newspack-listings__show-numbers'; + /** + * Sync data from specific content blocks to post meta. + * Source blocks for each meta field are set in the meta config above. + * + * @param int $post_id ID of the post being created or updated. + * @param array $post Post object of the post being created or updated. + */ + public static function sync_post_meta( $post_id, $post ) { + if ( ! self::is_listing( $post->post_type ) ) { + return; + } + + $blocks = parse_blocks( $post->post_content ); + $meta_fields = self::get_meta_fields( $post->post_type ); + + foreach ( $meta_fields as $field_name => $meta_field ) { + $source = $meta_field['source']; + $data_to_sync = Utils\get_data_from_blocks( $blocks, $source ); + + /* + * If there are no blocks matching the source, clear the field. + * This prevents garbage data from persisting if a block is removed + * after its data has already been saved as post meta. + */ + if ( false === $data_to_sync ) { + delete_post_meta( $post_id, $field_name ); + } else { + update_post_meta( $post_id, $field_name, $data_to_sync ); } + } + } - $content = '
      ' . $content . '
    '; + /** + * Enqueue custom styles for Newspack Listings front-end components. + */ + public static function custom_styles() { + if ( ! is_admin() ) { + wp_register_style( + 'newspack-listings-styles', + plugins_url( '../dist/front_end.css', __FILE__ ), + [], + NEWSPACK_LISTINGS_VERSION + ); + + wp_enqueue_style( 'newspack-listings-styles' ); } + } - return $content; + /** + * If using a Newspack theme, force single listings pages to use the wide template (sans widget sidebar). + * + * @param string $template File path of the template to use for the current single post. + * @return string Filtered template file path. + */ + public static function set_default_template( $template ) { + if ( self::is_listing() ) { + $wide_template = str_replace( 'single.php', 'single-wide.php', $template ); + + if ( file_exists( $wide_template ) ) { + $template = $wide_template; + + // Add the single-wide CSS class to the body. + add_filter( + 'body_class', + function( $classes ) { + $classes[] = 'post-template-single-wide'; + + return $classes; + } + ); + } + } + + return $template; + } + + /** + * Flush permalinks on plugin activation, ensuring that post types and taxonomies are registered first. + */ + public static function activation_hook() { + self::register_post_types(); + flush_rewrite_rules(); // phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.flush_rewrite_rules_flush_rewrite_rules } } diff --git a/includes/class-newspack-listings-settings.php b/includes/class-newspack-listings-settings.php index ad6dd287..ac3d88af 100644 --- a/includes/class-newspack-listings-settings.php +++ b/includes/class-newspack-listings-settings.php @@ -23,7 +23,7 @@ public static function init() { /** * Default values for site-wide settings. * - * @return Array Array of default settings. + * @return array Array of default settings. */ public static function get_default_settings() { $defaults = [ @@ -36,8 +36,8 @@ public static function get_default_settings() { /** * Get current site-wide settings, or defaults if not set. * - * @param String|null $option (Optional) Key name of a single setting to get. If not given, will return all settings. - * @return Array|Boolean Array of current site-wide settings, or false if returning a single option with no value. + * @param string|null $option (Optional) Key name of a single setting to get. If not given, will return all settings. + * @return array|Boolean Array of current site-wide settings, or false if returning a single option with no value. */ public static function get_settings( $option = null ) { $defaults = self::get_default_settings(); @@ -64,7 +64,7 @@ public static function get_settings( $option = null ) { /** * Get list of settings fields. * - * @return Array Settings list. + * @return array Settings list. */ public static function get_settings_list() { $defaults = self::get_default_settings(); @@ -126,7 +126,7 @@ public static function page_init() { /** * Render settings fields. * - * @param Array $setting Settings array. + * @param array $setting Settings array. */ public static function newspack_listings_settings_callback( $setting ) { $key = $setting['key']; diff --git a/includes/newspack-listings-utils.php b/includes/newspack-listings-utils.php index ba1f42a8..ccbf36be 100644 --- a/includes/newspack-listings-utils.php +++ b/includes/newspack-listings-utils.php @@ -7,23 +7,106 @@ namespace Newspack_Listings\Utils; -/** - * On plugin activation, flush permalinks. - */ -function activate() { - flush_rewrite_rules(); // phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.flush_rewrite_rules_flush_rewrite_rules -} - /** * Sanitize an array of text values. * - * @param Array $array Array of text values to be sanitized. - * @return Array Sanitized array. + * @param array $array Array of text or float values to be sanitized. + * @return array Sanitized array. */ function sanitize_array( $array ) { foreach ( $array as $key => $value ) { - $value = sanitize_text_field( $value ); + if ( is_array( $value ) ) { + $value = sanitize_array( $value ); + } else { + if ( is_string( $value ) ) { + $value = sanitize_text_field( $value ); + } else { + $value = floatval( $value ); + } + } } return $array; } + +/** + * Given a block name, get all blocks (and recursively, all inner blocks) matching the given type. + * + * @param string $block_name Block name to match. + * @param array $blocks Array of block objects to search. + * + * @return array Array of matching blocks. + */ +function get_blocks_by_type( $block_name, $blocks ) { + $matching_blocks = []; + + if ( empty( $block_name ) ) { + return $matching_blocks; + } + + foreach ( $blocks as $block ) { + if ( $block['blockName'] === $block_name ) { + $matching_blocks[] = $block; + } + + // Recursively check inner blocks, too. + if ( 0 < count( $block['innerBlocks'] ) ) { + $matching_blocks = array_merge( $matching_blocks, get_blocks_by_type( $block_name, $block['innerBlocks'] ) ); + } + } + + return $matching_blocks; +} + +/** + * Get data from content blocks within the given $post_id. + * Searches the content of the post for instances of the source block, + * then returns the given attributes for all block instances. + * + * @param array $blocks Array of block objects to get data from. + * @param array $source Info for the block to source the data from. + * ['blockName'] Name of the block to search for. + * ['attrs'] (Optional) Specific block attributes to get. + * If not provided, all attributes will be returned. + * + * @return array|Boolean Array of block data, or false if there are no blocks matching the given source (or no data to return). + */ +function get_data_from_blocks( $blocks, $source ) { + $data = []; + + if ( ! empty( $source ) && ! empty( $source['blockName'] ) ) { + $matching_blocks = get_blocks_by_type( $source['blockName'], $blocks ); + + // Return false if there are no matching blocks of the given source type. + if ( empty( $matching_blocks ) ) { + return false; + } + + // Gather data from all matching block instances. + foreach ( $matching_blocks as $matching_block ) { + $block_data = false; + + // If we have a source `attr` key, sync only that attribute, otherwise sync all attributes. + if ( ! empty( $source['attr'] ) ) { + if ( ! empty( $matching_block['attrs'][ $source['attr'] ] ) ) { + $block_data = $matching_block['attrs'][ $source['attr'] ]; + } + } else { + $block_data = [ $matching_block['attrs'] ]; + } + + if ( is_array( $block_data ) ) { + $data = array_merge( $data, $block_data ); + } else { + $data[] = $block_data; + } + } + } + + // Return false instead of an empty array, if there's no data to return. + if ( empty( $data ) ) { + return false; + } + + return $data; +} diff --git a/newspack-listings.php b/newspack-listings.php index 222c6c9b..609b4b5b 100644 --- a/newspack-listings.php +++ b/newspack-listings.php @@ -16,8 +16,9 @@ // Define NEWSPACK_LISTINGS_PLUGIN_FILE. if ( ! defined( 'NEWSPACK_LISTINGS_PLUGIN_FILE' ) ) { - define( 'NEWSPACK_LISTINGS_PLUGIN_FILE', plugin_dir_path( __FILE__ ) ); - define( 'NEWSPACK_LISTINGS_URL', plugin_dir_url( __FILE__ ) ); + define( 'NEWSPACK_LISTINGS_FILE', __FILE__ ); + define( 'NEWSPACK_LISTINGS_PLUGIN_FILE', plugin_dir_path( NEWSPACK_LISTINGS_FILE ) ); + define( 'NEWSPACK_LISTINGS_URL', plugin_dir_url( NEWSPACK_LISTINGS_FILE ) ); define( 'NEWSPACK_LISTINGS_VERSION', '0.0.1' ); } @@ -28,6 +29,3 @@ require_once NEWSPACK_LISTINGS_PLUGIN_FILE . '/includes/class-newspack-listings-core.php'; require_once NEWSPACK_LISTINGS_PLUGIN_FILE . '/includes/class-newspack-listings-api.php'; require_once NEWSPACK_LISTINGS_PLUGIN_FILE . '/includes/class-newspack-listings-blocks.php'; - -// On plugin activation/deactivation. -register_activation_hook( __FILE__, '\Newspack_Listings\Utils\activate' ); diff --git a/package-lock.json b/package-lock.json index 5a62051d..5dc64f5b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3351,12 +3351,41 @@ "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz", "integrity": "sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==" }, + "@types/prop-types": { + "version": "15.7.3", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.3.tgz", + "integrity": "sha512-KfRL3PuHmqQLOG+2tGpRO26Ctg+Cq1E01D2DMriKEATHgWLfeNDmq9e29Q9WIky0dQ3NPkd1mzYH8Lm936Z9qw==" + }, "@types/q": { "version": "1.5.2", "resolved": "https://registry.npmjs.org/@types/q/-/q-1.5.2.tgz", "integrity": "sha512-ce5d3q03Ex0sy4R14722Rmt6MT07Ua+k4FwDfdcToYJcMKNtRVQvJ6JCAPdAmAnbRb6CsX6aYb9m96NGod9uTw==", "dev": true }, + "@types/react": { + "version": "16.9.49", + "resolved": "https://registry.npmjs.org/@types/react/-/react-16.9.49.tgz", + "integrity": "sha512-DtLFjSj0OYAdVLBbyjhuV9CdGVHCkHn2R+xr3XkBvK2rS1Y1tkc14XSGjYgm5Fjjr90AxH9tiSzc1pCFMGO06g==", + "requires": { + "@types/prop-types": "*", + "csstype": "^3.0.2" + }, + "dependencies": { + "csstype": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.3.tgz", + "integrity": "sha512-jPl+wbWPOWJ7SXsWyqGRk3lGecbar0Cb0OvZF/r/ZU011R4YqiRehgkQ9p4eQfo9DSDLqLL3wHwfxeJiuIsNag==" + } + } + }, + "@types/react-dom": { + "version": "16.9.8", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-16.9.8.tgz", + "integrity": "sha512-ykkPQ+5nFknnlU6lDd947WbQ6TE3NNzbQAkInC2EKY1qeYdTKp7onFusmYZb+ityzx2YviqT6BXSu+LyWWJwcA==", + "requires": { + "@types/react": "*" + } + }, "@types/retry": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", @@ -4660,31 +4689,31 @@ } }, "@wordpress/block-serialization-default-parser": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@wordpress/block-serialization-default-parser/-/block-serialization-default-parser-3.7.0.tgz", - "integrity": "sha512-Q02yT1AKBTsWsqTi7ZwCIkzAHfL52txNJkRFH7Ln5B/WaMtPHm8EXIJV2BeNZnRjAxqL5zn5ZINJqJBjPX4bqg==", + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/@wordpress/block-serialization-default-parser/-/block-serialization-default-parser-3.7.1.tgz", + "integrity": "sha512-dps9iVfahF4ZT0k+sm1Y3wel7syjYtL6pMkRTDrigZinPqWm2F9G5tYPDMkawFMb2gYDcQDHygktLHR5yFbsig==", "requires": { "@babel/runtime": "^7.9.2" } }, "@wordpress/blocks": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@wordpress/blocks/-/blocks-6.21.0.tgz", - "integrity": "sha512-zirBnjzfBYsLhtpedEzddVFSSHTest0zZD6bqZSEFVv4RdMDqv55dejBG3nOK+bOpZ4fQ+gDT7HaMJY4IpU9FA==", + "version": "6.22.0", + "resolved": "https://registry.npmjs.org/@wordpress/blocks/-/blocks-6.22.0.tgz", + "integrity": "sha512-HGZVqajtk1c+EzixuFfvgsxpOFt3uk+LMemjGv29SVZA6d6uNaZhCCaht82p8HYQlgw8/UCbQAhQwdIo7cqxEw==", "requires": { "@babel/runtime": "^7.9.2", "@wordpress/autop": "^2.9.0", "@wordpress/blob": "^2.9.0", - "@wordpress/block-serialization-default-parser": "^3.7.0", - "@wordpress/compose": "^3.20.0", - "@wordpress/data": "^4.23.0", + "@wordpress/block-serialization-default-parser": "^3.7.1", + "@wordpress/compose": "^3.20.1", + "@wordpress/data": "^4.23.1", "@wordpress/deprecated": "^2.9.0", "@wordpress/dom": "^2.14.0", - "@wordpress/element": "^2.17.0", + "@wordpress/element": "^2.17.1", "@wordpress/hooks": "^2.9.0", "@wordpress/html-entities": "^2.8.0", "@wordpress/i18n": "^3.15.0", - "@wordpress/icons": "^2.5.0", + "@wordpress/icons": "^2.6.0", "@wordpress/is-shallow-equal": "^2.2.0", "@wordpress/shortcode": "^2.10.0", "hpq": "^1.3.0", @@ -4713,12 +4742,12 @@ } }, "@wordpress/compose": { - "version": "3.20.0", - "resolved": "https://registry.npmjs.org/@wordpress/compose/-/compose-3.20.0.tgz", - "integrity": "sha512-uOsfVuWOYtMUIOAk2dqiqKvg62WwMqAJG/ysBExM1imIofzIvTN2bhnVwy2yThBcQMIN/wdKBfhbZaLw2erRhA==", + "version": "3.20.1", + "resolved": "https://registry.npmjs.org/@wordpress/compose/-/compose-3.20.1.tgz", + "integrity": "sha512-hi6VRXBte26A1QObx1PvqPIvpfhquDn3IUaw1wCPsaZBqayPx8Os9L9h3nVtqDGs439N5Qvb3FvdXN1eDJk8Zw==", "requires": { "@babel/runtime": "^7.9.2", - "@wordpress/element": "^2.17.0", + "@wordpress/element": "^2.17.1", "@wordpress/is-shallow-equal": "^2.2.0", "@wordpress/priority-queue": "^1.8.0", "clipboard": "^2.0.1", @@ -4728,14 +4757,14 @@ } }, "@wordpress/data": { - "version": "4.23.0", - "resolved": "https://registry.npmjs.org/@wordpress/data/-/data-4.23.0.tgz", - "integrity": "sha512-plJZkf4otm8TupoA4B0RSmhdZptJOJshiMS02ihkvj0c2WdDomV5ERjOGBSVtJK30mm5ItOQjRIeH95v+EJURg==", + "version": "4.23.1", + "resolved": "https://registry.npmjs.org/@wordpress/data/-/data-4.23.1.tgz", + "integrity": "sha512-PKX5UNZkJ1QxGpc9HjHynhtMk1Qo2ASXM53tHk90Rw94TGVSp/4jkcvwwtml05RjTyuDAqFD2D8AGnzsR98Cpg==", "requires": { "@babel/runtime": "^7.9.2", - "@wordpress/compose": "^3.20.0", + "@wordpress/compose": "^3.20.1", "@wordpress/deprecated": "^2.9.0", - "@wordpress/element": "^2.17.0", + "@wordpress/element": "^2.17.1", "@wordpress/is-shallow-equal": "^2.2.0", "@wordpress/priority-queue": "^1.8.0", "@wordpress/redux-routine": "^3.11.0", @@ -4767,11 +4796,13 @@ } }, "@wordpress/element": { - "version": "2.17.0", - "resolved": "https://registry.npmjs.org/@wordpress/element/-/element-2.17.0.tgz", - "integrity": "sha512-WVhJXa2zhleG9nYl139T6ZBmpBw/UcXYkIpvnF6KA27H1EXyyoaUDPU0EfHfn8/T67IXd+gUxjyrvYdFFJwgHw==", + "version": "2.17.1", + "resolved": "https://registry.npmjs.org/@wordpress/element/-/element-2.17.1.tgz", + "integrity": "sha512-7MTmd2wy0tslZK/q+zLfMrdIkflcd77Zts+jUhoWFBTqwNMTaod4QggCM8fveXxrcrPCFfv86aslrI0D9zCWeQ==", "requires": { "@babel/runtime": "^7.9.2", + "@types/react": "^16.9.0", + "@types/react-dom": "^16.9.0", "@wordpress/escape-html": "^1.9.0", "lodash": "^4.17.19", "react": "^16.13.1", @@ -4802,27 +4833,14 @@ "@babel/runtime": "^7.9.2" } }, - "@wordpress/i18n": { - "version": "3.15.0", - "resolved": "https://registry.npmjs.org/@wordpress/i18n/-/i18n-3.15.0.tgz", - "integrity": "sha512-AawJgHEGPyMoPATl8a3Qa+cCZV3S6azPfvqeStbN2pSc7v0HqYhJhWaw80WToHkN4kyOsfu1PUVf1wefuoMNEA==", - "requires": { - "@babel/runtime": "^7.9.2", - "gettext-parser": "^1.3.1", - "lodash": "^4.17.19", - "memize": "^1.1.0", - "sprintf-js": "^1.1.1", - "tannin": "^1.2.0" - } - }, "@wordpress/icons": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@wordpress/icons/-/icons-2.5.0.tgz", - "integrity": "sha512-dT/SCp3l/5i5QNBef9B8GhVRNCNQ4RC5z8lc2IuAF2KbLDgujxZL/82qbQyADtRZYp7CqBbVrX2PVr5bz5k+ew==", + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@wordpress/icons/-/icons-2.6.0.tgz", + "integrity": "sha512-cVy2r9mMZiTdQlhkrb3IIbAfdMndI52spQlxRhv2Yzsjec97vMNyX7x6S8Amz/0JBttNDnxhQ9To8xrRBhMBlg==", "requires": { "@babel/runtime": "^7.9.2", - "@wordpress/element": "^2.17.0", - "@wordpress/primitives": "^1.8.0" + "@wordpress/element": "^2.17.1", + "@wordpress/primitives": "^1.8.1" } }, "@wordpress/is-shallow-equal": { @@ -4834,54 +4852,20 @@ } }, "@wordpress/primitives": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/@wordpress/primitives/-/primitives-1.8.0.tgz", - "integrity": "sha512-kmxJvyiotYGCNLE3z9V1vz5WrcLHIKlc2JM5RY7F+vYWUXxMzyTAiwFqkW+ulWCa1LYUU8Gai0LY5E2dusR8PA==", + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@wordpress/primitives/-/primitives-1.8.1.tgz", + "integrity": "sha512-5aDsNtVD+LfH9+EAIa9BAD5E5q3q325qz75XY8KquxYGM8/xPPVvDjj8fV78UZyBQ9Q0GLCyLyWs+8xoGlgW8w==", "requires": { "@babel/runtime": "^7.9.2", - "@wordpress/element": "^2.17.0", + "@wordpress/element": "^2.17.1", "classnames": "^2.2.5" } }, - "@wordpress/priority-queue": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/@wordpress/priority-queue/-/priority-queue-1.8.0.tgz", - "integrity": "sha512-w1xHOUp23w2+Vw6YMfPdD+QNnvNXSMHEJGpnhpea91dNA+rXFzHcLNvzKwt+TebGzRhJQ5AI/95+jg/ptujoRw==", - "requires": { - "@babel/runtime": "^7.9.2" - } - }, - "@wordpress/redux-routine": { - "version": "3.11.0", - "resolved": "https://registry.npmjs.org/@wordpress/redux-routine/-/redux-routine-3.11.0.tgz", - "integrity": "sha512-ol4c/X2Y+kQFjaugBv9GibQoIUuVB7EJJggA4ExMzSzi1vdTa1s3MPKH9d8KAaUl1UqboxJ5LHt5k/I6GFexuQ==", - "requires": { - "@babel/runtime": "^7.9.2", - "is-promise": "^4.0.0", - "lodash": "^4.17.19", - "rungen": "^0.3.2" - } - }, "is-promise": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==" }, - "lodash": { - "version": "4.17.20", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.20.tgz", - "integrity": "sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA==" - }, - "react-resize-aware": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/react-resize-aware/-/react-resize-aware-3.0.1.tgz", - "integrity": "sha512-HdPzwdcAv+BMFQEgyacFB40G4IxNMO7tSqaMjbnAouot8LXi5/Rx3/Fv+LU2cQekqiivE1LF4sGnwQ7SnoHrpg==" - }, - "sprintf-js": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.2.tgz", - "integrity": "sha512-VE0SOVEHCk7Qc8ulkWw3ntAzXuqf7S2lvwQaDLRnUeIEaKNQJzV6BwmLKhOqT61aGhfUMrXeaBk+oDGCzvhcug==" - }, "uuid": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/uuid/-/uuid-7.0.3.tgz", diff --git a/package.json b/package.json index e9d61da4..aebd17d4 100644 --- a/package.json +++ b/package.json @@ -96,7 +96,7 @@ "@wordpress/api-fetch": "^3.12.0", "@wordpress/base-styles": "^1.5.0", "@wordpress/block-editor": "^3.11.0", - "@wordpress/blocks": "^6.21.0", + "@wordpress/blocks": "^6.22.0", "@wordpress/components": "^10.1.0", "@wordpress/compose": "^3.20.0", "@wordpress/data": "^4.23.0", diff --git a/src/blocks/category.js b/src/blocks/category.js new file mode 100644 index 00000000..8a8e39fc --- /dev/null +++ b/src/blocks/category.js @@ -0,0 +1,30 @@ +/** + * External dependencies + */ +import { getCategories, setCategories } from '@wordpress/blocks'; + +/** + * Internal dependencies + */ +import { NewspackLogo } from '../components'; + +/** + * If tbe Newspack Blocks plugin is installed, use the existing Newspack block category. + * Otherwise, create the category. This lets Newspack Listings remain usable without + * depending on Newspack Blocks. + */ +export const setCustomCategory = () => { + const categories = getCategories(); + const hasNewspackCategory = !! categories.find( ( { slug } ) => slug === 'newspack' ); + + if ( ! hasNewspackCategory ) { + setCategories( [ + ...categories.filter( ( { slug } ) => slug !== 'newspack' ), + { + slug: 'newspack', + title: 'Newspack', + icon: , + }, + ] ); + } +}; diff --git a/src/blocks/curated-list/block.json b/src/blocks/curated-list/block.json new file mode 100644 index 00000000..2b680609 --- /dev/null +++ b/src/blocks/curated-list/block.json @@ -0,0 +1,85 @@ +{ + "name": "newspack-listings/curated-list", + "category": "newspack", + "attributes": { + "showNumbers": { + "type": "boolean", + "default": true + }, + "showMap": { + "type": "boolean", + "default": false + }, + "showSortByDate": { + "type": "boolean", + "default": false + }, + "showExcerpt": { + "type": "boolean", + "default": true + }, + "showImage": { + "type": "boolean", + "default": true + }, + "showCaption": { + "type": "boolean", + "default": false + }, + "imageShape": { + "type": "string", + "default": "landscape" + }, + "minHeight": { + "type": "integer", + "default": 0 + }, + "showCategory": { + "type": "boolean", + "default": false + }, + "mediaPosition": { + "type": "string", + "default": "top" + }, + "categories": { + "type": "array", + "default": [], + "items": { "type": "integer" } + }, + "tags": { + "type": "array", + "default": [], + "items": { "type": "integer" } + }, + "tagExclusions": { + "type": "array", + "default": [], + "items": { "type": "integer" } + }, + "typeScale": { + "type": "integer", + "default": 4 + }, + "imageScale": { + "type": "integer", + "default": 3 + }, + "mobileStack": { + "type": "boolean", + "default": false + }, + "textColor": { + "type": "string", + "default": "" + }, + "customTextColor": { + "type": "string", + "default": "" + }, + "showSubtitle": { + "type": "boolean", + "default": false + } + } +} diff --git a/src/blocks/curated-list/edit.js b/src/blocks/curated-list/edit.js new file mode 100644 index 00000000..dd6a68bd --- /dev/null +++ b/src/blocks/curated-list/edit.js @@ -0,0 +1,332 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +import { createBlock } from '@wordpress/blocks'; +import { InnerBlocks, InspectorControls, PanelColorSettings } from '@wordpress/block-editor'; +import { + Button, + ButtonGroup, + PanelBody, + PanelRow, + RangeControl, + ToggleControl, + BaseControl, +} from '@wordpress/components'; +import { compose } from '@wordpress/compose'; +import { withDispatch, withSelect } from '@wordpress/data'; +import { Fragment, useEffect, useState } from '@wordpress/element'; + +const CuratedListEditorComponent = ( { + attributes, + canUseMapBlock, + className, + clientId, + innerBlocks, + insertBlock, + removeBlock, + setAttributes, + updateBlockAttributes, +} ) => { + const [ locations, setLocations ] = useState( [] ); + const { + showNumbers, + showMap, + showSortByDate, + showExcerpt, + showImage, + showCaption, + minHeight, + showCategory, + mediaPosition, + typeScale, + imageScale, + mobileStack, + textColor, + showSubtitle, + } = attributes; + + const list = innerBlocks.find( + innerBlock => innerBlock.name === 'newspack-listings/list-container' + ); + const hasMap = innerBlocks.find( innerBlock => innerBlock.name === 'jetpack/map' ); + const classes = [ className, 'newspack-listings__curated-list' ]; + if ( showNumbers ) classes.push( 'show-numbers' ); + if ( showMap ) classes.push( 'show-map' ); + if ( showSortByDate ) classes.push( 'has-sort-by-date-ui' ); + + // Update locations in component state. This lets us keep the map block in sync with listing items. + useEffect(() => { + // Only build locations array if we have any listings, and the Jetpack Maps block exists. + const blockLocations = + canUseMapBlock && list + ? list.innerBlocks.reduce( ( acc, innerBlock ) => { + if ( innerBlock.attributes.locations && 0 < innerBlock.attributes.locations.length ) { + innerBlock.attributes.locations.map( location => acc.push( location ) ); + } + return acc; + }, [] ) + : []; + + setLocations( blockLocations ); + }, [ JSON.stringify( list ) ]); + + // Create, update, or remove map when showMap attribute or locations change. + useEffect(() => { + // Don't bother if the Jetpack Maps block doesn't exist. + if ( ! canUseMapBlock ) { + return; + } + + // If showMap toggle is enabled, update the existing map or create a new one. + if ( showMap ) { + if ( hasMap ) { + // If we already have a map, update it. + updateBlockAttributes( hasMap.clientId, { points: locations } ); + } else { + // Don't add a new map unless we have some locations to show. + if ( 0 === locations.length ) { + return; + } + + // Create a new map at the top of the list. + const newBlock = createBlock( 'jetpack/map', { + points: locations, + } ); + + insertBlock( newBlock, 0, clientId ); + } + } else if ( hasMap ) { + // If disabling the showMap toggle, remove the existing map. + removeBlock( hasMap.clientId ); + } + }, [ showMap, JSON.stringify( locations ) ]); + + const imageSizeOptions = [ + { + value: 1, + label: /* translators: label for small size option */ __( 'Small', 'newspack-listings' ), + shortName: /* translators: abbreviation for small size */ __( 'S', 'newspack-listings' ), + }, + { + value: 2, + label: /* translators: label for medium size option */ __( 'Medium', 'newspack-listings' ), + shortName: /* translators: abbreviation for medium size */ __( 'M', 'newspack-listings' ), + }, + { + value: 3, + label: /* translators: label for large size option */ __( 'Large', 'newspack-listings' ), + shortName: /* translators: abbreviation for large size */ __( 'L', 'newspack-listings' ), + }, + { + value: 4, + label: /* translators: label for extra large size option */ __( + 'Extra Large', + 'newspack-listings' + ), + shortName: /* translators: abbreviation for extra large size */ __( + 'XL', + 'newspack-listings' + ), + }, + ]; + + const subtitleIsSupportedInTheme = + typeof window === 'object' && + window.newspackIsPostSubtitleSupported && + window.newspackIsPostSubtitleSupported.post_subtitle; + + return ( +
    + + + + setAttributes( { showNumbers: ! showNumbers } ) } + /> + + + { canUseMapBlock && ( + + setAttributes( { showMap: ! showMap } ) } + /> + + ) } + + + setAttributes( { showSortByDate: ! showSortByDate } ) } + /> + + + + + setAttributes( { showImage: ! showImage } ) } + /> + + + { showImage && ( + + setAttributes( { showCaption: ! showCaption } ) } + /> + + ) } + + { showImage && mediaPosition !== 'top' && mediaPosition !== 'behind' && ( + + + setAttributes( { mobileStack: ! mobileStack } ) } + /> + + + + + { imageSizeOptions.map( option => { + const isCurrent = imageScale === option.value; + return ( + + ); + } ) } + + + + + ) } + + { showImage && mediaPosition === 'behind' && ( + setAttributes( { minHeight: _minHeight } ) } + min={ 0 } + max={ 100 } + required + /> + ) } + + + { subtitleIsSupportedInTheme && ( + + setAttributes( { showSubtitle: ! showSubtitle } ) } + /> + + ) } + + setAttributes( { showExcerpt: ! showExcerpt } ) } + /> + + setAttributes( { typeScale: _typeScale } ) } + min={ 1 } + max={ 10 } + beforeIcon="editor-textcolor" + afterIcon="editor-textcolor" + required + /> + + setAttributes( { textColor: value } ), + label: __( 'Text Color', 'newspack-listings' ), + }, + ] } + /> + + + setAttributes( { showCategory: ! showCategory } ) } + /> + + + +
    + + { __( 'Curated List', 'newspack-listings' ) } + + null } // We want to discourage editors from adding blocks in this top-level wrapper, but we can't lock the template because we still need to be able to programmatically add or remove map blocks. + /> +
    +
    + ); +}; + +const mapStateToProps = ( select, ownProps ) => { + const { getBlocksByClientId } = select( 'core/block-editor' ); + const { getBlockType } = select( 'core/blocks' ); + const innerBlocks = getBlocksByClientId( ownProps.clientId )[ 0 ].innerBlocks || []; + const canUseMapBlock = !! getBlockType( 'jetpack/map' ); // Check for existence of Jetpack Map block before enabling location-based features. + + return { + canUseMapBlock, + innerBlocks, + }; +}; + +const mapDispatchToProps = dispatch => { + const { insertBlock, removeBlock, updateBlockAttributes } = dispatch( 'core/block-editor' ); + + return { + insertBlock, + removeBlock, + updateBlockAttributes, + }; +}; + +export const CuratedListEditor = compose( [ + withSelect( mapStateToProps ), + withDispatch( mapDispatchToProps ), +] )( CuratedListEditorComponent ); diff --git a/src/blocks/curated-list/editor.scss b/src/blocks/curated-list/editor.scss new file mode 100644 index 00000000..1410cfbe --- /dev/null +++ b/src/blocks/curated-list/editor.scss @@ -0,0 +1,36 @@ +.newspack-listings { + &__curated-list { + &.show-numbers .newspack-listings__listing-editor::before { + color: #767676; + color: var( --newspack-listings--grey-medium ); + content: counter( list ) '. '; + counter-increment: list; + font-weight: bold; + } + } + + &__curated-list-editor { + border: 1px solid var( --newspack-listings--grey-dark ); + border-radius: 2px; + padding: 3rem 1rem 1rem; + + [data-type='jetpack/map'] { + pointer-events: none; + } + } + + &__curated-list-label { + background-color: #36f; + border-bottom-left-radius: 2px; + color: var( --newspack-listings--white ); + display: block; + font-size: 0.75rem; + font-weight: bold; + left: 1px; + letter-spacing: 0.01rem; + padding: 0.25rem 0.5rem; + position: absolute; + text-transform: uppercase; + top: 1px; + } +} diff --git a/src/blocks/curated-list/index.js b/src/blocks/curated-list/index.js new file mode 100644 index 00000000..34e3cf50 --- /dev/null +++ b/src/blocks/curated-list/index.js @@ -0,0 +1,34 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +import { registerBlockType } from '@wordpress/blocks'; + +/** + * Internal dependencies + */ +import './editor.scss'; +import { CuratedList } from './save'; +import { CuratedListEditor } from './edit'; +import metadata from './block.json'; +const { attributes, category, name } = metadata; + +export const registerCuratedListBlock = () => { + registerBlockType( name, { + title: __( 'Curated List', 'newspack-listing' ), + icon: 'list-view', + category, + keywords: [ + __( 'curated', 'newspack-listings' ), + __( 'list', 'newspack-listings' ), + __( 'lists', 'newspack-listings' ), + __( 'listings', 'newspack-listings' ), + __( 'latest', 'newspack-listings' ), + ], + + attributes, + + edit: CuratedListEditor, + save: CuratedList, + } ); +}; diff --git a/src/blocks/curated-list/save.js b/src/blocks/curated-list/save.js new file mode 100644 index 00000000..691032ff --- /dev/null +++ b/src/blocks/curated-list/save.js @@ -0,0 +1,34 @@ +/** + * External dependencies + */ +import { InnerBlocks } from '@wordpress/block-editor'; + +export const CuratedList = ( { attributes, className } ) => { + const { + showNumbers, + showMap, + showSortByDate, + // showExcerpt, + // showImage, + // showCaption, + // minHeight, + // showCategory, + // mediaPosition, + // typeScale, + // imageScale, + // mobileStack, + // textColor, + // showSubtitle, + } = attributes; + + const classes = [ className, 'newspack-listings__curated-list' ]; + if ( showNumbers ) classes.push( 'show-numbers' ); + if ( showMap ) classes.push( 'show-map' ); + if ( showSortByDate ) classes.push( 'has-sort-by-date-ui' ); + + return ( +
    + +
    + ); +}; diff --git a/src/blocks/index.js b/src/blocks/index.js index 617af410..9de9a5dc 100644 --- a/src/blocks/index.js +++ b/src/blocks/index.js @@ -1 +1,4 @@ +export * from './category'; +export * from './curated-list'; +export * from './list-container'; export * from './listing'; diff --git a/src/blocks/list-container/edit.js b/src/blocks/list-container/edit.js new file mode 100644 index 00000000..75a1a363 --- /dev/null +++ b/src/blocks/list-container/edit.js @@ -0,0 +1,32 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +import { InnerBlocks } from '@wordpress/block-editor'; +import { Notice } from '@wordpress/components'; +import { useSelect } from '@wordpress/data'; + +export const ListContainerEditor = ( { clientId } ) => { + const innerBlocks = useSelect( select => { + return select( 'core/block-editor' ).getBlocksByClientId( clientId )[ 0 ].innerBlocks || []; + } ); + + return ( +
    + { 0 === innerBlocks.length && ( + + { __( 'This list is empty. Click the [+] button to add some listings.' ) } + + ) } + } + /> +
    + ); +}; diff --git a/src/blocks/list-container/index.js b/src/blocks/list-container/index.js new file mode 100644 index 00000000..22c08a2f --- /dev/null +++ b/src/blocks/list-container/index.js @@ -0,0 +1,36 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +import { registerBlockType } from '@wordpress/blocks'; +import { InnerBlocks } from '@wordpress/block-editor'; + +/** + * Internal dependencies + */ +import { ListContainerEditor } from './edit'; + +export const registerListContainerBlock = () => { + registerBlockType( 'newspack-listings/list-container', { + title: __( 'Curated List Container', 'newspack-listing' ), + icon: 'list-view', + category: 'newspack', + keywords: [ + __( 'curated', 'newspack-listings' ), + __( 'list', 'newspack-listings' ), + __( 'lists', 'newspack-listings' ), + __( 'listings', 'newspack-listings' ), + __( 'latest', 'newspack-listings' ), + ], + + attributes: {}, + + // Hide from block inserter menus. + supports: { + inserter: false, + }, + + edit: ListContainerEditor, + save: () => , // also uses view.php + } ); +}; diff --git a/src/blocks/list-container/view.php b/src/blocks/list-container/view.php new file mode 100644 index 00000000..083f8d04 --- /dev/null +++ b/src/blocks/list-container/view.php @@ -0,0 +1,60 @@ + [], + 'render_callback' => __NAMESPACE__ . '\render_block', + ] + ); +} + +/** + * Block render callback. + * + * @param array $attributes Block attributes. + * @param string $inner_content InnerBlock content. + */ +function render_block( $attributes, $inner_content ) { + // Don't output the block inside RSS feeds. + if ( is_feed() ) { + return; + } + + // Bail if there's no InnerBlock content to display. + if ( empty( trim( $inner_content ) ) ) { + return ''; + } + + // Begin front-end output. + // TODO: Templatize this output; integrate more variations based on attributes. + + ob_start(); + + ?> +
      + +
    + { +const ListingEditorComponent = ( { + attributes, + className, + clientId, + getBlock, + getBlockParents, + name, + setAttributes, +} ) => { const [ post, setPost ] = useState( null ); const [ error, setError ] = useState( null ); const [ isEditingPost, setIsEditingPost ] = useState( false ); + const { listing } = attributes; + + // Get the parent Curated List block. + const parent = getBlock( getBlockParents( clientId )[ 0 ] ); + + // Parent Curated List block attributes. const { - listing, showExcerpt, showImage, showCaption, - minHeight, - showCategory, - mediaPosition, - typeScale, - imageScale, - mobileStack, - textColor, - showSubtitle, - } = attributes; + // minHeight, + // showCategory, + // mediaPosition, + // typeScale, + // imageScale, + // mobileStack, + // textColor, + // showSubtitle, + } = parent.attributes; + + // Build an array of just the listing post IDs that exist in the parent Curated List block. + const listItems = parent.innerBlocks.reduce( ( acc, innerBlock ) => { + if ( innerBlock.attributes.listing ) { + acc.push( innerBlock.attributes.listing ); + } - const { newspack_listings_show_map, newspack_listings_show_numbers } = meta; - const { post_types } = window.newspack_listings_data; - const classes = [ 'newspack-listings__list-item' ]; + return acc; + }, [] ); + const { post_types } = window.newspack_listings_data; + const classes = [ className, 'newspack-listings__listing' ]; const listingTypeSlug = name.split( '/' ).slice( -1 ); const listingType = post_types[ listingTypeSlug ]; classes.push( listingTypeSlug ); - if ( newspack_listings_show_map ) classes.push( 'newspack-listings__show-map' ); - - if ( newspack_listings_show_numbers ) classes.push( 'newspack-listings__show-numbers' ); - - const imageSizeOptions = [ - { - value: 1, - label: /* translators: label for small size option */ __( 'Small', 'newspack-listings' ), - shortName: /* translators: abbreviation for small size */ __( 'S', 'newspack-listings' ), - }, - { - value: 2, - label: /* translators: label for medium size option */ __( 'Medium', 'newspack-listings' ), - shortName: /* translators: abbreviation for medium size */ __( 'M', 'newspack-listings' ), - }, - { - value: 3, - label: /* translators: label for large size option */ __( 'Large', 'newspack-listings' ), - shortName: /* translators: abbreviation for large size */ __( 'L', 'newspack-listings' ), - }, - { - value: 4, - label: /* translators: label for extra large size option */ __( - 'Extra Large', - 'newspack-listings' - ), - shortName: /* translators: abbreviation for extra large size */ __( - 'XL', - 'newspack-listings' - ), - }, - ]; - - const subtitleIsSupportedInTheme = - typeof window === 'object' && - window.newspackIsPostSubtitleSupported && - window.newspackIsPostSubtitleSupported.post_subtitle; - // Fetch listing post data if we have a listing post ID. useEffect(() => { if ( listing ) { @@ -97,7 +68,12 @@ const ListingEditorComponent = ( { attributes, listItems, meta, name, setAttribu } }, [ listing ]); - // Fetch listing post title and content by listingId. + // Sync parent attributes to listing attributes, so that we can use parent attributes in the PHP render callback. + useEffect(() => { + setAttributes( { ...parent.attributes } ); + }, [ JSON.stringify( parent.attributes ) ]); + + // Fetch listing post by listingId. const fetchPost = async listingId => { try { setError( null ); @@ -105,7 +81,7 @@ const ListingEditorComponent = ( { attributes, listItems, meta, name, setAttribu path: addQueryArgs( '/newspack-listings/v1/listings', { per_page: 100, id: listingId, - _fields: 'id,title,content,meta', + _fields: 'id,title,excerpt,media,meta', } ), } ); @@ -113,7 +89,13 @@ const ListingEditorComponent = ( { attributes, listItems, meta, name, setAttribu throw `No posts found for ID ${ listingId }. Try refreshing or selecting a new post.`; } - setPost( posts[ 0 ] ); + const foundPost = posts[ 0 ]; + + if ( foundPost.meta && foundPost.meta.newspack_listings_locations ) { + setAttributes( { locations: foundPost.meta.newspack_listings_locations } ); + } + + setPost( foundPost ); } catch ( e ) { setError( e ); } @@ -122,7 +104,7 @@ const ListingEditorComponent = ( { attributes, listItems, meta, name, setAttribu // Renders the autocomplete search field to select listings. Will only show listings of the type that matches the block. const renderSearch = () => { return ( -
    +
    +
    { error && ( { error } ) } { post && post.title && ( -

    +

    { post.title }

    ) } - { post && post.content && { post.content } } + { showImage && post && post.media && post.media.image && ( +
    + { + { showCaption && post.media.caption && ( +
    + { post.media.caption } +
    + ) } +
    + ) } + { showExcerpt && post && post.excerpt && { post.excerpt } } @@ -194,134 +190,9 @@ const ListingEditorComponent = ( { attributes, listItems, meta, name, setAttribu }; return ( -
    - - - - setAttributes( { showImage: ! showImage } ) } - /> - - - { showImage && ( - - setAttributes( { showCaption: ! showCaption } ) } - /> - - ) } - - { showImage && mediaPosition !== 'top' && mediaPosition !== 'behind' && ( - - - setAttributes( { mobileStack: ! mobileStack } ) } - /> - - - - - { imageSizeOptions.map( option => { - const isCurrent = imageScale === option.value; - return ( - - ); - } ) } - - - - - ) } - - { showImage && mediaPosition === 'behind' && ( - setAttributes( { minHeight: _minHeight } ) } - min={ 0 } - max={ 100 } - required - /> - ) } - - - { subtitleIsSupportedInTheme && ( - - setAttributes( { showSubtitle: ! showSubtitle } ) } - /> - - ) } - - setAttributes( { showExcerpt: ! showExcerpt } ) } - /> - - setAttributes( { typeScale: _typeScale } ) } - min={ 1 } - max={ 10 } - beforeIcon="editor-textcolor" - afterIcon="editor-textcolor" - required - /> - - setAttributes( { textColor: value } ), - label: __( 'Text Color', 'newspack-listings' ), - }, - ] } - /> - - - setAttributes( { showCategory: ! showCategory } ) } - /> - - - - +
    - { listingTypeSlug } + { listingTypeSlug } { ! listing || isEditingPost ? renderSearch() : renderPost() }
    @@ -329,20 +200,11 @@ const ListingEditorComponent = ( { attributes, listItems, meta, name, setAttribu }; const mapStateToProps = select => { - const { getBlocks, getEditedPostAttribute } = select( 'core/editor' ); - const blocks = getBlocks(); - - // Build an array of just the list item post IDs. - const listItems = blocks.reduce( ( acc, item ) => { - if ( item.attributes.listing ) { - acc.push( item.attributes.listing ); - } - return acc; - }, [] ); + const { getBlock, getBlockParents } = select( 'core/block-editor' ); return { - meta: getEditedPostAttribute( 'meta' ), - listItems, + getBlock, + getBlockParents, }; }; diff --git a/src/blocks/listing/editor.scss b/src/blocks/listing/editor.scss index ee5f6516..5c111491 100644 --- a/src/blocks/listing/editor.scss +++ b/src/blocks/listing/editor.scss @@ -3,9 +3,9 @@ } .newspack-listings { - &__list-item-editor { + &__listing-editor { border-radius: 2px; - box-shadow: inset 0 0 0 1px #1e1e1e; + box-shadow: inset 0 0 0 1px var( --newspack-listings--grey-medium ); padding: 1rem; .components-spinner { @@ -19,9 +19,9 @@ } } - &__list-item-label { + &__listing-label { background-color: #eee; - background-color: var( --wp--preset--color--light-gray ); + background-color: var( --newspack-listings--grey-light ); border-bottom-left-radius: 2px; display: block; font-size: 0.75rem; @@ -34,15 +34,12 @@ top: 1px; } - &__show-numbers::before { - color: #767676; - color: var( --wp--preset--color--medium-gray ); - content: counter( list ) '. '; - counter-increment: list; - font-weight: bold; - } - &__error { margin: 1rem auto 2rem; } + + &__listing-featured-media { + margin: 1rem 0; + padding: 0; + } } diff --git a/src/blocks/listing/index.js b/src/blocks/listing/index.js index 995ef684..051dd4f9 100644 --- a/src/blocks/listing/index.js +++ b/src/blocks/listing/index.js @@ -10,6 +10,9 @@ import { registerBlockType } from '@wordpress/blocks'; import './editor.scss'; import { ListingEditor } from './edit'; import metadata from './block.json'; +import parentData from '../curated-list/block.json'; + +const parentAttributes = parentData.attributes; const { attributes, category } = metadata; const { post_types } = window.newspack_listings_data; @@ -20,13 +23,15 @@ export const registerListingBlock = () => { title: listingType.charAt( 0 ).toUpperCase() + listingType.slice( 1 ), icon: 'list-view', category, + parent: [ 'newspack-listings/list-container' ], keywords: [ __( 'lists', 'newspack-listings' ), __( 'listings', 'newspack-listings' ), __( 'latest', 'newspack-listings' ), ], - attributes, + // Combine attributes with parent attributes, so parent can pass data to InnerBlocks without relying on contexts. + attributes: Object.assign( attributes, parentAttributes ), edit: ListingEditor, save: () => null, // uses view.php diff --git a/src/blocks/listing/view.php b/src/blocks/listing/view.php index 1d75681a..d3dec5d1 100644 --- a/src/blocks/listing/view.php +++ b/src/blocks/listing/view.php @@ -13,22 +13,37 @@ * Dynamic block registration. */ function register_blocks() { + // Listings block attributes. + $block_json = json_decode( + file_get_contents( __DIR__ . '/block.json' ), // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents + true + ); + + // Parent Curated List block attributes. + $parent_block_json = json_decode( + file_get_contents( dirname( __DIR__ ) . '/curated-list/block.json' ), // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents + true + ); + + // Combine attributes with parent attributes, so parent can pass data to InnerBlocks. + $attributes = array_merge( $block_json['attributes'], $parent_block_json['attributes'] ); + + // Register a block for each listing type. foreach ( Core::NEWSPACK_LISTINGS_POST_TYPES as $label => $post_type ) { - if ( 'curated_list' !== $label ) { - register_block_type( - 'newspack-listings/' . $label, - [ - 'render_callback' => __NAMESPACE__ . '\render_block', - ] - ); - } + register_block_type( + 'newspack-listings/' . $label, + [ + 'attributes' => $attributes, + 'render_callback' => __NAMESPACE__ . '\render_block', + ] + ); } } /** * Block render callback. * - * @param Array $attributes Block attributes. + * @param array $attributes Block attributes (including parent attributes inherited from Curated List container block). */ function render_block( $attributes ) { // Don't output the block inside RSS feeds. @@ -41,6 +56,7 @@ function render_block( $attributes ) { return; } + // Get the listing post by post ID. $post = get_post( intval( $attributes['listing'] ) ); // Bail if there's no post with the saved ID. @@ -48,14 +64,37 @@ function render_block( $attributes ) { return; } + // Begin front-end output. + // TODO: Templatize this output; integrate more variations based on attributes. ob_start(); ?> -
  1. - +
  2. +

    post_title ); ?>

    - ID ) ) ); ?> + + + ID, 'large' ); + if ( ! empty( $featured_image ) ) : + ?> + + + + + ID ) ) ); + } + ?>
  3. diff --git a/src/components/autocomplete-tokenfield.js b/src/components/autocomplete-tokenfield.js index b6448ffe..22a73117 100644 --- a/src/components/autocomplete-tokenfield.js +++ b/src/components/autocomplete-tokenfield.js @@ -69,7 +69,7 @@ class AutocompleteTokenField extends Component { * Get a list of labels for input values. * * @param {Array} values Array of values (ids, etc.). - * @return {Array} array of valid labels corresponding to the values. + * {Array} array of valid labels corresponding to the values. */ getLabelsForValues( values ) { const { validValues } = this.state; diff --git a/src/components/index.js b/src/components/index.js index fd899097..dc4b60a4 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -1 +1,2 @@ export { default as QueryControls } from './query-controls'; +export { default as NewspackLogo } from './newspack-logo'; diff --git a/src/components/newspack-logo.js b/src/components/newspack-logo.js new file mode 100644 index 00000000..1d7bddb4 --- /dev/null +++ b/src/components/newspack-logo.js @@ -0,0 +1,17 @@ +/** + * External dependencies + */ +import { Path, SVG } from '@wordpress/components'; + +export default ( { size = 24 } ) => ( + + + + +); diff --git a/src/editor/index.js b/src/editor/index.js index 2e5f9d97..7ed3246f 100644 --- a/src/editor/index.js +++ b/src/editor/index.js @@ -1,24 +1,22 @@ -/** - * WordPress dependencies - */ -import { registerPlugin } from '@wordpress/plugins'; - /** * Internal dependencies */ -import { Sidebar } from './sidebar'; -import { registerListingBlock } from '../blocks'; +import { + registerCuratedListBlock, + registerListContainerBlock, + registerListingBlock, + setCustomCategory, +} from '../blocks'; +import { isListing } from './utils'; import './style.scss'; /** - * Register blocks. - */ -registerListingBlock(); - -/** - * Register sidebar editor settings. + * Register Curated List blocks. Don't register if we're in a listing already + * (to avoid possibly infinitely nesting lists within list items). */ -registerPlugin( 'newspack-listings-editor', { - render: Sidebar, - icon: null, -} ); +if ( ! isListing() ) { + setCustomCategory(); + registerCuratedListBlock(); + registerListContainerBlock(); + registerListingBlock(); +} diff --git a/src/editor/sidebar/index.js b/src/editor/sidebar/index.js index 9db81ff2..e5fb37ac 100644 --- a/src/editor/sidebar/index.js +++ b/src/editor/sidebar/index.js @@ -8,7 +8,7 @@ import { withDispatch, withSelect } from '@wordpress/data'; import { PluginDocumentSettingPanel } from '@wordpress/edit-post'; const SidebarComponent = ( { meta, postType, updateMetaValue } ) => { - const { meta_fields, post_type, post_types } = window.newspack_listings_data; + const { meta_fields, post_type_label, post_types } = window.newspack_listings_data; let isValidPostType = false; if ( ! post_types ) { @@ -64,7 +64,7 @@ const SidebarComponent = ( { meta, postType, updateMetaValue } ) => { { Object.keys( meta_fields ).map( renderMetaField ) } diff --git a/src/editor/style.scss b/src/editor/style.scss index 66569d56..0c40aaab 100644 --- a/src/editor/style.scss +++ b/src/editor/style.scss @@ -1,3 +1,6 @@ -body { - color: #36f; +:root { + --newspack-listings--grey-dark: #111; + --newspack-listings--grey-medium: #666; + --newspack-listings--grey-light: #ddd; + --newspack-listings--white: #fff; } diff --git a/src/editor/utils.js b/src/editor/utils.js new file mode 100644 index 00000000..b38677cb --- /dev/null +++ b/src/editor/utils.js @@ -0,0 +1,24 @@ +/** + * Util functions for Newspack Listings. + */ + +/** + * Check if the current post in the editor is a listing CPT. + * + * @return {bool} + */ +export const isListing = () => { + if ( ! window.newspack_listings_data ) { + return false; + } + + const { post_type, post_types } = window.newspack_listings_data; + + for ( const slug in post_types ) { + if ( post_types.hasOwnProperty( slug ) && post_type === post_types[ slug ] ) { + return true; + } + } + + return false; +}; diff --git a/src/listing-styles/index.js b/src/listing-styles/index.js new file mode 100644 index 00000000..ad5ef89c --- /dev/null +++ b/src/listing-styles/index.js @@ -0,0 +1,4 @@ +/** + * Custom styles for listings pages. + */ +import './listings/view.scss'; diff --git a/src/listing-styles/listings/curated-list.scss b/src/listing-styles/listings/curated-list.scss new file mode 100644 index 00000000..90784514 --- /dev/null +++ b/src/listing-styles/listings/curated-list.scss @@ -0,0 +1,13 @@ +.newspack-listings { + &__curated-list { + position: relative; + } + + &__list-container { + list-style: none; + + .show-numbers & { + list-style: decimal; + } + } +} diff --git a/src/listing-styles/listings/listing.scss b/src/listing-styles/listings/listing.scss new file mode 100644 index 00000000..87a1379a --- /dev/null +++ b/src/listing-styles/listings/listing.scss @@ -0,0 +1,11 @@ +.newspack-listings { + // Target only .newspack-listings* when it's an `a` element + @at-root a#{&} { + &__listing-link { + .entry-content & { + display: block; + text-decoration: none; + } + } + } +} diff --git a/src/listing-styles/listings/view.scss b/src/listing-styles/listings/view.scss new file mode 100644 index 00000000..66c82aef --- /dev/null +++ b/src/listing-styles/listings/view.scss @@ -0,0 +1,2 @@ +@import './curated-list'; +@import './listing'; diff --git a/webpack.config.js b/webpack.config.js index 460d78a1..a13bd144 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -13,11 +13,12 @@ const path = require( 'path' ); * Internal variables */ const editor = path.join( __dirname, 'src', 'editor' ); +const frontEnd = [ path.join( __dirname, 'src', 'listing-styles' ) ]; const webpackConfig = getBaseWebpackConfig( { WP: true }, { - entry: { editor }, + entry: { editor, front_end: frontEnd }, 'output-path': path.join( __dirname, 'dist' ), } );