@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.
- package/CHANGELOG.md +2 -0
- package/build/entity-types/helpers.js.map +1 -1
- package/build/entity-types/plugin.js.map +1 -1
- package/build/entity-types/theme.js.map +1 -1
- package/build/fetch/__experimental-fetch-link-suggestions.js +89 -118
- package/build/fetch/__experimental-fetch-link-suggestions.js.map +1 -1
- package/build/queried-data/reducer.js +3 -3
- package/build/queried-data/reducer.js.map +1 -1
- package/build/reducer.js +1 -1
- package/build/reducer.js.map +1 -1
- package/build/resolvers.js +1 -1
- package/build/resolvers.js.map +1 -1
- package/build-module/entity-types/helpers.js.map +1 -1
- package/build-module/entity-types/plugin.js.map +1 -1
- package/build-module/entity-types/theme.js.map +1 -1
- package/build-module/fetch/__experimental-fetch-link-suggestions.js +86 -118
- package/build-module/fetch/__experimental-fetch-link-suggestions.js.map +1 -1
- package/build-module/queried-data/reducer.js +3 -3
- package/build-module/queried-data/reducer.js.map +1 -1
- package/build-module/reducer.js +1 -1
- package/build-module/reducer.js.map +1 -1
- package/build-module/resolvers.js +1 -1
- package/build-module/resolvers.js.map +1 -1
- package/build-types/entity-types/helpers.d.ts +1 -1
- package/build-types/entity-types/plugin.d.ts +1 -1
- package/build-types/entity-types/plugin.d.ts.map +1 -1
- package/build-types/entity-types/theme.d.ts +9 -0
- package/build-types/entity-types/theme.d.ts.map +1 -1
- package/build-types/fetch/__experimental-fetch-link-suggestions.d.ts +48 -84
- package/build-types/fetch/__experimental-fetch-link-suggestions.d.ts.map +1 -1
- package/build-types/queried-data/reducer.d.ts.map +1 -1
- package/package.json +17 -17
- package/src/entity-types/helpers.ts +1 -1
- package/src/entity-types/plugin.ts +1 -1
- package/src/entity-types/theme.ts +10 -0
- package/src/fetch/__experimental-fetch-link-suggestions.ts +296 -0
- package/src/fetch/test/__experimental-fetch-link-suggestions.js +95 -1
- package/src/queried-data/reducer.js +4 -3
- package/src/reducer.js +1 -1
- package/src/resolvers.js +1 -1
- package/tsconfig.tsbuildinfo +1 -1
- 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
|
|
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( {
|