@automattic/newspack-blocks 4.34.2 → 4.35.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/AGENTS.md +1 -0
  2. package/CHANGELOG.md +21 -0
  3. package/dist/editor-rtl.css +17 -17
  4. package/dist/editor.asset.php +1 -1
  5. package/dist/editor.css +17 -17
  6. package/dist/editor.js +22 -22
  7. package/includes/class-newspack-blocks-api.php +94 -0
  8. package/includes/class-newspack-blocks-caching.php +1 -1
  9. package/includes/class-newspack-blocks.php +2 -0
  10. package/languages/newspack-blocks-de_DE-17dfe823d00514d2459851e9b0f738b1.json +1 -0
  11. package/languages/newspack-blocks-de_DE-53e2a1d5945b8d2b1c35e81ae1e532f3.json +1 -1
  12. package/languages/newspack-blocks-de_DE.po +15 -10
  13. package/languages/newspack-blocks-es_ES-17dfe823d00514d2459851e9b0f738b1.json +1 -0
  14. package/languages/newspack-blocks-es_ES-53e2a1d5945b8d2b1c35e81ae1e532f3.json +1 -1
  15. package/languages/newspack-blocks-es_ES.po +15 -10
  16. package/languages/newspack-blocks-fr_BE-17dfe823d00514d2459851e9b0f738b1.json +1 -0
  17. package/languages/newspack-blocks-fr_BE-53e2a1d5945b8d2b1c35e81ae1e532f3.json +1 -1
  18. package/languages/newspack-blocks-fr_BE.po +15 -10
  19. package/languages/newspack-blocks-nb_NO-17dfe823d00514d2459851e9b0f738b1.json +1 -0
  20. package/languages/newspack-blocks-nb_NO-53e2a1d5945b8d2b1c35e81ae1e532f3.json +1 -1
  21. package/languages/newspack-blocks-nb_NO.po +15 -10
  22. package/languages/newspack-blocks-pt_PT-17dfe823d00514d2459851e9b0f738b1.json +1 -0
  23. package/languages/newspack-blocks-pt_PT-53e2a1d5945b8d2b1c35e81ae1e532f3.json +1 -1
  24. package/languages/newspack-blocks-pt_PT.po +15 -10
  25. package/languages/newspack-blocks.pot +17 -11
  26. package/newspack-blocks.php +2 -2
  27. package/package.json +6 -6
  28. package/src/blocks/carousel/edit.js +1 -1
  29. package/src/blocks/homepage-articles/class-wp-rest-newspack-articles-controller.php +47 -0
  30. package/src/blocks/homepage-articles/store.js +101 -38
  31. package/src/blocks/homepage-articles/store.test.js +189 -0
  32. package/vendor/composer/installed.php +2 -2
@@ -11,6 +11,7 @@ import { set } from 'lodash';
11
11
  */
12
12
  import { register, select } from '@wordpress/data';
13
13
  import apiFetch from '@wordpress/api-fetch';
14
+ import { __ } from '@wordpress/i18n';
14
15
  import { addQueryArgs } from '@wordpress/url';
15
16
 
16
17
  /**
@@ -86,28 +87,103 @@ const POSTS_QUERIES_CACHE = {};
86
87
  const createCacheKey = JSON.stringify;
87
88
 
88
89
  /**
89
- * Get posts for a single block.
90
+ * The query a block's posts are cached under. A deduplicating block's result depends on
91
+ * the posts shown above it, so the exclusion list is part of its key.
92
+ *
93
+ * @param {Object} block an object with a postsQuery and a deduplicate flag
94
+ * @param {Array} exclude IDs of posts already shown above the block
95
+ * @return {Object} posts query
96
+ */
97
+ const effectiveQuery = ( block, exclude ) => ( block.deduplicate ? { ...block.postsQuery, exclude } : block.postsQuery );
98
+
99
+ /**
100
+ * Fetch posts for blocks in document order, carrying the exclusion list from each
101
+ * deduplicating block to the next.
102
+ *
103
+ * Blocks answered from the cache are dispatched without a request. From the first block
104
+ * that isn't cached, the rest go to the batch endpoint, which applies the exclusion list
105
+ * server-side, so a page costs one request instead of one per block. When only one block
106
+ * needs posts, it uses the single-block endpoint instead.
90
107
  *
91
108
  * @yield
92
- * @param {Object} block an object with a postsQuery and a clientId
109
+ * @param {Array} blockQueries objects with clientId, postsQuery and deduplicate, in document order
110
+ * @param {Array} exclude IDs of posts to exclude from the first deduplicating block
93
111
  */
94
- function* getPostsForBlock( block ) {
95
- const cacheKey = createCacheKey( block.postsQuery );
96
- const restUrl = window.newspack_blocks_data.posts_rest_url;
97
- let posts = POSTS_QUERIES_CACHE[ cacheKey ];
98
- if ( posts === undefined ) {
99
- const url = addQueryArgs( restUrl, {
100
- ...block.postsQuery,
101
- // `context=edit` is needed, so that custom REST fields are returned.
102
- context: 'edit',
103
- } );
104
- posts = yield call( apiFetch, { url } );
105
- POSTS_QUERIES_CACHE[ cacheKey ] = posts;
106
- }
112
+ export function* fetchPostsForBlocks( blockQueries, exclude ) {
113
+ const { posts_rest_url: singleUrl, posts_batch_rest_url: url } = window.newspack_blocks_data;
114
+ // Localized as a string, and the server enforces its own limit either way.
115
+ const maxQueries = Number( window.newspack_blocks_data.posts_batch_max_queries );
116
+ const pending = [ ...blockQueries ];
117
+
118
+ const showPosts = function* ( block, posts ) {
119
+ POSTS_QUERIES_CACHE[ createCacheKey( effectiveQuery( block, exclude ) ) ] = posts;
120
+ yield put( { type: 'UPDATE_BLOCK_POSTS', clientId: block.clientId, posts } );
121
+ if ( block.deduplicate ) {
122
+ exclude = [ ...exclude, ...posts.map( post => post.id ) ];
123
+ }
124
+ };
125
+
126
+ while ( pending.length ) {
127
+ const cached = POSTS_QUERIES_CACHE[ createCacheKey( effectiveQuery( pending[ 0 ], exclude ) ) ];
128
+ if ( cached !== undefined ) {
129
+ yield* showPosts( pending.shift(), cached );
130
+ continue;
131
+ }
132
+
133
+ const batch = pending.splice( 0, maxQueries );
134
+
135
+ if ( batch.length === 1 ) {
136
+ const [ block ] = batch;
137
+ try {
138
+ const posts = yield call( apiFetch, {
139
+ // `context=edit` is needed, so that custom REST fields are returned.
140
+ url: addQueryArgs( singleUrl, { ...effectiveQuery( block, exclude ), context: 'edit' } ),
141
+ } );
142
+ yield* showPosts( block, posts );
143
+ } catch ( e ) {
144
+ // A failed block adds nothing to the exclusion list, so later blocks can still load.
145
+ yield put( { type: 'UPDATE_BLOCK_ERROR', clientId: block.clientId, error: e.message } );
146
+ }
147
+ continue;
148
+ }
149
+
150
+ let results;
151
+ try {
152
+ results = yield call( apiFetch, {
153
+ url,
154
+ method: 'POST',
155
+ data: {
156
+ exclude,
157
+ queries: batch.map( ( { clientId, postsQuery, deduplicate } ) => ( {
158
+ clientId,
159
+ // `context=edit` is needed, so that custom REST fields are returned.
160
+ postsQuery: { ...postsQuery, context: 'edit' },
161
+ deduplicate,
162
+ } ) ),
163
+ },
164
+ } );
165
+ } catch ( e ) {
166
+ // Without this batch's posts the exclusion list for later blocks is unknown.
167
+ for ( const block of [ ...batch, ...pending ] ) {
168
+ yield put( { type: 'UPDATE_BLOCK_ERROR', clientId: block.clientId, error: e.message } );
169
+ }
170
+ return;
171
+ }
107
172
 
108
- const postsIds = posts.map( post => post.id );
109
- yield put( { type: 'UPDATE_BLOCK_POSTS', clientId: block.clientId, posts } );
110
- return postsIds;
173
+ const resultsByClientId = Object.fromEntries( results.map( result => [ result.clientId, result ] ) );
174
+ for ( const block of batch ) {
175
+ const result = resultsByClientId[ block.clientId ];
176
+ if ( result?.posts ) {
177
+ yield* showPosts( block, result.posts );
178
+ } else {
179
+ yield put( {
180
+ type: 'UPDATE_BLOCK_ERROR',
181
+ clientId: block.clientId,
182
+ error: result?.error || __( 'The posts for this block could not be loaded.', 'newspack-blocks' ),
183
+ } );
184
+ }
185
+ }
186
+ }
111
187
  }
112
188
 
113
189
  /**
@@ -140,33 +216,20 @@ const createFetchPostsSaga = blockNames => {
140
216
 
141
217
  const blocks = recursivelyGetBlocks( getBlocks );
142
218
 
143
- const blockQueries = getBlockQueries( blocks, blockNames );
219
+ const blockQueries = getBlockQueries( blocks, blockNames ).map( block => ( {
220
+ ...block,
221
+ deduplicate: Boolean( shouldDeduplicate( block.clientId ) ),
222
+ } ) );
144
223
 
145
224
  // Use requested specific posts ids as the starting state of exclusion list.
146
- const specificPostsId = blockQueries.reduce( ( acc, { clientId, postsQuery } ) => {
147
- if ( shouldDeduplicate( clientId ) && postsQuery.include ) {
225
+ const specificPostsId = blockQueries.reduce( ( acc, { deduplicate, postsQuery } ) => {
226
+ if ( deduplicate && postsQuery.include ) {
148
227
  acc = [ ...acc, ...postsQuery.include ];
149
228
  }
150
229
  return acc;
151
230
  }, [] );
152
231
 
153
- let exclude = sanitizePostList( [ ...specificPostsId, getCurrentPostId() ] );
154
- while ( blockQueries.length ) {
155
- const nextBlock = blockQueries.shift();
156
- const deduplicate = shouldDeduplicate( nextBlock.clientId );
157
- if ( deduplicate ) {
158
- nextBlock.postsQuery.exclude = exclude;
159
- }
160
- let fetchedPostIds = [];
161
- try {
162
- fetchedPostIds = yield call( getPostsForBlock, nextBlock );
163
- } catch ( e ) {
164
- yield put( { type: 'UPDATE_BLOCK_ERROR', clientId: nextBlock.clientId, error: e.message } );
165
- }
166
- if ( deduplicate ) {
167
- exclude = [ ...exclude, ...fetchedPostIds ];
168
- }
169
- }
232
+ yield call( fetchPostsForBlocks, blockQueries, sanitizePostList( [ ...specificPostsId, getCurrentPostId() ] ) );
170
233
 
171
234
  yield put( { type: 'ENABLE_UI' } );
172
235
  }
@@ -0,0 +1,189 @@
1
+ /**
2
+ * External dependencies
3
+ */
4
+ import { runSaga } from 'redux-saga';
5
+
6
+ /**
7
+ * WordPress dependencies
8
+ */
9
+ import { getQueryArgs } from '@wordpress/url';
10
+
11
+ jest.mock( '@wordpress/api-fetch' );
12
+
13
+ const SINGLE_URL = 'https://example.test/wp-json/newspack-blocks/v1/newspack-blocks-posts';
14
+ const BATCH_URL = 'https://example.test/wp-json/newspack-blocks/v1/newspack-blocks-posts-batch';
15
+
16
+ // Every query draws from posts 1–20, newest first, skipping the posts in its exclusion list.
17
+ const findPosts = ( postsToShow, exclude ) => {
18
+ const ids = [];
19
+ for ( let id = 1; id <= 20 && ids.length < postsToShow; id++ ) {
20
+ if ( ! exclude.includes( id ) ) {
21
+ ids.push( id );
22
+ }
23
+ }
24
+ return ids;
25
+ };
26
+
27
+ // Stands in for both endpoints. The batch endpoint carries the exclusion list from each
28
+ // deduplicating query to the next; the single-block endpoint takes its list from the URL.
29
+ const fakeEndpoints = ( { url, data } ) => {
30
+ if ( ! data ) {
31
+ const { postsToShow, exclude = [] } = getQueryArgs( url );
32
+ return Promise.resolve( findPosts( Number( postsToShow ), exclude.map( Number ) ).map( id => ( { id } ) ) );
33
+ }
34
+ let exclude = [ ...data.exclude ];
35
+ return Promise.resolve(
36
+ data.queries.map( ( { clientId, postsQuery, deduplicate } ) => {
37
+ if ( postsQuery.fail ) {
38
+ return { clientId, error: 'Invalid parameter(s): postsToShow' };
39
+ }
40
+ const ids = findPosts( postsQuery.postsToShow, deduplicate ? exclude : [] );
41
+ if ( deduplicate ) {
42
+ exclude = [ ...exclude, ...ids ];
43
+ }
44
+ return { clientId, posts: ids.map( id => ( { id } ) ) };
45
+ } )
46
+ );
47
+ };
48
+
49
+ const block = ( clientId, postsToShow, extra = {} ) => ( {
50
+ clientId,
51
+ postsQuery: { postsToShow, ...extra },
52
+ deduplicate: true,
53
+ } );
54
+
55
+ describe( 'fetchPostsForBlocks', () => {
56
+ // The query cache lives at module scope, so each test loads a fresh copy of the store,
57
+ // along with the api-fetch mock that copy calls.
58
+ let fetchPostsForBlocks, apiFetch;
59
+
60
+ const run = async ( blocks, exclude = [] ) => {
61
+ const dispatched = [];
62
+ await runSaga( { dispatch: action => dispatched.push( action ) }, fetchPostsForBlocks, blocks, exclude ).toPromise();
63
+ return {
64
+ postsByBlock: Object.fromEntries(
65
+ dispatched
66
+ .filter( action => action.type === 'UPDATE_BLOCK_POSTS' )
67
+ .map( action => [ action.clientId, action.posts.map( post => post.id ) ] )
68
+ ),
69
+ errorsByBlock: Object.fromEntries(
70
+ dispatched.filter( action => action.type === 'UPDATE_BLOCK_ERROR' ).map( action => [ action.clientId, action.error ] )
71
+ ),
72
+ };
73
+ };
74
+
75
+ beforeEach( () => {
76
+ // wp_localize_script casts every scalar to a string, so the limit arrives as one.
77
+ window.newspack_blocks_data = { posts_rest_url: SINGLE_URL, posts_batch_rest_url: BATCH_URL, posts_batch_max_queries: '50' };
78
+ jest.isolateModules( () => {
79
+ apiFetch = require( '@wordpress/api-fetch' ).default;
80
+ ( { fetchPostsForBlocks } = require( './store' ) );
81
+ } );
82
+ apiFetch.mockImplementation( fakeEndpoints );
83
+ } );
84
+
85
+ it( 'loads every block on the page with one request', async () => {
86
+ const { postsByBlock } = await run( [ block( 'a', 2 ), block( 'b', 2 ), block( 'c', 1 ) ], [ 20 ] );
87
+
88
+ expect( apiFetch ).toHaveBeenCalledTimes( 1 );
89
+ expect( apiFetch.mock.calls[ 0 ][ 0 ] ).toMatchObject( { url: BATCH_URL, method: 'POST', data: { exclude: [ 20 ] } } );
90
+ expect( postsByBlock ).toEqual( { a: [ 1, 2 ], b: [ 3, 4 ], c: [ 5 ] } );
91
+ } );
92
+
93
+ it( 'asks for custom REST fields', async () => {
94
+ await run( [ block( 'a', 1 ), block( 'b', 1 ) ] );
95
+
96
+ expect( apiFetch.mock.calls[ 0 ][ 0 ].data.queries[ 0 ].postsQuery.context ).toBe( 'edit' );
97
+ } );
98
+
99
+ it( 'uses the single-block request when only one block needs posts', async () => {
100
+ const { postsByBlock } = await run( [ block( 'a', 2 ) ], [ 1 ] );
101
+
102
+ expect( apiFetch ).toHaveBeenCalledTimes( 1 );
103
+ const [ { url, data, method } ] = apiFetch.mock.calls[ 0 ];
104
+ expect( url.startsWith( SINGLE_URL + '?' ) ).toBe( true );
105
+ expect( getQueryArgs( url ) ).toMatchObject( { context: 'edit', exclude: [ '1' ] } );
106
+ expect( data ).toBeUndefined();
107
+ expect( method ).toBeUndefined();
108
+ expect( postsByBlock ).toEqual( { a: [ 2, 3 ] } );
109
+ } );
110
+
111
+ it( 'fetches only the last block, on its own, when it is the one that changed', async () => {
112
+ await run( [ block( 'a', 2 ), block( 'b', 2 ) ] );
113
+ const { postsByBlock } = await run( [ block( 'a', 2 ), block( 'b', 3 ) ] );
114
+
115
+ expect( apiFetch ).toHaveBeenCalledTimes( 2 );
116
+ const [ { url, data } ] = apiFetch.mock.calls[ 1 ];
117
+ expect( data ).toBeUndefined();
118
+ expect( getQueryArgs( url ).exclude ).toEqual( [ '1', '2' ] );
119
+ expect( postsByBlock ).toEqual( { a: [ 1, 2 ], b: [ 3, 4, 5 ] } );
120
+ } );
121
+
122
+ it( 'reports a failed single-block request on that block only', async () => {
123
+ apiFetch.mockRejectedValueOnce( new Error( 'Service unavailable' ) );
124
+
125
+ const { postsByBlock, errorsByBlock } = await run( [ block( 'a', 1 ) ] );
126
+
127
+ expect( errorsByBlock ).toEqual( { a: 'Service unavailable' } );
128
+ expect( postsByBlock ).toEqual( {} );
129
+ } );
130
+
131
+ it( 'reuses cached posts when nothing on the page changed', async () => {
132
+ const blocks = [ block( 'a', 2 ), block( 'b', 2 ) ];
133
+ await run( blocks );
134
+ const { postsByBlock } = await run( blocks );
135
+
136
+ expect( apiFetch ).toHaveBeenCalledTimes( 1 );
137
+ expect( postsByBlock ).toEqual( { a: [ 1, 2 ], b: [ 3, 4 ] } );
138
+ } );
139
+
140
+ // Blocks above an edited block keep their cached posts. The edited block and everything
141
+ // below it are re-fetched, starting from the posts the blocks above show.
142
+ it( 'only re-fetches from the first block that changed', async () => {
143
+ await run( [ block( 'a', 2 ), block( 'b', 2 ), block( 'c', 2 ) ] );
144
+ const { postsByBlock } = await run( [ block( 'a', 2 ), block( 'b', 3 ), block( 'c', 2 ) ] );
145
+
146
+ expect( apiFetch ).toHaveBeenCalledTimes( 2 );
147
+ const { data } = apiFetch.mock.calls[ 1 ][ 0 ];
148
+ expect( data.queries.map( query => query.clientId ) ).toEqual( [ 'b', 'c' ] );
149
+ expect( data.exclude ).toEqual( [ 1, 2 ] );
150
+ expect( postsByBlock ).toEqual( { a: [ 1, 2 ], b: [ 3, 4, 5 ], c: [ 6, 7 ] } );
151
+ } );
152
+
153
+ it( 'keeps the posts of a block without deduplication available to the blocks below it', async () => {
154
+ const { postsByBlock } = await run( [ block( 'a', 2 ), { ...block( 'b', 2 ), deduplicate: false }, block( 'c', 2 ) ] );
155
+
156
+ expect( postsByBlock ).toEqual( { a: [ 1, 2 ], b: [ 1, 2 ], c: [ 3, 4 ] } );
157
+ } );
158
+
159
+ it( 'reports a failed query on its own block and still loads the others', async () => {
160
+ const { postsByBlock, errorsByBlock } = await run( [ block( 'a', 1 ), block( 'b', 1, { fail: true } ), block( 'c', 1 ) ] );
161
+
162
+ expect( errorsByBlock ).toEqual( { b: 'Invalid parameter(s): postsToShow' } );
163
+ expect( postsByBlock ).toEqual( { a: [ 1 ], c: [ 2 ] } );
164
+ } );
165
+
166
+ // Later blocks' exclusion lists depend on the failed batch, so they can't be loaded either.
167
+ it( 'reports an error on every remaining block when a batch request fails', async () => {
168
+ window.newspack_blocks_data.posts_batch_max_queries = '2';
169
+ apiFetch.mockImplementationOnce( fakeEndpoints ).mockRejectedValueOnce( new Error( 'Service unavailable' ) );
170
+
171
+ const { postsByBlock, errorsByBlock } = await run( [ block( 'a', 1 ), block( 'b', 1 ), block( 'c', 1 ), block( 'd', 1 ), block( 'e', 1 ) ] );
172
+
173
+ expect( postsByBlock ).toEqual( { a: [ 1 ], b: [ 2 ] } );
174
+ expect( errorsByBlock ).toEqual( { c: 'Service unavailable', d: 'Service unavailable', e: 'Service unavailable' } );
175
+ expect( apiFetch ).toHaveBeenCalledTimes( 2 );
176
+ } );
177
+
178
+ it( 'splits a page larger than the batch limit and carries deduplication across batches', async () => {
179
+ window.newspack_blocks_data.posts_batch_max_queries = '2';
180
+
181
+ const { postsByBlock } = await run( [ block( 'a', 1 ), block( 'b', 1 ), block( 'c', 1 ), block( 'd', 1 ), block( 'e', 1 ) ] );
182
+
183
+ expect( apiFetch ).toHaveBeenCalledTimes( 3 );
184
+ expect( apiFetch.mock.calls[ 1 ][ 0 ].data.exclude ).toEqual( [ 1, 2 ] );
185
+ // The last block is left over on its own, so it uses the single-block request.
186
+ expect( getQueryArgs( apiFetch.mock.calls[ 2 ][ 0 ].url ).exclude ).toEqual( [ '1', '2', '3', '4' ] );
187
+ expect( postsByBlock ).toEqual( { a: [ 1 ], b: [ 2 ], c: [ 3 ], d: [ 4 ], e: [ 5 ] } );
188
+ } );
189
+ } );
@@ -3,7 +3,7 @@
3
3
  'name' => 'automattic/newspack-blocks',
4
4
  'pretty_version' => 'dev-main',
5
5
  'version' => 'dev-main',
6
- 'reference' => '522ae9aeb3235a91972f3835a4c21aff6acc5543',
6
+ 'reference' => 'ce1df4abbe28872d98ac84032eabca65ae1b6abf',
7
7
  'type' => 'wordpress-plugin',
8
8
  'install_path' => __DIR__ . '/../../',
9
9
  'aliases' => array(),
@@ -13,7 +13,7 @@
13
13
  'automattic/newspack-blocks' => array(
14
14
  'pretty_version' => 'dev-main',
15
15
  'version' => 'dev-main',
16
- 'reference' => '522ae9aeb3235a91972f3835a4c21aff6acc5543',
16
+ 'reference' => 'ce1df4abbe28872d98ac84032eabca65ae1b6abf',
17
17
  'type' => 'wordpress-plugin',
18
18
  'install_path' => __DIR__ . '/../../',
19
19
  'aliases' => array(),