@wordpress/core-data 7.0.0 → 7.1.0

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 (42) hide show
  1. package/CHANGELOG.md +2 -0
  2. package/build/entity-types/helpers.js.map +1 -1
  3. package/build/entity-types/plugin.js.map +1 -1
  4. package/build/entity-types/theme.js.map +1 -1
  5. package/build/fetch/__experimental-fetch-link-suggestions.js +89 -118
  6. package/build/fetch/__experimental-fetch-link-suggestions.js.map +1 -1
  7. package/build/queried-data/reducer.js +3 -3
  8. package/build/queried-data/reducer.js.map +1 -1
  9. package/build/reducer.js +1 -1
  10. package/build/reducer.js.map +1 -1
  11. package/build/resolvers.js +1 -1
  12. package/build/resolvers.js.map +1 -1
  13. package/build-module/entity-types/helpers.js.map +1 -1
  14. package/build-module/entity-types/plugin.js.map +1 -1
  15. package/build-module/entity-types/theme.js.map +1 -1
  16. package/build-module/fetch/__experimental-fetch-link-suggestions.js +86 -118
  17. package/build-module/fetch/__experimental-fetch-link-suggestions.js.map +1 -1
  18. package/build-module/queried-data/reducer.js +3 -3
  19. package/build-module/queried-data/reducer.js.map +1 -1
  20. package/build-module/reducer.js +1 -1
  21. package/build-module/reducer.js.map +1 -1
  22. package/build-module/resolvers.js +1 -1
  23. package/build-module/resolvers.js.map +1 -1
  24. package/build-types/entity-types/helpers.d.ts +1 -1
  25. package/build-types/entity-types/plugin.d.ts +1 -1
  26. package/build-types/entity-types/plugin.d.ts.map +1 -1
  27. package/build-types/entity-types/theme.d.ts +9 -0
  28. package/build-types/entity-types/theme.d.ts.map +1 -1
  29. package/build-types/fetch/__experimental-fetch-link-suggestions.d.ts +48 -84
  30. package/build-types/fetch/__experimental-fetch-link-suggestions.d.ts.map +1 -1
  31. package/build-types/queried-data/reducer.d.ts.map +1 -1
  32. package/package.json +17 -17
  33. package/src/entity-types/helpers.ts +1 -1
  34. package/src/entity-types/plugin.ts +1 -1
  35. package/src/entity-types/theme.ts +10 -0
  36. package/src/fetch/__experimental-fetch-link-suggestions.ts +296 -0
  37. package/src/fetch/test/__experimental-fetch-link-suggestions.js +95 -1
  38. package/src/queried-data/reducer.js +4 -3
  39. package/src/reducer.js +1 -1
  40. package/src/resolvers.js +1 -1
  41. package/tsconfig.tsbuildinfo +1 -1
  42. package/src/fetch/__experimental-fetch-link-suggestions.js +0 -237
@@ -0,0 +1,296 @@
1
+ /**
2
+ * WordPress dependencies
3
+ */
4
+ import apiFetch from '@wordpress/api-fetch';
5
+ import { addQueryArgs } from '@wordpress/url';
6
+ import { decodeEntities } from '@wordpress/html-entities';
7
+ import { __ } from '@wordpress/i18n';
8
+
9
+ export type SearchOptions = {
10
+ /**
11
+ * Displays initial search suggestions, when true.
12
+ */
13
+ isInitialSuggestions?: boolean;
14
+ /**
15
+ * Search options for initial suggestions.
16
+ */
17
+ initialSuggestionsSearchOptions?: Omit<
18
+ SearchOptions,
19
+ 'isInitialSuggestions' | 'initialSuggestionsSearchOptions'
20
+ >;
21
+ /**
22
+ * Filters by search type.
23
+ */
24
+ type?: 'attachment' | 'post' | 'term' | 'post-format';
25
+ /**
26
+ * Slug of the post-type or taxonomy.
27
+ */
28
+ subtype?: string;
29
+ /**
30
+ * Which page of results to return.
31
+ */
32
+ page?: number;
33
+ /**
34
+ * Search results per page.
35
+ */
36
+ perPage?: number;
37
+ };
38
+
39
+ export type EditorSettings = {
40
+ /**
41
+ * Disables post formats, when true.
42
+ */
43
+ disablePostFormats?: boolean;
44
+ };
45
+
46
+ type SearchAPIResult = {
47
+ id: number;
48
+ title: string;
49
+ url: string;
50
+ type: string;
51
+ subtype: string;
52
+ };
53
+
54
+ type MediaAPIResult = {
55
+ id: number;
56
+ title: { rendered: string };
57
+ source_url: string;
58
+ type: string;
59
+ };
60
+
61
+ export type SearchResult = {
62
+ /**
63
+ * Post or term id.
64
+ */
65
+ id: number;
66
+ /**
67
+ * Link url.
68
+ */
69
+ url: string;
70
+ /**
71
+ * Title of the link.
72
+ */
73
+ title: string;
74
+ /**
75
+ * The taxonomy or post type slug or type URL.
76
+ */
77
+ type: string;
78
+ /**
79
+ * Link kind of post-type or taxonomy
80
+ */
81
+ kind?: string;
82
+ };
83
+
84
+ /**
85
+ * Fetches link suggestions from the WordPress API.
86
+ *
87
+ * WordPress does not support searching multiple tables at once, e.g. posts and terms, so we
88
+ * perform multiple queries at the same time and then merge the results together.
89
+ *
90
+ * @param search
91
+ * @param searchOptions
92
+ * @param editorSettings
93
+ *
94
+ * @example
95
+ * ```js
96
+ * import { __experimentalFetchLinkSuggestions as fetchLinkSuggestions } from '@wordpress/core-data';
97
+ *
98
+ * //...
99
+ *
100
+ * export function initialize( id, settings ) {
101
+ *
102
+ * settings.__experimentalFetchLinkSuggestions = (
103
+ * search,
104
+ * searchOptions
105
+ * ) => fetchLinkSuggestions( search, searchOptions, settings );
106
+ * ```
107
+ */
108
+ export default async function fetchLinkSuggestions(
109
+ search: string,
110
+ searchOptions: SearchOptions = {},
111
+ editorSettings: EditorSettings = {}
112
+ ): Promise< SearchResult[] > {
113
+ const searchOptionsToUse =
114
+ searchOptions.isInitialSuggestions &&
115
+ searchOptions.initialSuggestionsSearchOptions
116
+ ? {
117
+ ...searchOptions,
118
+ ...searchOptions.initialSuggestionsSearchOptions,
119
+ }
120
+ : searchOptions;
121
+
122
+ const {
123
+ type,
124
+ subtype,
125
+ page,
126
+ perPage = searchOptions.isInitialSuggestions ? 3 : 20,
127
+ } = searchOptionsToUse;
128
+
129
+ const { disablePostFormats = false } = editorSettings;
130
+
131
+ const queries: Promise< SearchResult[] >[] = [];
132
+
133
+ if ( ! type || type === 'post' ) {
134
+ queries.push(
135
+ apiFetch< SearchAPIResult[] >( {
136
+ path: addQueryArgs( '/wp/v2/search', {
137
+ search,
138
+ page,
139
+ per_page: perPage,
140
+ type: 'post',
141
+ subtype,
142
+ } ),
143
+ } )
144
+ .then( ( results ) => {
145
+ return results.map( ( result ) => {
146
+ return {
147
+ id: result.id,
148
+ url: result.url,
149
+ title:
150
+ decodeEntities( result.title || '' ) ||
151
+ __( '(no title)' ),
152
+ type: result.subtype || result.type,
153
+ kind: 'post-type',
154
+ };
155
+ } );
156
+ } )
157
+ .catch( () => [] ) // Fail by returning no results.
158
+ );
159
+ }
160
+
161
+ if ( ! type || type === 'term' ) {
162
+ queries.push(
163
+ apiFetch< SearchAPIResult[] >( {
164
+ path: addQueryArgs( '/wp/v2/search', {
165
+ search,
166
+ page,
167
+ per_page: perPage,
168
+ type: 'term',
169
+ subtype,
170
+ } ),
171
+ } )
172
+ .then( ( results ) => {
173
+ return results.map( ( result ) => {
174
+ return {
175
+ id: result.id,
176
+ url: result.url,
177
+ title:
178
+ decodeEntities( result.title || '' ) ||
179
+ __( '(no title)' ),
180
+ type: result.subtype || result.type,
181
+ kind: 'taxonomy',
182
+ };
183
+ } );
184
+ } )
185
+ .catch( () => [] ) // Fail by returning no results.
186
+ );
187
+ }
188
+
189
+ if ( ! disablePostFormats && ( ! type || type === 'post-format' ) ) {
190
+ queries.push(
191
+ apiFetch< SearchAPIResult[] >( {
192
+ path: addQueryArgs( '/wp/v2/search', {
193
+ search,
194
+ page,
195
+ per_page: perPage,
196
+ type: 'post-format',
197
+ subtype,
198
+ } ),
199
+ } )
200
+ .then( ( results ) => {
201
+ return results.map( ( result ) => {
202
+ return {
203
+ id: result.id,
204
+ url: result.url,
205
+ title:
206
+ decodeEntities( result.title || '' ) ||
207
+ __( '(no title)' ),
208
+ type: result.subtype || result.type,
209
+ kind: 'taxonomy',
210
+ };
211
+ } );
212
+ } )
213
+ .catch( () => [] ) // Fail by returning no results.
214
+ );
215
+ }
216
+
217
+ if ( ! type || type === 'attachment' ) {
218
+ queries.push(
219
+ apiFetch< MediaAPIResult[] >( {
220
+ path: addQueryArgs( '/wp/v2/media', {
221
+ search,
222
+ page,
223
+ per_page: perPage,
224
+ } ),
225
+ } )
226
+ .then( ( results ) => {
227
+ return results.map( ( result ) => {
228
+ return {
229
+ id: result.id,
230
+ url: result.source_url,
231
+ title:
232
+ decodeEntities( result.title.rendered || '' ) ||
233
+ __( '(no title)' ),
234
+ type: result.type,
235
+ kind: 'media',
236
+ };
237
+ } );
238
+ } )
239
+ .catch( () => [] ) // Fail by returning no results.
240
+ );
241
+ }
242
+
243
+ const responses = await Promise.all( queries );
244
+
245
+ let results = responses.flat();
246
+ results = results.filter( ( result ) => !! result.id );
247
+ results = sortResults( results, search );
248
+ results = results.slice( 0, perPage );
249
+ return results;
250
+ }
251
+
252
+ /**
253
+ * Sort search results by relevance to the given query.
254
+ *
255
+ * Sorting is necessary as we're querying multiple endpoints and merging the results. For example
256
+ * a taxonomy title might be more relevant than a post title, but by default taxonomy results will
257
+ * be ordered after all the (potentially irrelevant) post results.
258
+ *
259
+ * We sort by scoring each result, where the score is the number of tokens in the title that are
260
+ * also in the search query, divided by the total number of tokens in the title. This gives us a
261
+ * score between 0 and 1, where 1 is a perfect match.
262
+ *
263
+ * @param results
264
+ * @param search
265
+ */
266
+ export function sortResults( results: SearchResult[], search: string ) {
267
+ const searchTokens = new Set( tokenize( search ) );
268
+
269
+ const scores = {};
270
+ for ( const result of results ) {
271
+ if ( result.title ) {
272
+ const titleTokens = tokenize( result.title );
273
+ const matchingTokens = titleTokens.filter( ( token ) =>
274
+ searchTokens.has( token )
275
+ );
276
+ scores[ result.id ] = matchingTokens.length / titleTokens.length;
277
+ } else {
278
+ scores[ result.id ] = 0;
279
+ }
280
+ }
281
+
282
+ return results.sort( ( a, b ) => scores[ b.id ] - scores[ a.id ] );
283
+ }
284
+
285
+ /**
286
+ * Turns text into an array of tokens, with whitespace and punctuation removed.
287
+ *
288
+ * For example, `"I'm having a ball."` becomes `[ "im", "having", "a", "ball" ]`.
289
+ *
290
+ * @param text
291
+ */
292
+ export function tokenize( text: string ): string[] {
293
+ // \p{L} matches any kind of letter from any language.
294
+ // \p{N} matches any kind of numeric character.
295
+ return text.toLowerCase().match( /[\p{L}\p{N}]+/gu ) || [];
296
+ }
@@ -1,7 +1,11 @@
1
1
  /**
2
2
  * Internal dependencies
3
3
  */
4
- import fetchLinkSuggestions from '../__experimental-fetch-link-suggestions';
4
+ import {
5
+ default as fetchLinkSuggestions,
6
+ sortResults,
7
+ tokenize,
8
+ } from '../__experimental-fetch-link-suggestions';
5
9
 
6
10
  jest.mock( '@wordpress/api-fetch', () =>
7
11
  jest.fn( ( { path } ) => {
@@ -317,3 +321,93 @@ describe( 'fetchLinkSuggestions', () => {
317
321
  );
318
322
  } );
319
323
  } );
324
+
325
+ describe( 'sortResults', () => {
326
+ it( 'returns empty array for empty results', () => {
327
+ expect( sortResults( [], '' ) ).toEqual( [] );
328
+ } );
329
+
330
+ it( 'orders results', () => {
331
+ const results = [
332
+ {
333
+ id: 1,
334
+ title: 'How to get from Stockholm to Helsinki by boat',
335
+ url: 'http://wordpress.local/stockholm-helsinki-boat/',
336
+ type: 'page',
337
+ kind: 'post-type',
338
+ },
339
+ {
340
+ id: 2,
341
+ title: 'A day trip from Stockholm to Swedish countryside towns',
342
+ url: 'http://wordpress.local/day-trip-stockholm/',
343
+ type: 'page',
344
+ kind: 'post-type',
345
+ },
346
+ {
347
+ id: 3,
348
+ title: 'The art of packing lightly: How to travel with just a cabin bag',
349
+ url: 'http://wordpress.local/packing-lightly/',
350
+ type: 'page',
351
+ kind: 'post-type',
352
+ },
353
+ {
354
+ id: 4,
355
+ title: 'Tips for travel with a young baby',
356
+ url: 'http://wordpress.local/young-baby-tips/',
357
+ type: 'page',
358
+ kind: 'post-type',
359
+ },
360
+ {
361
+ id: 5,
362
+ title: '', // Test that empty titles don't cause an error.
363
+ url: 'http://wordpress.local/420/',
364
+ type: 'page',
365
+ kind: 'post-type',
366
+ },
367
+ {
368
+ id: 6,
369
+ title: 'City Guides',
370
+ url: 'http://wordpress.local/city-guides/',
371
+ type: 'category',
372
+ kind: 'taxonomy',
373
+ },
374
+ {
375
+ id: 7,
376
+ title: 'Travel Tips',
377
+ url: 'http://wordpress.local/travel-tips/',
378
+ type: 'category',
379
+ kind: 'taxonomy',
380
+ },
381
+ ];
382
+ const order = sortResults( results, 'travel tips' ).map(
383
+ ( result ) => result.id
384
+ );
385
+ expect( order ).toEqual( [
386
+ 7, // exact match
387
+ 4, // contains: travel, tips
388
+ 3, // contains: travel
389
+ // same order as input:
390
+ 1,
391
+ 2,
392
+ 5,
393
+ 6,
394
+ ] );
395
+ } );
396
+ } );
397
+
398
+ describe( 'tokenize', () => {
399
+ it( 'returns empty array for empty string', () => {
400
+ expect( tokenize( '' ) ).toEqual( [] );
401
+ } );
402
+
403
+ it( 'tokenizes a string', () => {
404
+ expect( tokenize( 'Hello, world!' ) ).toEqual( [ 'hello', 'world' ] );
405
+ } );
406
+
407
+ it( 'tokenizes non latin languages', () => {
408
+ expect( tokenize( 'こんにちは、世界!' ) ).toEqual( [
409
+ 'こんにちは',
410
+ '世界',
411
+ ] );
412
+ } );
413
+ } );
@@ -110,7 +110,8 @@ export function items( state = {}, action ) {
110
110
  [ context ]: {
111
111
  ...state[ context ],
112
112
  ...action.items.reduce( ( accumulator, value ) => {
113
- const itemId = value[ key ];
113
+ const itemId = value?.[ key ];
114
+
114
115
  accumulator[ itemId ] = conservativeMapItem(
115
116
  state?.[ context ]?.[ itemId ],
116
117
  value
@@ -164,7 +165,7 @@ export function itemIsComplete( state = {}, action ) {
164
165
  [ context ]: {
165
166
  ...state[ context ],
166
167
  ...action.items.reduce( ( result, item ) => {
167
- const itemId = item[ key ];
168
+ const itemId = item?.[ key ];
168
169
 
169
170
  // Defer to completeness if already assigned. Technically the
170
171
  // data may be outdated if receiving items for a field subset.
@@ -232,7 +233,7 @@ const receiveQueries = compose( [
232
233
  return {
233
234
  itemIds: getMergedItemIds(
234
235
  state?.itemIds || [],
235
- action.items.map( ( item ) => item[ key ] ),
236
+ action.items.map( ( item ) => item?.[ key ] ).filter( Boolean ),
236
237
  page,
237
238
  perPage
238
239
  ),
package/src/reducer.js CHANGED
@@ -256,7 +256,7 @@ function entity( entityConfig ) {
256
256
  const nextState = { ...state };
257
257
 
258
258
  for ( const record of action.items ) {
259
- const recordId = record[ action.key ];
259
+ const recordId = record?.[ action.key ];
260
260
  const edits = nextState[ recordId ];
261
261
  if ( ! edits ) {
262
262
  continue;
package/src/resolvers.js CHANGED
@@ -278,7 +278,7 @@ export const getEntityRecords =
278
278
  if ( ! query?._fields && ! query.context ) {
279
279
  const key = entityConfig.key || DEFAULT_ENTITY_KEY;
280
280
  const resolutionsArgs = records
281
- .filter( ( record ) => record[ key ] )
281
+ .filter( ( record ) => record?.[ key ] )
282
282
  .map( ( record ) => [ kind, name, record[ key ] ] );
283
283
 
284
284
  dispatch( {