From 43e3152209643c8d0829b58a17ccc9047efe756e Mon Sep 17 00:00:00 2001 From: Heyde Moura Date: Thu, 13 Aug 2026 13:15:07 -0300 Subject: [PATCH 1/7] VideoPress: add a playlist block Add a lightweight videopress/playlist block that stores a list of VideoPress video GUIDs and plays them in sequence. The editor manages the GUID list (add by GUID or URL, reorder, remove, optional titles); the frontend renders the first video with a clickable item list, and the view script advances to the next video when the player posts videopress_ended, with optional looping. Co-Authored-By: Claude Fable 5 --- .../vidp-370-add-a-videopress-playlist-block | 4 + .../videopress/src/class-initializer.php | 144 ++++++++++ .../block-editor/blocks/playlist/block.json | 42 +++ .../block-editor/blocks/playlist/edit.tsx | 248 ++++++++++++++++++ .../block-editor/blocks/playlist/editor.scss | 48 ++++ .../block-editor/blocks/playlist/index.ts | 27 ++ .../block-editor/blocks/playlist/types.ts | 12 + .../block-editor/blocks/playlist/view.scss | 50 ++++ .../block-editor/blocks/playlist/view.ts | 92 +++++++ .../tests/php/Playlist_Block_Test.php | 157 +++++++++++ .../packages/videopress/webpack.config.js | 4 + 11 files changed, 828 insertions(+) create mode 100644 projects/packages/videopress/changelog/vidp-370-add-a-videopress-playlist-block create mode 100644 projects/packages/videopress/src/client/block-editor/blocks/playlist/block.json create mode 100644 projects/packages/videopress/src/client/block-editor/blocks/playlist/edit.tsx create mode 100644 projects/packages/videopress/src/client/block-editor/blocks/playlist/editor.scss create mode 100644 projects/packages/videopress/src/client/block-editor/blocks/playlist/index.ts create mode 100644 projects/packages/videopress/src/client/block-editor/blocks/playlist/types.ts create mode 100644 projects/packages/videopress/src/client/block-editor/blocks/playlist/view.scss create mode 100644 projects/packages/videopress/src/client/block-editor/blocks/playlist/view.ts create mode 100644 projects/packages/videopress/tests/php/Playlist_Block_Test.php diff --git a/projects/packages/videopress/changelog/vidp-370-add-a-videopress-playlist-block b/projects/packages/videopress/changelog/vidp-370-add-a-videopress-playlist-block new file mode 100644 index 000000000000..9de10bdfbdea --- /dev/null +++ b/projects/packages/videopress/changelog/vidp-370-add-a-videopress-playlist-block @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Add a VideoPress playlist block that stores a list of video GUIDs and plays them in sequence. diff --git a/projects/packages/videopress/src/class-initializer.php b/projects/packages/videopress/src/class-initializer.php index 0114d6a8a300..9968c3749b0c 100644 --- a/projects/packages/videopress/src/class-initializer.php +++ b/projects/packages/videopress/src/class-initializer.php @@ -269,6 +269,9 @@ public static function video_enqueue_bridge_when_oembed_present( $cache, $url, $ public static function register_videopress_blocks() { // Register VideoPress Video block. self::register_videopress_video_block(); + + // Register VideoPress Playlist block. + self::register_videopress_playlist_block(); } /** @@ -552,6 +555,147 @@ public static function register_videopress_video_block() { Block_Editor_Extensions::init( $script_handle ); } + /** + * Register the VideoPress playlist block. + * + * @return void + */ + public static function register_videopress_playlist_block() { + /* + * Unlike the video block, the playlist block has no "activate the module" + * placeholder, so don't register it at all when VideoPress isn't available. + */ + if ( + Status::is_jetpack_plugin_without_videopress_module_active() + && ! Status::is_standalone_plugin_active() + ) { + return; + } + + $metadata_file = __DIR__ . '/../build/block-editor/blocks/playlist/block.json'; + if ( ! file_exists( $metadata_file ) ) { + return; + } + + $metadata = json_decode( + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents + file_get_contents( $metadata_file ) + ); + + if ( empty( $metadata->name ) ) { + return; + } + + // Do not register if the block is already registered. + if ( \WP_Block_Type_Registry::get_instance()->is_registered( $metadata->name ) ) { + return; + } + + register_block_type( + $metadata_file, + array( + 'render_callback' => array( __CLASS__, 'render_videopress_playlist_block' ), + ) + ); + } + + /** + * Build the VideoPress embed URL for a playlist entry. + * + * @param string $guid Video GUID. + * @param bool $autoplay Whether the video should start playing as soon as it loads. + * + * @return string Embed URL. + */ + private static function get_playlist_embed_url( $guid, $autoplay ) { + return add_query_arg( + array( + 'cover' => 1, + 'autoPlay' => (int) $autoplay, + 'preloadContent' => 'metadata', + ), + 'https://videopress.com/embed/' . rawurlencode( $guid ) + ); + } + + /** + * VideoPress playlist block render method. + * + * @param array $block_attributes Block attributes. + * + * @return string Block markup. + */ + public static function render_videopress_playlist_block( $block_attributes ) { + $videos = array(); + + if ( isset( $block_attributes['videos'] ) && is_array( $block_attributes['videos'] ) ) { + foreach ( $block_attributes['videos'] as $video ) { + if ( ! is_array( $video ) || empty( $video['guid'] ) || ! is_string( $video['guid'] ) ) { + continue; + } + + // VideoPress GUIDs are 8 alphanumeric characters; drop anything else. + if ( ! preg_match( '/^[a-z\d]{8}$/i', $video['guid'] ) ) { + continue; + } + + $videos[] = array( + 'guid' => $video['guid'], + 'title' => isset( $video['title'] ) && is_string( $video['title'] ) ? $video['title'] : '', + ); + } + } + + if ( ! $videos ) { + return ''; + } + + $auto_advance = ! isset( $block_attributes['autoAdvance'] ) || $block_attributes['autoAdvance']; + $loop = ! empty( $block_attributes['loop'] ); + + // The JWT token bridge lets the embed play private videos for authorized viewers. + Jwt_Token_Bridge::enqueue_jwt_token_bridge(); + + $items_markup = ''; + foreach ( $videos as $index => $video ) { + $title = '' !== $video['title'] + ? $video['title'] + /* translators: %d: position of the video in the playlist. */ + : sprintf( __( 'Video %d', 'jetpack-videopress-pkg' ), $index + 1 ); + + $items_markup .= sprintf( + '
  • ', + 0 === $index ? ' is-current' : '', + esc_attr( $video['guid'] ), + esc_url( self::get_playlist_embed_url( $video['guid'], true ) ), + 0 === $index ? 'true' : 'false', + $index + 1, + esc_html( $title ) + ); + } + + $player_markup = sprintf( + '
    ', + esc_attr__( 'VideoPress Playlist Player', 'jetpack-videopress-pkg' ), + esc_url( self::get_playlist_embed_url( $videos[0]['guid'], false ) ), + esc_attr( $videos[0]['guid'] ) + ); + + $wrapper_attributes = get_block_wrapper_attributes( + array( + 'data-auto-advance' => $auto_advance ? '1' : '0', + 'data-loop' => $loop ? '1' : '0', + ) + ); + + return sprintf( + '
    %2$s
      %3$s
    ', + $wrapper_attributes, + $player_markup, + $items_markup + ); + } + /** * Enqueue the VideoPress Iframe API script * when the URL of oEmbed HTML is a VideoPress URL. diff --git a/projects/packages/videopress/src/client/block-editor/blocks/playlist/block.json b/projects/packages/videopress/src/client/block-editor/blocks/playlist/block.json new file mode 100644 index 000000000000..edcd3e7b3c80 --- /dev/null +++ b/projects/packages/videopress/src/client/block-editor/blocks/playlist/block.json @@ -0,0 +1,42 @@ +{ + "$schema": "https://schemas.wp.org/trunk/block.json", + "apiVersion": 3, + "name": "videopress/playlist", + "version": "0.1.0", + "title": "VideoPress Playlist", + "category": "media", + "icon": "", + "description": "Play a list of VideoPress videos in sequence.", + "keywords": [ "playlist", "video", "videopress" ], + "supports": { + "html": false, + "align": true, + "anchor": true, + "spacing": { + "margin": true, + "padding": true + } + }, + "attributes": { + "videos": { + "type": "array", + "items": { + "type": "object" + }, + "default": [] + }, + "autoAdvance": { + "type": "boolean", + "default": true + }, + "loop": { + "type": "boolean", + "default": false + } + }, + "textdomain": "jetpack-videopress-pkg", + "editorScript": "file:./index.js", + "editorStyle": "file:./index.css", + "viewScript": "file:./view.js", + "viewStyle": "file:./view.css" +} diff --git a/projects/packages/videopress/src/client/block-editor/blocks/playlist/edit.tsx b/projects/packages/videopress/src/client/block-editor/blocks/playlist/edit.tsx new file mode 100644 index 000000000000..2efad3c951ae --- /dev/null +++ b/projects/packages/videopress/src/client/block-editor/blocks/playlist/edit.tsx @@ -0,0 +1,248 @@ +/** + * WordPress dependencies + */ +import { InspectorControls, useBlockProps } from '@wordpress/block-editor'; +import { Button, PanelBody, Placeholder, TextControl, ToggleControl } from '@wordpress/components'; +import { useState } from '@wordpress/element'; +import { __, sprintf } from '@wordpress/i18n'; +import { chevronDown, chevronUp, closeSmall } from '@wordpress/icons'; +/** + * Internal dependencies + */ +import { isVideoPressGuid, pickGUIDFromUrl } from '../../../lib/url'; +import { VideoPressIcon } from '../video/components/icons'; +import './editor.scss'; +/** + * Types + */ +import type { PlaylistBlockAttributes, PlaylistVideo } from './types'; +import type { BlockEditProps } from '@wordpress/blocks'; + +/** + * Extract a VideoPress GUID from user input, which can be + * either a bare GUID or any recognized VideoPress URL. + * + * @param value - Raw user input. + * @return The GUID, or null when the input is not recognized. + */ +function parseVideoInput( value: string ): string | null { + const trimmed = value.trim(); + if ( ! trimmed ) { + return null; + } + + const guid = isVideoPressGuid( trimmed ); + if ( guid ) { + return guid as string; + } + + return pickGUIDFromUrl( trimmed ); +} + +/** + * VideoPress Playlist block Edit component. + * + * @param props - Block edit props. + * @param props.attributes - Block attributes. + * @param props.setAttributes - Attributes setter. + * @return React component. + */ +export default function PlaylistBlockEdit( { + attributes, + setAttributes, +}: BlockEditProps< PlaylistBlockAttributes > ) { + const { videos, autoAdvance, loop } = attributes; + const [ currentIndex, setCurrentIndex ] = useState( 0 ); + const [ newVideoInput, setNewVideoInput ] = useState( '' ); + const [ inputError, setInputError ] = useState( false ); + + const blockProps = useBlockProps( { className: 'videopress-playlist-editor' } ); + + const currentVideo = videos[ currentIndex ] ?? videos[ 0 ]; + + const addVideo = () => { + const guid = parseVideoInput( newVideoInput ); + if ( ! guid ) { + setInputError( true ); + return; + } + + setAttributes( { videos: [ ...videos, { guid } ] } ); + setNewVideoInput( '' ); + setInputError( false ); + }; + + const removeVideo = ( index: number ) => { + setAttributes( { videos: videos.filter( ( _, i ) => i !== index ) } ); + if ( currentIndex >= index && currentIndex > 0 ) { + setCurrentIndex( currentIndex - 1 ); + } + }; + + const moveVideo = ( index: number, direction: -1 | 1 ) => { + const target = index + direction; + if ( target < 0 || target >= videos.length ) { + return; + } + + const reordered = [ ...videos ]; + [ reordered[ index ], reordered[ target ] ] = [ reordered[ target ], reordered[ index ] ]; + setAttributes( { videos: reordered } ); + + if ( currentIndex === index ) { + setCurrentIndex( target ); + } else if ( currentIndex === target ) { + setCurrentIndex( index ); + } + }; + + const updateVideoTitle = ( index: number, title: string ) => { + const updated = videos.map( ( video: PlaylistVideo, i: number ) => + i === index ? { ...video, title } : video + ); + setAttributes( { videos: updated } ); + }; + + const addVideoForm = ( +
    + { + setNewVideoInput( value ); + setInputError( false ); + } } + onKeyDown={ event => { + if ( event.key === 'Enter' ) { + event.preventDefault(); + addVideo(); + } + } } + help={ + inputError + ? __( 'Enter a VideoPress GUID or a VideoPress video URL.', 'jetpack-videopress-pkg' ) + : undefined + } + /> + +
    + ); + + if ( ! videos.length ) { + return ( +
    + + { addVideoForm } + +
    + ); + } + + return ( +
    + + + setAttributes( { autoAdvance: value } ) } + /> + setAttributes( { loop: value } ) } + /> + + + + { currentVideo && ( +
    +
    ` + + `
      ${ items }
    `; + + document.body.appendChild( block ); + return block; +} + +/** + * Dispatch a player message event like the VideoPress embed does. + * + * @param guid - GUID the message reports on. + * @param origin - Message origin. + * @param event - Player event name. + */ +function postPlayerMessage( + guid: string, + origin = 'https://videopress.com', + event = 'videopress_ended' +) { + window.dispatchEvent( new MessageEvent( 'message', { data: { event, id: guid }, origin } ) ); +} + +describe( 'playlist view script', () => { + afterEach( () => { + document.body.innerHTML = ''; + } ); + + it( 'swaps the player to the clicked item and marks it current', () => { + const block = buildPlaylist( [ 'aaaa1111', 'bbbb2222' ] ); + initPlaylist( block ); + + const player = block.querySelector< HTMLIFrameElement >( PLAYER_SELECTOR ); + const items = block.querySelectorAll< HTMLButtonElement >( ITEM_SELECTOR ); + + items[ 1 ].click(); + + expect( player.src ).toBe( 'https://videopress.com/embed/bbbb2222?autoPlay=1' ); + expect( player.dataset.guid ).toBe( 'bbbb2222' ); + expect( items[ 1 ] ).toHaveClass( 'is-current' ); + expect( items[ 1 ] ).toHaveAttribute( 'aria-current', 'true' ); + expect( items[ 0 ] ).not.toHaveClass( 'is-current' ); + expect( items[ 0 ] ).toHaveAttribute( 'aria-current', 'false' ); + } ); + + it( 'advances to the next video when the current one ends', () => { + const block = buildPlaylist( [ 'aaaa1111', 'bbbb2222' ] ); + initPlaylist( block ); + + postPlayerMessage( 'aaaa1111' ); + + const player = block.querySelector< HTMLIFrameElement >( PLAYER_SELECTOR ); + expect( player.src ).toBe( 'https://videopress.com/embed/bbbb2222?autoPlay=1' ); + } ); + + it( 'ignores ended messages from untrusted origins', () => { + const block = buildPlaylist( [ 'aaaa1111', 'bbbb2222' ] ); + initPlaylist( block ); + + postPlayerMessage( 'aaaa1111', 'https://evil.example' ); + + const player = block.querySelector< HTMLIFrameElement >( PLAYER_SELECTOR ); + expect( player.dataset.guid ).toBe( 'aaaa1111' ); + } ); + + it( 'ignores ended messages for videos other than the current one', () => { + const block = buildPlaylist( [ 'aaaa1111', 'bbbb2222', 'cccc3333' ] ); + initPlaylist( block ); + + postPlayerMessage( 'bbbb2222' ); + + const player = block.querySelector< HTMLIFrameElement >( PLAYER_SELECTOR ); + expect( player.dataset.guid ).toBe( 'aaaa1111' ); + } ); + + it( 'ignores non-ended player events', () => { + const block = buildPlaylist( [ 'aaaa1111', 'bbbb2222' ] ); + initPlaylist( block ); + + postPlayerMessage( 'aaaa1111', 'https://videopress.com', 'videopress_playing' ); + + const player = block.querySelector< HTMLIFrameElement >( PLAYER_SELECTOR ); + expect( player.dataset.guid ).toBe( 'aaaa1111' ); + } ); + + it( 'stops at the last video when looping is off', () => { + const block = buildPlaylist( [ 'aaaa1111', 'bbbb2222' ] ); + initPlaylist( block ); + + postPlayerMessage( 'aaaa1111' ); + postPlayerMessage( 'bbbb2222' ); + + const player = block.querySelector< HTMLIFrameElement >( PLAYER_SELECTOR ); + expect( player.dataset.guid ).toBe( 'bbbb2222' ); + } ); + + it( 'returns to the first video when looping is on', () => { + const block = buildPlaylist( [ 'aaaa1111', 'bbbb2222' ], '1', '1' ); + initPlaylist( block ); + + postPlayerMessage( 'aaaa1111' ); + postPlayerMessage( 'bbbb2222' ); + + const player = block.querySelector< HTMLIFrameElement >( PLAYER_SELECTOR ); + expect( player.src ).toBe( 'https://videopress.com/embed/aaaa1111?autoPlay=1' ); + } ); + + it( 'does not auto-advance when auto-advance is off, but clicks still work', () => { + const block = buildPlaylist( [ 'aaaa1111', 'bbbb2222' ], '0' ); + initPlaylist( block ); + + postPlayerMessage( 'aaaa1111' ); + + const player = block.querySelector< HTMLIFrameElement >( PLAYER_SELECTOR ); + expect( player.dataset.guid ).toBe( 'aaaa1111' ); + + block.querySelectorAll< HTMLButtonElement >( ITEM_SELECTOR )[ 1 ].click(); + expect( player.dataset.guid ).toBe( 'bbbb2222' ); + } ); + + it( 'keeps two playlists on the same page independent', () => { + const first = buildPlaylist( [ 'aaaa1111', 'bbbb2222' ] ); + const second = buildPlaylist( [ 'dddd4444', 'eeee5555' ] ); + initAllPlaylists(); + + postPlayerMessage( 'aaaa1111' ); + + expect( first.querySelector< HTMLIFrameElement >( PLAYER_SELECTOR ).dataset.guid ).toBe( + 'bbbb2222' + ); + expect( second.querySelector< HTMLIFrameElement >( PLAYER_SELECTOR ).dataset.guid ).toBe( + 'dddd4444' + ); + } ); + + it( 'bails on blocks without a player or items', () => { + const empty = document.createElement( 'figure' ); + empty.className = 'wp-block-videopress-playlist'; + document.body.appendChild( empty ); + + expect( () => initAllPlaylists() ).not.toThrow(); + } ); +} ); diff --git a/projects/packages/videopress/src/client/block-editor/blocks/playlist/view.ts b/projects/packages/videopress/src/client/block-editor/blocks/playlist/view.ts index 239faae89a97..a3dd97943e30 100644 --- a/projects/packages/videopress/src/client/block-editor/blocks/playlist/view.ts +++ b/projects/packages/videopress/src/client/block-editor/blocks/playlist/view.ts @@ -19,7 +19,7 @@ type PlayerMessage = { * * @param block - The playlist block wrapper element. */ -function initPlaylist( block: HTMLElement ) { +export function initPlaylist( block: HTMLElement ) { const player = block.querySelector< HTMLIFrameElement >( '.videopress-playlist__player' ); const items = Array.from( block.querySelectorAll< HTMLButtonElement >( '.videopress-playlist__item' ) @@ -85,8 +85,13 @@ function initPlaylist( block: HTMLElement ) { } ); } -domReady( () => { +/** + * Initialize every playlist block on the page. + */ +export function initAllPlaylists() { document .querySelectorAll< HTMLElement >( '.wp-block-videopress-playlist' ) .forEach( initPlaylist ); -} ); +} + +domReady( initAllPlaylists ); diff --git a/projects/packages/videopress/tests/php/Playlist_Block_Test.php b/projects/packages/videopress/tests/php/Playlist_Block_Test.php index 6f69c5454c30..27fb0ed22efe 100644 --- a/projects/packages/videopress/tests/php/Playlist_Block_Test.php +++ b/projects/packages/videopress/tests/php/Playlist_Block_Test.php @@ -27,28 +27,43 @@ class Playlist_Block_Test extends BaseTestCase { /** - * Set up before each test. + * Tear down after each test. */ - public function set_up() { - parent::set_up(); + public function tear_down() { + parent::tear_down(); - if ( ! \WP_Block_Type_Registry::get_instance()->is_registered( 'videopress/playlist' ) ) { - register_block_type( - 'videopress/playlist', - array( - 'render_callback' => array( VideoPress_Initializer::class, 'render_videopress_playlist_block' ), - ) - ); + if ( \WP_Block_Type_Registry::get_instance()->is_registered( 'videopress/playlist' ) ) { + \WP_Block_Type_Registry::get_instance()->unregister( 'videopress/playlist' ); } } /** - * Tear down after each test. + * Write a block.json fixture for registration tests and return its path. + * + * The build output isn't present when the PHP suite runs in CI, so + * registration is exercised against a fixture instead. + * + * @param array $metadata Metadata to encode. + * @return string Path to the fixture file. */ - public function tear_down() { - parent::tear_down(); + private function create_metadata_fixture( $metadata ) { + // register_block_type_from_metadata() requires the file to be named block.json. + $dir = get_temp_dir() . uniqid( 'playlist-block-', true ); + mkdir( $dir ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_mkdir + $file = $dir . '/block.json'; + file_put_contents( $file, wp_json_encode( $metadata, JSON_UNESCAPED_SLASHES ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents + + return $file; + } - \WP_Block_Type_Registry::get_instance()->unregister( 'videopress/playlist' ); + /** + * Remove a fixture created by create_metadata_fixture(). + * + * @param string $file Path returned by create_metadata_fixture(). + */ + private function remove_metadata_fixture( $file ) { + unlink( $file ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink + rmdir( dirname( $file ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir } /** @@ -59,11 +74,59 @@ public function tear_down() { * @return string Rendered markup. */ private function render( $attributes ) { + if ( ! \WP_Block_Type_Registry::get_instance()->is_registered( 'videopress/playlist' ) ) { + register_block_type( + 'videopress/playlist', + array( + 'render_callback' => array( VideoPress_Initializer::class, 'render_videopress_playlist_block' ), + ) + ); + } + // Empty attributes encode to `[]`, which the block parser rejects; omit them instead. $json = empty( $attributes ) ? '' : wp_json_encode( $attributes, JSON_UNESCAPED_SLASHES ) . ' '; return do_blocks( '' ); } + /** Tests that the block registers from metadata with the render callback wired. */ + public function test_registration_from_metadata() { + $fixture = $this->create_metadata_fixture( + array( + 'name' => 'videopress/playlist', + 'title' => 'VideoPress Playlist', + ) + ); + + VideoPress_Initializer::register_videopress_playlist_block( $fixture ); + + $registry = \WP_Block_Type_Registry::get_instance(); + $this->assertTrue( $registry->is_registered( 'videopress/playlist' ) ); + $this->assertSame( + array( VideoPress_Initializer::class, 'render_videopress_playlist_block' ), + $registry->get_registered( 'videopress/playlist' )->render_callback + ); + + // A second call must not fatal on (or duplicate) the existing registration. + VideoPress_Initializer::register_videopress_playlist_block( $fixture ); + $this->assertTrue( $registry->is_registered( 'videopress/playlist' ) ); + + $this->remove_metadata_fixture( $fixture ); + } + + /** Tests that registration is skipped when the metadata file is missing or unusable. */ + public function test_registration_skipped_without_usable_metadata() { + $registry = \WP_Block_Type_Registry::get_instance(); + + VideoPress_Initializer::register_videopress_playlist_block( '/nonexistent/block.json' ); + $this->assertFalse( $registry->is_registered( 'videopress/playlist' ) ); + + $nameless = $this->create_metadata_fixture( array( 'title' => 'No name' ) ); + VideoPress_Initializer::register_videopress_playlist_block( $nameless ); + $this->assertFalse( $registry->is_registered( 'videopress/playlist' ) ); + + $this->remove_metadata_fixture( $nameless ); + } + /** Tests that the player and playlist items are rendered. */ public function test_renders_player_and_items() { $html = $this->render( @@ -120,6 +183,8 @@ public function test_invalid_guids_are_dropped() { 'videos' => array( array( 'guid' => '">' ), array( 'guid' => 'short' ), + 'not-an-array-entry', + array( 'title' => 'No guid at all' ), array( 'guid' => 'abcd1234' ), ), ) From 3df2ce5485ac31d77ef16f0628061134006837ed Mon Sep 17 00:00:00 2001 From: Heyde Moura Date: Thu, 13 Aug 2026 14:22:05 -0300 Subject: [PATCH 3/7] Add media library selection to the playlist block Adds a 'Choose from library' button (MediaUpload, multiple selection) next to the GUID/URL input, so playlists can be built from the site's VideoPress videos. Library picks map attachments to GUIDs via videopress_guid and use the attachment title as the item title; selections with no VideoPress videos surface an error notice, shared with the GUID/URL validation path. Co-Authored-By: Claude Fable 5 --- .../block-editor/blocks/playlist/edit.tsx | 135 +++++++++++++----- .../block-editor/blocks/playlist/editor.scss | 11 +- .../blocks/playlist/test/edit.test.tsx | 54 ++++++- 3 files changed, 164 insertions(+), 36 deletions(-) diff --git a/projects/packages/videopress/src/client/block-editor/blocks/playlist/edit.tsx b/projects/packages/videopress/src/client/block-editor/blocks/playlist/edit.tsx index 2efad3c951ae..67ef30d3f5ad 100644 --- a/projects/packages/videopress/src/client/block-editor/blocks/playlist/edit.tsx +++ b/projects/packages/videopress/src/client/block-editor/blocks/playlist/edit.tsx @@ -1,8 +1,20 @@ /** * WordPress dependencies */ -import { InspectorControls, useBlockProps } from '@wordpress/block-editor'; -import { Button, PanelBody, Placeholder, TextControl, ToggleControl } from '@wordpress/components'; +import { + InspectorControls, + MediaUpload, + MediaUploadCheck, + useBlockProps, +} from '@wordpress/block-editor'; +import { + Button, + Notice, + PanelBody, + Placeholder, + TextControl, + ToggleControl, +} from '@wordpress/components'; import { useState } from '@wordpress/element'; import { __, sprintf } from '@wordpress/i18n'; import { chevronDown, chevronUp, closeSmall } from '@wordpress/icons'; @@ -11,11 +23,13 @@ import { chevronDown, chevronUp, closeSmall } from '@wordpress/icons'; */ import { isVideoPressGuid, pickGUIDFromUrl } from '../../../lib/url'; import { VideoPressIcon } from '../video/components/icons'; +import { VIDEOPRESS_VIDEO_ALLOWED_MEDIA_TYPES } from '../video/constants'; import './editor.scss'; /** * Types */ import type { PlaylistBlockAttributes, PlaylistVideo } from './types'; +import type { AdminAjaxQueryAttachmentsResponseItemProps } from '../../../types'; import type { BlockEditProps } from '@wordpress/blocks'; /** @@ -54,7 +68,7 @@ export default function PlaylistBlockEdit( { const { videos, autoAdvance, loop } = attributes; const [ currentIndex, setCurrentIndex ] = useState( 0 ); const [ newVideoInput, setNewVideoInput ] = useState( '' ); - const [ inputError, setInputError ] = useState( false ); + const [ errorNotice, setErrorNotice ] = useState< string | null >( null ); const blockProps = useBlockProps( { className: 'videopress-playlist-editor' } ); @@ -63,13 +77,53 @@ export default function PlaylistBlockEdit( { const addVideo = () => { const guid = parseVideoInput( newVideoInput ); if ( ! guid ) { - setInputError( true ); + setErrorNotice( + __( 'Enter a VideoPress GUID or a VideoPress video URL.', 'jetpack-videopress-pkg' ) + ); return; } setAttributes( { videos: [ ...videos, { guid } ] } ); setNewVideoInput( '' ); - setInputError( false ); + setErrorNotice( null ); + }; + + const addVideosFromLibrary = ( + selection: + | AdminAjaxQueryAttachmentsResponseItemProps + | AdminAjaxQueryAttachmentsResponseItemProps[] + ) => { + const mediaItems = Array.isArray( selection ) ? selection : [ selection ]; + + const libraryVideos: PlaylistVideo[] = []; + for ( const media of mediaItems ) { + // Depending on the endpoint, `videopress_guid` can be an array or a string. + const guid = Array.isArray( media?.videopress_guid ) + ? media.videopress_guid[ 0 ] + : media?.videopress_guid; + + if ( ! guid ) { + continue; + } + + libraryVideos.push( { + guid, + ...( typeof media.title === 'string' && media.title !== '' && { title: media.title } ), + } ); + } + + if ( ! libraryVideos.length ) { + setErrorNotice( + __( + 'None of the selected items are VideoPress videos. Choose videos hosted on VideoPress.', + 'jetpack-videopress-pkg' + ) + ); + return; + } + + setAttributes( { videos: [ ...videos, ...libraryVideos ] } ); + setErrorNotice( null ); }; const removeVideo = ( index: number ) => { @@ -104,33 +158,48 @@ export default function PlaylistBlockEdit( { }; const addVideoForm = ( -
    - { - setNewVideoInput( value ); - setInputError( false ); - } } - onKeyDown={ event => { - if ( event.key === 'Enter' ) { - event.preventDefault(); - addVideo(); - } - } } - help={ - inputError - ? __( 'Enter a VideoPress GUID or a VideoPress video URL.', 'jetpack-videopress-pkg' ) - : undefined - } - /> - +
    +
    + { + setNewVideoInput( value ); + setErrorNotice( null ); + } } + onKeyDown={ event => { + if ( event.key === 'Enter' ) { + event.preventDefault(); + addVideo(); + } + } } + /> + + + void } ) => ( + + ) } + /> + +
    + { errorNotice && ( + + { errorNotice } + + ) }
    ); @@ -141,7 +210,7 @@ export default function PlaylistBlockEdit( { icon={ VideoPressIcon } label={ __( 'VideoPress Playlist', 'jetpack-videopress-pkg' ) } instructions={ __( - 'Add VideoPress videos by GUID or URL to build a playlist that plays them in sequence.', + 'Build a playlist that plays videos in sequence. Choose videos from your VideoPress library, or add them by GUID or URL.', 'jetpack-videopress-pkg' ) } > diff --git a/projects/packages/videopress/src/client/block-editor/blocks/playlist/editor.scss b/projects/packages/videopress/src/client/block-editor/blocks/playlist/editor.scss index e71ea47f1e13..94f750081bbb 100644 --- a/projects/packages/videopress/src/client/block-editor/blocks/playlist/editor.scss +++ b/projects/packages/videopress/src/client/block-editor/blocks/playlist/editor.scss @@ -35,14 +35,23 @@ flex-grow: 1; } + &__add-container { + margin-block-start: 8px; + + .components-notice { + margin: 8px 0 0; + } + } + &__add { display: flex; align-items: flex-start; + flex-wrap: wrap; gap: 8px; - margin-block-start: 8px; .components-base-control { flex-grow: 1; + min-inline-size: 200px; } } } diff --git a/projects/packages/videopress/src/client/block-editor/blocks/playlist/test/edit.test.tsx b/projects/packages/videopress/src/client/block-editor/blocks/playlist/test/edit.test.tsx index 57a41a2e8f9e..1541a3e56baa 100644 --- a/projects/packages/videopress/src/client/block-editor/blocks/playlist/test/edit.test.tsx +++ b/projects/packages/videopress/src/client/block-editor/blocks/playlist/test/edit.test.tsx @@ -4,9 +4,20 @@ import Edit from '../edit'; import type { PlaylistBlockAttributes } from '../types'; import type { BlockEditProps } from '@wordpress/blocks'; +// What the mocked media modal "returns" when the library button is clicked. +let mockLibrarySelection: unknown = []; + jest.mock( '@wordpress/block-editor', () => ( { useBlockProps: ( props: Record< string, unknown > = {} ) => props, InspectorControls: ( { children }: { children: React.ReactNode } ) =>
    { children }
    , + MediaUploadCheck: ( { children }: { children: React.ReactNode } ) => <>{ children }, + MediaUpload: ( { + onSelect, + render: renderProp, + }: { + onSelect: ( selection: unknown ) => void; + render: ( props: { open: () => void } ) => React.ReactNode; + } ) => <>{ renderProp( { open: () => onSelect( mockLibrarySelection ) } ) }, } ) ); /** @@ -61,9 +72,10 @@ describe( 'PlaylistBlockEdit', () => { await user.click( screen.getByText( 'Add to playlist' ) ); expect( setAttributes ).not.toHaveBeenCalled(); + // The Notice also announces via an a11y live region, so match all. expect( - screen.getByText( 'Enter a VideoPress GUID or a VideoPress video URL.' ) - ).toBeInTheDocument(); + screen.getAllByText( 'Enter a VideoPress GUID or a VideoPress video URL.' ).length + ).toBeGreaterThan( 0 ); } ); it( 'previews the first video and lists every item', () => { @@ -131,6 +143,44 @@ describe( 'PlaylistBlockEdit', () => { ); } ); + it( 'adds VideoPress videos selected from the media library', async () => { + const user = userEvent.setup(); + const { setAttributes } = renderEdit( { videos: [ { guid: 'abcd1234' } ] } ); + + mockLibrarySelection = [ + { videopress_guid: [ 'efgh5678' ], title: 'Library video' }, + { videopress_guid: 'ijkl9012', title: '' }, + { title: 'Not a VideoPress video' }, + ]; + + await user.click( screen.getByText( 'Choose from library' ) ); + + expect( setAttributes ).toHaveBeenCalledWith( { + videos: [ + { guid: 'abcd1234' }, + { guid: 'efgh5678', title: 'Library video' }, + { guid: 'ijkl9012' }, + ], + } ); + } ); + + it( 'shows an error when no library selection is a VideoPress video', async () => { + const user = userEvent.setup(); + const { setAttributes } = renderEdit(); + + mockLibrarySelection = [ { title: 'Plain video attachment' } ]; + + await user.click( screen.getByText( 'Choose from library' ) ); + + expect( setAttributes ).not.toHaveBeenCalled(); + // The Notice also announces via an a11y live region, so match all. + expect( + screen.getAllByText( + 'None of the selected items are VideoPress videos. Choose videos hosted on VideoPress.' + ).length + ).toBeGreaterThan( 0 ); + } ); + it( 'toggles the auto-advance and loop settings', async () => { const user = userEvent.setup(); const { setAttributes } = renderEdit( { videos: [ { guid: 'abcd1234' } ] } ); From fd3a3faabb8ff5e16f031f099d6ed071660a17e4 Mon Sep 17 00:00:00 2001 From: Heyde Moura Date: Thu, 13 Aug 2026 14:50:24 -0300 Subject: [PATCH 4/7] Make playlist item titles read-only, sourced from video data Item titles in the playlist block editor are no longer editable text fields. Titles come from the video's own data: library selections keep the attachment title, and entries added by GUID or URL fetch the title from the VideoPress API, falling back to the GUID label when the video data isn't reachable. Co-Authored-By: Claude Fable 5 --- .../block-editor/blocks/playlist/edit.tsx | 59 +++++++++++++------ .../block-editor/blocks/playlist/editor.scss | 3 + .../blocks/playlist/test/edit.test.tsx | 42 ++++++++----- 3 files changed, 72 insertions(+), 32 deletions(-) diff --git a/projects/packages/videopress/src/client/block-editor/blocks/playlist/edit.tsx b/projects/packages/videopress/src/client/block-editor/blocks/playlist/edit.tsx index 67ef30d3f5ad..77ba15de601e 100644 --- a/projects/packages/videopress/src/client/block-editor/blocks/playlist/edit.tsx +++ b/projects/packages/videopress/src/client/block-editor/blocks/playlist/edit.tsx @@ -15,12 +15,14 @@ import { TextControl, ToggleControl, } from '@wordpress/components'; -import { useState } from '@wordpress/element'; +import { useEffect, useRef, useState } from '@wordpress/element'; +import { decodeEntities } from '@wordpress/html-entities'; import { __, sprintf } from '@wordpress/i18n'; import { chevronDown, chevronUp, closeSmall } from '@wordpress/icons'; /** * Internal dependencies */ +import { fetchVideoItem } from '../../../lib/fetch-video-item'; import { isVideoPressGuid, pickGUIDFromUrl } from '../../../lib/url'; import { VideoPressIcon } from '../video/components/icons'; import { VIDEOPRESS_VIDEO_ALLOWED_MEDIA_TYPES } from '../video/constants'; @@ -70,6 +72,41 @@ export default function PlaylistBlockEdit( { const [ newVideoInput, setNewVideoInput ] = useState( '' ); const [ errorNotice, setErrorNotice ] = useState< string | null >( null ); + // Always points at the latest videos so async title fetches never clobber newer edits. + const videosRef = useRef( videos ); + videosRef.current = videos; + + // GUIDs with a title fetch already started; they are not retried, so a + // failed fetch simply leaves the GUID as the visible label. + const titleFetchesStarted = useRef( new Set< string >() ); + + useEffect( () => { + videos.forEach( video => { + if ( video.title || titleFetchesStarted.current.has( video.guid ) ) { + return; + } + + titleFetchesStarted.current.add( video.guid ); + + fetchVideoItem( { guid: video.guid, isPrivate: false, skipRatingControl: true } ) + .then( videoItem => { + if ( ! videoItem?.title ) { + return; + } + + const title = decodeEntities( videoItem.title ); + setAttributes( { + videos: videosRef.current.map( entry => + entry.guid === video.guid && ! entry.title ? { ...entry, title } : entry + ), + } ); + } ) + .catch( () => { + // Leave the GUID as the visible label when the video data isn't reachable. + } ); + } ); + }, [ videos, setAttributes ] ); + const blockProps = useBlockProps( { className: 'videopress-playlist-editor' } ); const currentVideo = videos[ currentIndex ] ?? videos[ 0 ]; @@ -150,13 +187,6 @@ export default function PlaylistBlockEdit( { } }; - const updateVideoTitle = ( index: number, title: string ) => { - const updated = videos.map( ( video: PlaylistVideo, i: number ) => - i === index ? { ...video, title } : video - ); - setAttributes( { videos: updated } ); - }; - const addVideoForm = (
    @@ -280,16 +310,9 @@ export default function PlaylistBlockEdit( { > { index + 1 }. - updateVideoTitle( index, value ) } - /> + + { video.title || video.guid } + diff --git a/projects/packages/videopress/src/client/block-editor/blocks/playlist/test/edit.test.tsx b/projects/packages/videopress/src/client/block-editor/blocks/playlist/test/edit.test.tsx index 4cc4b0f56df4..33381692baf6 100644 --- a/projects/packages/videopress/src/client/block-editor/blocks/playlist/test/edit.test.tsx +++ b/projects/packages/videopress/src/client/block-editor/blocks/playlist/test/edit.test.tsx @@ -1,5 +1,6 @@ import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import { fetchVideoItem } from '../../../../lib/fetch-video-item'; import Edit from '../edit'; import type { PlaylistBlockAttributes } from '../types'; import type { BlockEditProps } from '@wordpress/blocks'; @@ -11,6 +12,8 @@ jest.mock( '../../../../lib/fetch-video-item', () => ( { fetchVideoItem: jest.fn( () => Promise.resolve( { title: 'Fetched title' } ) ), } ) ); +const mockFetchVideoItem = fetchVideoItem as jest.Mock; + jest.mock( '@wordpress/block-editor', () => ( { useBlockProps: ( props: Record< string, unknown > = {} ) => props, InspectorControls: ( { children }: { children: React.ReactNode } ) =>
    { children }
    , @@ -43,7 +46,7 @@ function renderEdit( attributes: Partial< PlaylistBlockAttributes > = {} ) { } describe( 'PlaylistBlockEdit', () => { - it( 'shows the placeholder and adds a video by GUID', async () => { + it( 'shows the placeholder and adds a video by GUID with its fetched title', async () => { const user = userEvent.setup(); const { setAttributes } = renderEdit(); @@ -52,10 +55,17 @@ describe( 'PlaylistBlockEdit', () => { await user.type( screen.getByPlaceholderText( 'VideoPress GUID or URL' ), 'abcd1234' ); await user.click( screen.getByText( 'Add to playlist' ) ); - expect( setAttributes ).toHaveBeenCalledWith( { videos: [ { guid: 'abcd1234' } ] } ); + await waitFor( () => + expect( setAttributes ).toHaveBeenCalledWith( { + videos: [ { guid: 'abcd1234', title: 'Fetched title' } ], + } ) + ); + expect( mockFetchVideoItem ).toHaveBeenCalledWith( + expect.objectContaining( { guid: 'abcd1234' } ) + ); } ); - it( 'accepts a VideoPress URL and extracts its GUID', async () => { + it( 'accepts a VideoPress URL and pulls the title from the video data', async () => { const user = userEvent.setup(); const { setAttributes } = renderEdit(); @@ -65,7 +75,24 @@ describe( 'PlaylistBlockEdit', () => { ); await user.keyboard( '{Enter}' ); - expect( setAttributes ).toHaveBeenCalledWith( { videos: [ { guid: 'efgh5678' } ] } ); + await waitFor( () => + expect( setAttributes ).toHaveBeenCalledWith( { + videos: [ { guid: 'efgh5678', title: 'Fetched title' } ], + } ) + ); + } ); + + it( 'still adds the video when the title fetch fails', async () => { + const user = userEvent.setup(); + mockFetchVideoItem.mockRejectedValueOnce( new Error( 'not reachable' ) ); + const { setAttributes } = renderEdit(); + + await user.type( screen.getByPlaceholderText( 'VideoPress GUID or URL' ), 'abcd1234' ); + await user.click( screen.getByText( 'Add to playlist' ) ); + + await waitFor( () => + expect( setAttributes ).toHaveBeenCalledWith( { videos: [ { guid: 'abcd1234' } ] } ) + ); } ); it( 'rejects unrecognized input with an error message', async () => { From e04cc78e849b18399bc653776ce7fd63e30ec153 Mon Sep 17 00:00:00 2001 From: Heyde Moura Date: Thu, 13 Aug 2026 15:36:06 -0300 Subject: [PATCH 6/7] Keep playlist titles in sync with the video data Titles now always reflect the videos' current metadata. In the editor, every entry is refreshed from the VideoPress API once per session and the stored title is replaced when it differs. On the frontend, the render callback pulls the current title from the public VideoPress API (transient-cached for an hour, with brief negative caching on failure) and falls back to the stored title when the API has no usable answer, e.g. for private videos. Co-Authored-By: Claude Fable 5 --- .../videopress/src/class-initializer.php | 52 +++++++++- .../block-editor/blocks/playlist/edit.tsx | 24 +++-- .../blocks/playlist/test/edit.test.tsx | 12 +++ .../tests/php/Playlist_Block_Test.php | 98 ++++++++++++++++++- 4 files changed, 175 insertions(+), 11 deletions(-) diff --git a/projects/packages/videopress/src/class-initializer.php b/projects/packages/videopress/src/class-initializer.php index 83a7b493e0d9..bac6e3f5256d 100644 --- a/projects/packages/videopress/src/class-initializer.php +++ b/projects/packages/videopress/src/class-initializer.php @@ -605,6 +605,50 @@ public static function register_videopress_playlist_block( $metadata_file = null ); } + /** + * Get the freshest available title for a playlist video. + * + * Reads the public VideoPress API so renamed videos show their current + * title, cached in a transient to keep renders cheap. Falls back to the + * title stored in the block attributes when the API has no usable answer + * (e.g. private videos or connectivity issues). + * + * @param string $guid Video GUID. + * @param string $stored_title Title stored in the block attributes. + * + * @return string Title to render. + */ + private static function get_playlist_video_title( $guid, $stored_title ) { + $cache_key = 'videopress_playlist_title_' . $guid; + $cached = get_transient( $cache_key ); + + if ( false !== $cached ) { + // An empty string is a negative-cache entry from a recent failed lookup. + return is_string( $cached ) && '' !== $cached ? $cached : $stored_title; + } + + $response = wp_remote_get( + 'https://public-api.wordpress.com/rest/v1.1/videos/' . rawurlencode( $guid ), + array( 'timeout' => 2 ) + ); + + if ( ! is_wp_error( $response ) && 200 === wp_remote_retrieve_response_code( $response ) ) { + $body = json_decode( wp_remote_retrieve_body( $response ), true ); + + if ( ! empty( $body['title'] ) && is_string( $body['title'] ) ) { + $title = wp_specialchars_decode( $body['title'], ENT_QUOTES ); + set_transient( $cache_key, $title, HOUR_IN_SECONDS ); + + return $title; + } + } + + // Negative-cache failures briefly so an unreachable API doesn't slow every render. + set_transient( $cache_key, '', 5 * MINUTE_IN_SECONDS ); + + return $stored_title; + } + /** * Build the VideoPress embed URL for a playlist entry. * @@ -664,10 +708,12 @@ public static function render_videopress_playlist_block( $block_attributes ) { $items_markup = ''; foreach ( $videos as $index => $video ) { - $title = '' !== $video['title'] - ? $video['title'] + $title = self::get_playlist_video_title( $video['guid'], $video['title'] ); + + if ( '' === $title ) { /* translators: %d: position of the video in the playlist. */ - : sprintf( __( 'Video %d', 'jetpack-videopress-pkg' ), $index + 1 ); + $title = sprintf( __( 'Video %d', 'jetpack-videopress-pkg' ), $index + 1 ); + } $items_markup .= sprintf( '
  • ', diff --git a/projects/packages/videopress/src/client/block-editor/blocks/playlist/edit.tsx b/projects/packages/videopress/src/client/block-editor/blocks/playlist/edit.tsx index 6454a73b4a0c..c726ab9364aa 100644 --- a/projects/packages/videopress/src/client/block-editor/blocks/playlist/edit.tsx +++ b/projects/packages/videopress/src/client/block-editor/blocks/playlist/edit.tsx @@ -77,13 +77,15 @@ export default function PlaylistBlockEdit( { const videosRef = useRef( videos ); videosRef.current = videos; - // GUIDs with a title fetch already started; they are not retried, so a - // failed fetch simply leaves the GUID as the visible label. + // GUIDs with a title refresh already started this editor session; they are + // not re-fetched, so a failed lookup simply keeps the stored label. const titleFetchesStarted = useRef( new Set< string >() ); + // Titles always mirror the video data: every entry is refreshed once per + // editor session, and the stored title is replaced whenever it differs. useEffect( () => { videos.forEach( video => { - if ( video.title || titleFetchesStarted.current.has( video.guid ) ) { + if ( titleFetchesStarted.current.has( video.guid ) ) { return; } @@ -96,14 +98,20 @@ export default function PlaylistBlockEdit( { } const title = decodeEntities( videoItem.title ); + const current = videosRef.current; + + if ( ! current.some( entry => entry.guid === video.guid && entry.title !== title ) ) { + return; + } + setAttributes( { - videos: videosRef.current.map( entry => - entry.guid === video.guid && ! entry.title ? { ...entry, title } : entry + videos: current.map( entry => + entry.guid === video.guid ? { ...entry, title } : entry ), } ); } ) .catch( () => { - // Leave the GUID as the visible label when the video data isn't reachable. + // Keep the stored title (or GUID) when the video data isn't reachable. } ); } ); }, [ videos, setAttributes ] ); @@ -134,10 +142,12 @@ export default function PlaylistBlockEdit( { const videoItem = await fetchVideoItem( { guid, isPrivate: false, skipRatingControl: true } ); if ( videoItem?.title ) { title = decodeEntities( videoItem.title ); + // Fresh from the video data; no need for the refresh effect to re-fetch it. + titleFetchesStarted.current.add( guid ); } } catch { // The entry still works without a title; the list shows the GUID and - // the backfill effect below retries once more. + // the refresh effect below retries once more. } setAttributes( { videos: [ ...videosRef.current, { guid, ...( title && { title } ) } ] } ); diff --git a/projects/packages/videopress/src/client/block-editor/blocks/playlist/test/edit.test.tsx b/projects/packages/videopress/src/client/block-editor/blocks/playlist/test/edit.test.tsx index 33381692baf6..787fcba6200a 100644 --- a/projects/packages/videopress/src/client/block-editor/blocks/playlist/test/edit.test.tsx +++ b/projects/packages/videopress/src/client/block-editor/blocks/playlist/test/edit.test.tsx @@ -145,6 +145,18 @@ describe( 'PlaylistBlockEdit', () => { ); } ); + it( 'replaces stored titles that differ from the video data', async () => { + const { setAttributes } = renderEdit( { + videos: [ { guid: 'abcd1234', title: 'Stale stored title' } ], + } ); + + await waitFor( () => + expect( setAttributes ).toHaveBeenCalledWith( { + videos: [ { guid: 'abcd1234', title: 'Fetched title' } ], + } ) + ); + } ); + it( 'removes an item from the playlist', async () => { const user = userEvent.setup(); const { setAttributes } = renderEdit( { diff --git a/projects/packages/videopress/tests/php/Playlist_Block_Test.php b/projects/packages/videopress/tests/php/Playlist_Block_Test.php index 27fb0ed22efe..ef4d33d2e0d4 100644 --- a/projects/packages/videopress/tests/php/Playlist_Block_Test.php +++ b/projects/packages/videopress/tests/php/Playlist_Block_Test.php @@ -26,6 +26,20 @@ #[PreserveGlobalState( false )] class Playlist_Block_Test extends BaseTestCase { + /** + * Number of title lookups attempted during the current test. + * + * @var int + */ + private $title_requests = 0; + + /** + * Canned response for title lookups, or null to simulate an unreachable API. + * + * @var array|null + */ + private $title_response = null; + /** * Tear down after each test. */ @@ -35,6 +49,29 @@ public function tear_down() { if ( \WP_Block_Type_Registry::get_instance()->is_registered( 'videopress/playlist' ) ) { \WP_Block_Type_Registry::get_instance()->unregister( 'videopress/playlist' ); } + + // Drop title transients so caching state never leaks between tests. + foreach ( array( 'abcd1234', 'efgh5678' ) as $guid ) { + delete_transient( 'videopress_playlist_title_' . $guid ); + } + } + + /** + * Intercept the title lookup so tests never hit the network. + * + * @param false|array|\WP_Error $preempt Whether to preempt the request. + * @param array $args Request arguments. + * @param string $url Request URL. + * @return false|array|\WP_Error + */ + public function intercept_title_request( $preempt, $args, $url ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable + if ( ! str_contains( $url, 'public-api.wordpress.com/rest/v1.1/videos/' ) ) { + return $preempt; + } + + ++$this->title_requests; + + return $this->title_response ?? new \WP_Error( 'http_request_failed', 'unreachable' ); } /** @@ -85,7 +122,12 @@ private function render( $attributes ) { // Empty attributes encode to `[]`, which the block parser rejects; omit them instead. $json = empty( $attributes ) ? '' : wp_json_encode( $attributes, JSON_UNESCAPED_SLASHES ) . ' '; - return do_blocks( '' ); + + add_filter( 'pre_http_request', array( $this, 'intercept_title_request' ), 10, 3 ); + $html = do_blocks( '' ); + remove_filter( 'pre_http_request', array( $this, 'intercept_title_request' ) ); + + return $html; } /** Tests that the block registers from metadata with the render callback wired. */ @@ -162,6 +204,60 @@ public function test_renders_player_and_items() { $this->assertStringContainsString( 'data-loop="0"', $html ); } + /** Tests that rendering pulls the current title from the video data over the stored one. */ + public function test_render_uses_fresh_title_from_video_data() { + $this->title_response = array( + 'response' => array( 'code' => 200 ), + 'body' => wp_json_encode( array( 'title' => 'Renamed on VideoPress' ), JSON_UNESCAPED_SLASHES ), + ); + + $html = $this->render( + array( + 'videos' => array( + array( + 'guid' => 'abcd1234', + 'title' => 'Stale stored title', + ), + ), + ) + ); + + $this->assertStringContainsString( 'Renamed on VideoPress', $html ); + $this->assertStringNotContainsString( 'Stale stored title', $html ); + } + + /** Tests that title lookups are cached and fall back to the stored title on failure. */ + public function test_render_title_lookups_are_cached() { + $this->title_response = array( + 'response' => array( 'code' => 200 ), + 'body' => wp_json_encode( array( 'title' => 'Fresh title' ), JSON_UNESCAPED_SLASHES ), + ); + + $attributes = array( + 'videos' => array( + array( + 'guid' => 'abcd1234', + 'title' => 'Stored title', + ), + ), + ); + + $this->render( $attributes ); + $this->render( $attributes ); + $this->assertSame( 1, $this->title_requests, 'The second render must be served from cache.' ); + + // Unreachable API: the stored title is kept, and the failure is negative-cached. + $this->title_requests = 0; + $this->title_response = null; + delete_transient( 'videopress_playlist_title_abcd1234' ); + + $html = $this->render( $attributes ); + $this->assertStringContainsString( 'Stored title', $html ); + + $this->render( $attributes ); + $this->assertSame( 1, $this->title_requests, 'Failed lookups must be negative-cached.' ); + } + /** Tests that autoAdvance and loop attributes reach the frontend dataset. */ public function test_auto_advance_and_loop_attributes() { $html = $this->render( From 5165c07da1995238299caa035c059a6f2680ce50 Mon Sep 17 00:00:00 2001 From: Heyde Moura Date: Thu, 13 Aug 2026 15:48:56 -0300 Subject: [PATCH 7/7] Suppress Phan duplicate-statement warning in the cache test The identical back-to-back render is the point of the test: the second call must be served from the transient cache. Co-Authored-By: Claude Fable 5 --- projects/packages/videopress/tests/php/Playlist_Block_Test.php | 1 + 1 file changed, 1 insertion(+) diff --git a/projects/packages/videopress/tests/php/Playlist_Block_Test.php b/projects/packages/videopress/tests/php/Playlist_Block_Test.php index ef4d33d2e0d4..c1ef1657eb87 100644 --- a/projects/packages/videopress/tests/php/Playlist_Block_Test.php +++ b/projects/packages/videopress/tests/php/Playlist_Block_Test.php @@ -243,6 +243,7 @@ public function test_render_title_lookups_are_cached() { ); $this->render( $attributes ); + // @phan-suppress-next-line PhanPluginDuplicateAdjacentStatement -- Deliberate identical re-render to prove the lookup is served from cache. $this->render( $attributes ); $this->assertSame( 1, $this->title_requests, 'The second render must be served from cache.' );