@automattic/newspack-blocks 4.33.1 → 4.34.0-alpha.2

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/CHANGELOG.md +7 -0
  2. package/dist/editor-rtl.css +4 -3
  3. package/dist/editor.asset.php +1 -1
  4. package/dist/editor.css +4 -3
  5. package/dist/editor.js +14 -14
  6. package/dist/modal.asset.php +1 -1
  7. package/dist/modal.js +1 -1
  8. package/includes/class-modal-checkout.php +248 -6
  9. package/includes/class-newspack-blocks-api.php +8 -0
  10. package/includes/class-newspack-blocks.php +38 -0
  11. package/languages/newspack-blocks-de_DE.po +124 -119
  12. package/languages/newspack-blocks-es_ES.po +124 -119
  13. package/languages/newspack-blocks-fr_BE.po +124 -119
  14. package/languages/newspack-blocks-nb_NO.po +124 -119
  15. package/languages/newspack-blocks-pt_PT.po +124 -119
  16. package/languages/newspack-blocks.pot +129 -125
  17. package/newspack-blocks.php +2 -2
  18. package/package.json +2 -2
  19. package/src/blocks/author-list/class-wp-rest-newspack-author-list-controller.php +6 -1
  20. package/src/blocks/author-profile/class-wp-rest-newspack-authors-controller.php +196 -48
  21. package/src/blocks/author-profile/edit.js +23 -65
  22. package/src/blocks/author-profile/single-author.js +7 -12
  23. package/src/blocks/author-profile/social-links.js +25 -0
  24. package/src/blocks/author-profile/social-links.test.js +42 -0
  25. package/src/blocks/checkout-button/view.php +4 -2
  26. package/src/blocks/homepage-articles/templates/article.php +1 -18
  27. package/src/modal-checkout/checkout-button-trigger.js +162 -24
  28. package/src/modal-checkout/checkout-button-trigger.test.js +149 -9
  29. package/src/modal-checkout/modal.js +31 -1
  30. package/src/shared/js/author-fetch.js +217 -0
  31. package/src/shared/js/author-fetch.test.js +230 -0
  32. package/vendor/composer/installed.php +2 -2
@@ -0,0 +1,217 @@
1
+ /**
2
+ * Shared author lookups for the editor.
3
+ *
4
+ * Every Author Profile block used to fetch its author with its own request, so a staff page
5
+ * with thirty blocks fired thirty requests at once and, on a site near its PHP worker limit,
6
+ * some were dropped. Lookups made within one short window are combined into a single call to
7
+ * the authors endpoint, results are shared between blocks and kept briefly, and identical
8
+ * suggestion-list requests are de-duplicated the same way.
9
+ */
10
+
11
+ /**
12
+ * WordPress dependencies
13
+ */
14
+ import apiFetch from '@wordpress/api-fetch';
15
+ import { addQueryArgs } from '@wordpress/url';
16
+
17
+ const ENDPOINT = '/newspack-blocks/v1/authors';
18
+
19
+ // Blocks mount in bursts as the editor renders. This is long enough to catch a burst and
20
+ // short enough to be invisible next to the request itself.
21
+ const BATCH_WINDOW_MS = 50;
22
+
23
+ // Most ids the endpoint resolves in one request. Larger batches are split to match, so a block
24
+ // past the limit is not told its author does not exist.
25
+ const MAX_BATCH_IDS = 100;
26
+
27
+ // Long enough to cover a page load and block re-mounts, short enough that a profile edited in
28
+ // another tab shows up on the next reload.
29
+ const CACHE_TTL_MS = 60 * 1000;
30
+
31
+ const cache = new Map();
32
+ const pendingBatches = new Map();
33
+ const inFlightLists = new Map();
34
+
35
+ const authorKey = ( authorId, isGuestAuthor ) => `${ isGuestAuthor ? 'g' : 'u' }:${ authorId }`;
36
+
37
+ const readCache = key => {
38
+ const hit = cache.get( key );
39
+ if ( ! hit ) {
40
+ return null;
41
+ }
42
+ if ( hit.expires < Date.now() ) {
43
+ cache.delete( key );
44
+ return null;
45
+ }
46
+ return hit;
47
+ };
48
+
49
+ const writeCache = ( key, value ) => cache.set( key, { expires: Date.now() + CACHE_TTL_MS, value } );
50
+
51
+ /**
52
+ * Request a set of authors, giving a failed request one more try.
53
+ *
54
+ * A request dropped by a busy server is the failure this fetcher exists to soften, and a whole
55
+ * page of blocks now rides on each one, so a single failure should not blank every block.
56
+ *
57
+ * @param {Object} params Query params.
58
+ * @return {Promise<Array>} The author records.
59
+ */
60
+ const requestAuthors = async params => {
61
+ const path = addQueryArgs( ENDPOINT, params );
62
+ try {
63
+ return await apiFetch( { path } );
64
+ } catch {
65
+ return apiFetch( { path } );
66
+ }
67
+ };
68
+
69
+ /**
70
+ * Resolve every caller covered by one request from its response.
71
+ *
72
+ * @param {string} batchKey Key grouping lookups that share fields and avatar options.
73
+ * @param {Array} entries Queued lookups the request carries.
74
+ * @param {Promise} request The request.
75
+ */
76
+ const settleEntries = async ( batchKey, entries, request ) => {
77
+ try {
78
+ const authors = await request;
79
+ const byKey = new Map( ( authors || [] ).map( author => [ authorKey( author.id, author.is_guest ), author ] ) );
80
+ entries.forEach( entry => {
81
+ const key = authorKey( entry.authorId, entry.isGuestAuthor );
82
+ // A guest id that matched no guest author comes back as the WP user with that id.
83
+ const author = byKey.get( key ) || ( entry.isGuestAuthor ? byKey.get( authorKey( entry.authorId, false ) ) : undefined );
84
+ writeCache( `${ batchKey }|${ key }`, author );
85
+ entry.settlers.forEach( ( { resolve } ) => resolve( author ) );
86
+ } );
87
+ } catch ( error ) {
88
+ entries.forEach( entry => entry.settlers.forEach( ( { reject } ) => reject( error ) ) );
89
+ }
90
+ };
91
+
92
+ /**
93
+ * Send the lookups queued under a batch key, in as many requests as the endpoint's limit needs.
94
+ *
95
+ * @param {string} batchKey Key grouping lookups that share fields and avatar options.
96
+ */
97
+ const flushBatch = batchKey => {
98
+ const batch = pendingBatches.get( batchKey );
99
+ pendingBatches.delete( batchKey );
100
+ if ( ! batch ) {
101
+ return;
102
+ }
103
+
104
+ const entries = Array.from( batch.entries.values() );
105
+ const userEntries = entries.filter( entry => ! entry.isGuestAuthor );
106
+ const guestEntries = entries.filter( entry => entry.isGuestAuthor );
107
+ const requests = Math.max( Math.ceil( userEntries.length / MAX_BATCH_IDS ), Math.ceil( guestEntries.length / MAX_BATCH_IDS ) );
108
+
109
+ for ( let i = 0; i < requests; i++ ) {
110
+ const users = userEntries.slice( i * MAX_BATCH_IDS, ( i + 1 ) * MAX_BATCH_IDS );
111
+ const guests = guestEntries.slice( i * MAX_BATCH_IDS, ( i + 1 ) * MAX_BATCH_IDS );
112
+ const params = { fields: batch.fields };
113
+ if ( batch.avatarHideDefault ) {
114
+ params.avatar_hide_default = 1;
115
+ }
116
+ if ( users.length ) {
117
+ params.author_ids = users.map( entry => entry.authorId ).join( ',' );
118
+ }
119
+ if ( guests.length ) {
120
+ params.guest_author_ids = guests.map( entry => entry.authorId ).join( ',' );
121
+ }
122
+ settleEntries( batchKey, [ ...users, ...guests ], requestAuthors( params ) );
123
+ }
124
+ };
125
+
126
+ /**
127
+ * Look up one author, sharing the request with every other lookup made in the same window.
128
+ *
129
+ * @param {Object} options
130
+ * @param {number} options.authorId WP user ID, or guest author post ID.
131
+ * @param {boolean} options.isGuestAuthor Whether the ID is a guest author.
132
+ * @param {string} options.fields Comma-separated fields to request.
133
+ * @param {boolean} options.avatarHideDefault Whether to hide the default avatar.
134
+ * @return {Promise<Object|undefined>} The author record, or undefined when none matched.
135
+ */
136
+ export function fetchAuthorById( { authorId, isGuestAuthor = false, fields, avatarHideDefault = false } ) {
137
+ const id = parseInt( authorId, 10 );
138
+ const batchKey = `${ fields }|${ avatarHideDefault ? 1 : 0 }`;
139
+ const key = authorKey( id, !! isGuestAuthor );
140
+
141
+ const cached = readCache( `${ batchKey }|${ key }` );
142
+ if ( cached ) {
143
+ return Promise.resolve( cached.value );
144
+ }
145
+
146
+ let batch = pendingBatches.get( batchKey );
147
+ if ( ! batch ) {
148
+ batch = {
149
+ fields,
150
+ avatarHideDefault,
151
+ entries: new Map(),
152
+ timer: setTimeout( () => flushBatch( batchKey ), BATCH_WINDOW_MS ),
153
+ };
154
+ pendingBatches.set( batchKey, batch );
155
+ }
156
+
157
+ let entry = batch.entries.get( key );
158
+ if ( ! entry ) {
159
+ entry = { authorId: id, isGuestAuthor: !! isGuestAuthor, settlers: [] };
160
+ batch.entries.set( key, entry );
161
+ }
162
+
163
+ return new Promise( ( resolve, reject ) => entry.settlers.push( { resolve, reject } ) );
164
+ }
165
+
166
+ /**
167
+ * Fetch a page of author suggestions, sharing identical in-flight requests.
168
+ *
169
+ * @param {Object} options
170
+ * @param {string} options.search Search string.
171
+ * @param {number} options.offset Offset for pagination.
172
+ * @param {string} options.fields Comma-separated fields to request.
173
+ * @return {Promise<{authors: Array, total: number}>} Authors and the total reported by the endpoint.
174
+ */
175
+ export function fetchAuthorList( { search, offset, fields = 'id,name' } = {} ) {
176
+ const params = { search: search || '', offset: offset || 0, fields };
177
+ const key = `list|${ params.search }|${ params.offset }|${ params.fields }`;
178
+ // Only the initial list is worth keeping: every empty block asks for it at once, while a typed
179
+ // search should see an author created or renamed a moment ago.
180
+ const keep = ! params.search && ! params.offset;
181
+
182
+ const cached = keep ? readCache( key ) : null;
183
+ if ( cached ) {
184
+ return Promise.resolve( cached.value );
185
+ }
186
+ if ( inFlightLists.has( key ) ) {
187
+ return inFlightLists.get( key );
188
+ }
189
+
190
+ // The request starts on the next tick, once it is recorded as in flight, so one that fails
191
+ // before it starts is still cleared instead of being handed to every later caller.
192
+ const request = Promise.resolve()
193
+ .then( () => apiFetch( { parse: false, path: addQueryArgs( ENDPOINT, params ) } ) )
194
+ .then( async response => {
195
+ const total = parseInt( response.headers.get( 'x-wp-total' ) || 0, 10 );
196
+ const authors = await response.json();
197
+ const result = { authors, total };
198
+ if ( keep ) {
199
+ writeCache( key, result );
200
+ }
201
+ return result;
202
+ } )
203
+ .finally( () => inFlightLists.delete( key ) );
204
+ inFlightLists.set( key, request );
205
+
206
+ return request;
207
+ }
208
+
209
+ /**
210
+ * Forget cached results and queued lookups. Intended for tests.
211
+ */
212
+ export function resetAuthorFetchCache() {
213
+ cache.clear();
214
+ pendingBatches.forEach( batch => clearTimeout( batch.timer ) );
215
+ pendingBatches.clear();
216
+ inFlightLists.clear();
217
+ }
@@ -0,0 +1,230 @@
1
+ import apiFetch from '@wordpress/api-fetch';
2
+ import { getQueryArgs } from '@wordpress/url';
3
+
4
+ import { fetchAuthorById, fetchAuthorList, resetAuthorFetchCache } from './author-fetch';
5
+
6
+ jest.mock( '@wordpress/api-fetch', () => ( { __esModule: true, default: jest.fn() } ) );
7
+
8
+ const FIELDS = 'id,name,bio';
9
+
10
+ const user = ( id, name ) => ( { id, is_guest: false, name } );
11
+ const guest = ( id, name ) => ( { id, is_guest: true, name } );
12
+
13
+ /**
14
+ * Let the batching window elapse and the resulting promises settle.
15
+ */
16
+ const flush = async () => {
17
+ await jest.advanceTimersByTimeAsync( 100 );
18
+ };
19
+
20
+ const argsOf = call => getQueryArgs( call[ 0 ].path );
21
+
22
+ beforeEach( () => {
23
+ jest.useFakeTimers();
24
+ apiFetch.mockReset();
25
+ resetAuthorFetchCache();
26
+ } );
27
+
28
+ afterEach( () => {
29
+ jest.useRealTimers();
30
+ } );
31
+
32
+ describe( 'fetchAuthorById', () => {
33
+ it( 'coalesces lookups made in the same window into one request', async () => {
34
+ apiFetch.mockResolvedValue( [ user( 1, 'One' ), user( 2, 'Two' ), guest( 5, 'Five' ) ] );
35
+
36
+ const lookups = Promise.all( [
37
+ fetchAuthorById( { authorId: 1, isGuestAuthor: false, fields: FIELDS } ),
38
+ fetchAuthorById( { authorId: 2, isGuestAuthor: false, fields: FIELDS } ),
39
+ fetchAuthorById( { authorId: 5, isGuestAuthor: true, fields: FIELDS } ),
40
+ ] );
41
+ await flush();
42
+ await lookups;
43
+
44
+ expect( apiFetch ).toHaveBeenCalledTimes( 1 );
45
+ expect( argsOf( apiFetch.mock.calls[ 0 ] ) ).toEqual( {
46
+ author_ids: '1,2',
47
+ guest_author_ids: '5',
48
+ fields: FIELDS,
49
+ } );
50
+ } );
51
+
52
+ it( 'resolves each caller with its own author', async () => {
53
+ apiFetch.mockResolvedValue( [ user( 1, 'One' ), guest( 1, 'Guest One' ) ] );
54
+
55
+ const lookups = Promise.all( [
56
+ fetchAuthorById( { authorId: 1, isGuestAuthor: false, fields: FIELDS } ),
57
+ fetchAuthorById( { authorId: 1, isGuestAuthor: true, fields: FIELDS } ),
58
+ ] );
59
+ await flush();
60
+ const [ asUser, asGuest ] = await lookups;
61
+
62
+ expect( asUser ).toEqual( user( 1, 'One' ) );
63
+ expect( asGuest ).toEqual( guest( 1, 'Guest One' ) );
64
+ } );
65
+
66
+ it( 'resolves undefined for an author the endpoint did not return', async () => {
67
+ apiFetch.mockResolvedValue( [ user( 1, 'One' ) ] );
68
+
69
+ const lookup = fetchAuthorById( { authorId: 404, isGuestAuthor: false, fields: FIELDS } );
70
+ await flush();
71
+
72
+ await expect( lookup ).resolves.toBeUndefined();
73
+ } );
74
+
75
+ it( 'keeps lookups with different fields or avatar options in separate requests', async () => {
76
+ apiFetch.mockResolvedValue( [ user( 1, 'One' ) ] );
77
+
78
+ const lookups = Promise.all( [
79
+ fetchAuthorById( { authorId: 1, isGuestAuthor: false, fields: FIELDS } ),
80
+ fetchAuthorById( { authorId: 1, isGuestAuthor: false, fields: 'id,name' } ),
81
+ fetchAuthorById( { authorId: 1, isGuestAuthor: false, fields: FIELDS, avatarHideDefault: true } ),
82
+ ] );
83
+ await flush();
84
+ await lookups;
85
+
86
+ expect( apiFetch ).toHaveBeenCalledTimes( 3 );
87
+ expect( argsOf( apiFetch.mock.calls[ 2 ] ) ).toMatchObject( { avatar_hide_default: '1' } );
88
+ } );
89
+
90
+ it( 'serves a repeated lookup from cache without a new request', async () => {
91
+ apiFetch.mockResolvedValue( [ user( 1, 'One' ) ] );
92
+
93
+ const first = fetchAuthorById( { authorId: 1, isGuestAuthor: false, fields: FIELDS } );
94
+ await flush();
95
+ await first;
96
+
97
+ const second = fetchAuthorById( { authorId: 1, isGuestAuthor: false, fields: FIELDS } );
98
+ await flush();
99
+
100
+ await expect( second ).resolves.toEqual( user( 1, 'One' ) );
101
+ expect( apiFetch ).toHaveBeenCalledTimes( 1 );
102
+ } );
103
+
104
+ it( 'resolves a guest lookup with the WP user the endpoint fell back to', async () => {
105
+ apiFetch.mockResolvedValue( [ user( 7, 'Legacy' ) ] );
106
+
107
+ const lookup = fetchAuthorById( { authorId: 7, isGuestAuthor: true, fields: FIELDS } );
108
+ await flush();
109
+
110
+ await expect( lookup ).resolves.toEqual( user( 7, 'Legacy' ) );
111
+ expect( argsOf( apiFetch.mock.calls[ 0 ] ) ).toEqual( { guest_author_ids: '7', fields: FIELDS } );
112
+ } );
113
+
114
+ it( 'splits a batch larger than the endpoint limit into several requests', async () => {
115
+ apiFetch.mockResolvedValue( [] );
116
+
117
+ const lookups = Promise.all(
118
+ Array.from( { length: 101 }, ( _, i ) => fetchAuthorById( { authorId: i + 1, isGuestAuthor: false, fields: FIELDS } ) )
119
+ );
120
+ await flush();
121
+ await lookups;
122
+
123
+ expect( apiFetch ).toHaveBeenCalledTimes( 2 );
124
+ expect( argsOf( apiFetch.mock.calls[ 0 ] ).author_ids.split( ',' ) ).toHaveLength( 100 );
125
+ expect( argsOf( apiFetch.mock.calls[ 1 ] ).author_ids ).toBe( '101' );
126
+ } );
127
+
128
+ it( 'retries a failed request once before rejecting', async () => {
129
+ apiFetch.mockRejectedValueOnce( new Error( 'The response is not a valid JSON response.' ) ).mockResolvedValueOnce( [ user( 1, 'One' ) ] );
130
+
131
+ const lookup = fetchAuthorById( { authorId: 1, isGuestAuthor: false, fields: FIELDS } );
132
+ await flush();
133
+
134
+ await expect( lookup ).resolves.toEqual( user( 1, 'One' ) );
135
+ expect( apiFetch ).toHaveBeenCalledTimes( 2 );
136
+ } );
137
+
138
+ it( 'rejects every caller when the request keeps failing and tries again on the next lookup', async () => {
139
+ apiFetch.mockRejectedValue( new Error( 'The response is not a valid JSON response.' ) );
140
+
141
+ const first = fetchAuthorById( { authorId: 1, isGuestAuthor: false, fields: FIELDS } );
142
+ const second = fetchAuthorById( { authorId: 2, isGuestAuthor: false, fields: FIELDS } );
143
+ first.catch( () => {} );
144
+ second.catch( () => {} );
145
+ await flush();
146
+
147
+ await expect( first ).rejects.toThrow( 'not a valid JSON response' );
148
+ await expect( second ).rejects.toThrow( 'not a valid JSON response' );
149
+ expect( apiFetch ).toHaveBeenCalledTimes( 2 );
150
+
151
+ apiFetch.mockResolvedValue( [ user( 1, 'One' ) ] );
152
+ const retry = fetchAuthorById( { authorId: 1, isGuestAuthor: false, fields: FIELDS } );
153
+ await flush();
154
+
155
+ await expect( retry ).resolves.toEqual( user( 1, 'One' ) );
156
+ expect( apiFetch ).toHaveBeenCalledTimes( 3 );
157
+ } );
158
+ } );
159
+
160
+ describe( 'fetchAuthorList', () => {
161
+ const response = ( authors, total ) => ( {
162
+ headers: { get: header => ( header === 'x-wp-total' ? String( total ) : null ) },
163
+ json: async () => authors,
164
+ } );
165
+
166
+ it( 'returns the authors and the total from the response headers', async () => {
167
+ apiFetch.mockResolvedValue( response( [ user( 1, 'One' ) ], 42 ) );
168
+
169
+ await expect( fetchAuthorList( { search: '', offset: 0, fields: 'id,name' } ) ).resolves.toEqual( {
170
+ authors: [ user( 1, 'One' ) ],
171
+ total: 42,
172
+ } );
173
+ expect( apiFetch.mock.calls[ 0 ][ 0 ] ).toMatchObject( { parse: false } );
174
+ expect( argsOf( apiFetch.mock.calls[ 0 ] ) ).toEqual( { search: '', offset: '0', fields: 'id,name' } );
175
+ } );
176
+
177
+ it( 'shares one request between identical concurrent lookups', async () => {
178
+ apiFetch.mockResolvedValue( response( [ user( 1, 'One' ) ], 1 ) );
179
+
180
+ const [ a, b ] = await Promise.all( [
181
+ fetchAuthorList( { search: '', offset: 0, fields: 'id,name' } ),
182
+ fetchAuthorList( { search: '', offset: 0, fields: 'id,name' } ),
183
+ ] );
184
+
185
+ expect( apiFetch ).toHaveBeenCalledTimes( 1 );
186
+ expect( a ).toEqual( b );
187
+ } );
188
+
189
+ it( 'requests again when the search differs', async () => {
190
+ apiFetch.mockResolvedValue( response( [], 0 ) );
191
+
192
+ await fetchAuthorList( { search: '', offset: 0, fields: 'id,name' } );
193
+ await fetchAuthorList( { search: 'jo', offset: 0, fields: 'id,name' } );
194
+
195
+ expect( apiFetch ).toHaveBeenCalledTimes( 2 );
196
+ } );
197
+
198
+ it( 'serves the initial list from cache once it has loaded', async () => {
199
+ apiFetch.mockResolvedValue( response( [ user( 1, 'One' ) ], 1 ) );
200
+
201
+ await fetchAuthorList( { search: '', offset: 0, fields: 'id,name' } );
202
+ await fetchAuthorList( { search: '', offset: 0, fields: 'id,name' } );
203
+
204
+ expect( apiFetch ).toHaveBeenCalledTimes( 1 );
205
+ } );
206
+
207
+ it( 'does not keep a typed search after it settles', async () => {
208
+ apiFetch.mockResolvedValue( response( [], 0 ) );
209
+
210
+ await fetchAuthorList( { search: 'jo', offset: 0, fields: 'id,name' } );
211
+ await fetchAuthorList( { search: 'jo', offset: 0, fields: 'id,name' } );
212
+
213
+ expect( apiFetch ).toHaveBeenCalledTimes( 2 );
214
+ } );
215
+
216
+ it( 'does not keep a request that failed before it started', async () => {
217
+ apiFetch.mockImplementationOnce( () => {
218
+ throw new Error( 'boom' );
219
+ } );
220
+
221
+ await expect( fetchAuthorList( { search: 'jo', offset: 0, fields: 'id,name' } ) ).rejects.toThrow( 'boom' );
222
+
223
+ apiFetch.mockResolvedValue( response( [ user( 1, 'One' ) ], 1 ) );
224
+
225
+ await expect( fetchAuthorList( { search: 'jo', offset: 0, fields: 'id,name' } ) ).resolves.toEqual( {
226
+ authors: [ user( 1, 'One' ) ],
227
+ total: 1,
228
+ } );
229
+ } );
230
+ } );
@@ -3,7 +3,7 @@
3
3
  'name' => 'automattic/newspack-blocks',
4
4
  'pretty_version' => 'dev-main',
5
5
  'version' => 'dev-main',
6
- 'reference' => 'bb97d4c46864b3260f24b22690bb570eaf91e404',
6
+ 'reference' => 'a27db19e71dc79f83fb88c77e58cde099da0b0a1',
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' => 'bb97d4c46864b3260f24b22690bb570eaf91e404',
16
+ 'reference' => 'a27db19e71dc79f83fb88c77e58cde099da0b0a1',
17
17
  'type' => 'wordpress-plugin',
18
18
  'install_path' => __DIR__ . '/../../',
19
19
  'aliases' => array(),