newspack-components 4.2.0 → 4.3.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/dist/cjs/action-card/index.js +21 -12
- package/dist/cjs/action-card/style.scss +25 -2
- package/dist/cjs/autocomplete-tokenfield/index.js +2 -2
- package/dist/cjs/autocomplete-with-latest-posts/index.js +289 -0
- package/dist/cjs/autocomplete-with-latest-posts/style.scss +150 -0
- package/dist/cjs/badge/index.js +32 -0
- package/dist/cjs/badge/style.scss +36 -0
- package/dist/cjs/button/index.js +2 -0
- package/dist/cjs/card/core-card.js +141 -0
- package/dist/cjs/card/index.js +33 -0
- package/dist/cjs/card/style-core.scss +227 -0
- package/dist/cjs/card/style.scss +2 -2
- package/dist/cjs/card-settings-group/index.js +49 -0
- package/dist/cjs/card-settings-group/style.scss +33 -0
- package/dist/cjs/card-sortable-list/index.js +390 -0
- package/dist/cjs/card-sortable-list/style.scss +48 -0
- package/dist/cjs/confirm-dialog/index.js +59 -0
- package/dist/cjs/divider/index.js +42 -0
- package/dist/cjs/divider/style.scss +51 -0
- package/dist/cjs/form-token-field/style.scss +0 -5
- package/dist/cjs/grid/index.js +2 -1
- package/dist/cjs/grid/style.scss +63 -9
- package/dist/cjs/index.js +42 -0
- package/dist/cjs/modal/index.js +11 -4
- package/dist/cjs/modal/style.scss +43 -13
- package/dist/cjs/newspack-icon/style.scss +5 -3
- package/dist/cjs/section-header/index.js +76 -17
- package/dist/cjs/section-header/style.scss +109 -4
- package/dist/cjs/style.scss +2 -0
- package/dist/cjs/waiting/index.js +3 -1
- package/dist/cjs/waiting/style.scss +4 -0
- package/dist/cjs/web-preview/index.js +98 -39
- package/dist/cjs/web-preview/style.scss +20 -53
- package/dist/cjs/with-wizard/index.js +1 -1
- package/dist/cjs/with-wizard/style.scss +24 -6
- package/dist/cjs/with-wizard-screen/style.scss +39 -10
- package/dist/cjs/wizard/components/WizardSnackbar.js +70 -0
- package/dist/cjs/wizard/components/style.scss +48 -0
- package/dist/cjs/wizard/index.js +97 -16
- package/dist/cjs/wizard/store/index.js +46 -0
- package/dist/esm/action-card/index.js +21 -12
- package/dist/esm/action-card/style.scss +25 -2
- package/dist/esm/autocomplete-tokenfield/index.js +2 -2
- package/dist/esm/autocomplete-with-latest-posts/index.js +281 -0
- package/dist/esm/autocomplete-with-latest-posts/style.scss +150 -0
- package/dist/esm/badge/index.js +24 -0
- package/dist/esm/badge/style.scss +36 -0
- package/dist/esm/button/index.js +2 -0
- package/dist/esm/card/core-card.js +133 -0
- package/dist/esm/card/index.js +33 -0
- package/dist/esm/card/style-core.scss +227 -0
- package/dist/esm/card/style.scss +2 -2
- package/dist/esm/card-settings-group/index.js +42 -0
- package/dist/esm/card-settings-group/style.scss +33 -0
- package/dist/esm/card-sortable-list/index.js +381 -0
- package/dist/esm/card-sortable-list/style.scss +48 -0
- package/dist/esm/confirm-dialog/index.js +49 -0
- package/dist/esm/divider/index.js +34 -0
- package/dist/esm/divider/style.scss +51 -0
- package/dist/esm/form-token-field/style.scss +0 -5
- package/dist/esm/grid/index.js +2 -1
- package/dist/esm/grid/style.scss +63 -9
- package/dist/esm/index.js +6 -0
- package/dist/esm/modal/index.js +11 -4
- package/dist/esm/modal/style.scss +43 -13
- package/dist/esm/newspack-icon/style.scss +5 -3
- package/dist/esm/section-header/index.js +75 -18
- package/dist/esm/section-header/style.scss +109 -4
- package/dist/esm/style.scss +2 -0
- package/dist/esm/waiting/index.js +3 -1
- package/dist/esm/waiting/style.scss +4 -0
- package/dist/esm/web-preview/index.js +101 -42
- package/dist/esm/web-preview/style.scss +20 -53
- package/dist/esm/with-wizard/index.js +1 -1
- package/dist/esm/with-wizard/style.scss +24 -6
- package/dist/esm/with-wizard-screen/style.scss +39 -10
- package/dist/esm/wizard/components/WizardSnackbar.js +64 -0
- package/dist/esm/wizard/components/style.scss +48 -0
- package/dist/esm/wizard/index.js +101 -20
- package/dist/esm/wizard/store/index.js +46 -0
- package/package.json +5 -6
- package/shared/author-context.js +26 -0
- package/shared/hooks/use-coauthors.js +220 -0
- package/shared/hooks/use-coauthors.test.js +624 -0
- package/shared/hooks/use-custom-byline.js +47 -0
- package/shared/hooks/use-custom-byline.test.js +133 -0
- package/shared/scss/_mixins.scss +46 -0
- package/src/action-card/action-card.d.ts +7 -3
- package/src/action-card/index.js +24 -11
- package/src/action-card/style.scss +25 -2
- package/src/autocomplete-tokenfield/index.js +2 -2
- package/src/autocomplete-with-latest-posts/index.js +290 -0
- package/src/autocomplete-with-latest-posts/style.scss +150 -0
- package/src/badge/index.tsx +24 -0
- package/src/badge/style.scss +36 -0
- package/src/button/index.tsx +3 -2
- package/src/card/core-card.js +147 -0
- package/src/card/index.js +39 -1
- package/src/card/style-core.scss +227 -0
- package/src/card/style.scss +2 -2
- package/src/card-settings-group/README.md +43 -0
- package/src/card-settings-group/index.tsx +53 -0
- package/src/card-settings-group/style.scss +33 -0
- package/src/card-sortable-list/README.md +51 -0
- package/src/card-sortable-list/index.tsx +412 -0
- package/src/card-sortable-list/style.scss +48 -0
- package/src/confirm-dialog/index.tsx +64 -0
- package/src/divider/README.md +25 -0
- package/src/divider/index.js +31 -0
- package/src/divider/style.scss +51 -0
- package/src/form-token-field/style.scss +0 -5
- package/src/grid/index.tsx +2 -1
- package/src/grid/style.scss +63 -9
- package/src/index.js +6 -0
- package/src/modal/index.js +12 -3
- package/src/modal/style.scss +43 -13
- package/src/newspack-icon/style.scss +5 -3
- package/src/section-header/index.js +72 -13
- package/src/section-header/style.scss +109 -4
- package/src/style.scss +2 -0
- package/src/waiting/index.js +2 -1
- package/src/waiting/style.scss +4 -0
- package/src/web-preview/index.js +90 -35
- package/src/web-preview/style.scss +20 -53
- package/src/with-wizard/index.js +1 -1
- package/src/with-wizard/style.scss +24 -6
- package/src/with-wizard-screen/style.scss +39 -10
- package/src/wizard/components/WizardSnackbar.js +58 -0
- package/src/wizard/components/style.scss +48 -0
- package/src/wizard/index.js +108 -14
- package/src/wizard/store/index.js +26 -0
|
@@ -0,0 +1,624 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* External dependencies
|
|
3
|
+
*/
|
|
4
|
+
import { renderHook, act, waitFor } from '@testing-library/react';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* WordPress dependencies
|
|
8
|
+
*/
|
|
9
|
+
import { useSelect } from '@wordpress/data';
|
|
10
|
+
import apiFetch from '@wordpress/api-fetch';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Internal dependencies
|
|
14
|
+
*/
|
|
15
|
+
import { useCoAuthors, resetGuestAvatarCacheForTests } from './use-coauthors';
|
|
16
|
+
|
|
17
|
+
jest.mock( '@wordpress/data', () => ( {
|
|
18
|
+
useSelect: jest.fn(),
|
|
19
|
+
} ) );
|
|
20
|
+
|
|
21
|
+
jest.mock( '@wordpress/core-data', () => ( {
|
|
22
|
+
store: 'core',
|
|
23
|
+
} ) );
|
|
24
|
+
|
|
25
|
+
jest.mock( '@wordpress/api-fetch', () => jest.fn() );
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Helper to create a mock select function for multiple stores.
|
|
29
|
+
*
|
|
30
|
+
* @param {Object} options Mock options.
|
|
31
|
+
* @param {Object} options.capStore CAP store mock (null if unavailable).
|
|
32
|
+
* @param {number} options.currentPostId Currently-edited post ID.
|
|
33
|
+
* @param {Object} options.entityRecords Map of postId -> post entity record.
|
|
34
|
+
* @return {Function} Mock select function.
|
|
35
|
+
*/
|
|
36
|
+
const createMockSelect = ( { capStore = null, currentPostId = 123, entityRecords = {} } = {} ) => {
|
|
37
|
+
return storeName => {
|
|
38
|
+
if ( storeName === 'cap/authors' ) {
|
|
39
|
+
return capStore;
|
|
40
|
+
}
|
|
41
|
+
if ( storeName === 'core/editor' ) {
|
|
42
|
+
return {
|
|
43
|
+
getCurrentPostId: () => currentPostId,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
if ( storeName === 'core' ) {
|
|
47
|
+
return {
|
|
48
|
+
getEntityRecord: ( kind, type, id ) => entityRecords[ id ] || null,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
};
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
describe( 'useCoAuthors', () => {
|
|
56
|
+
beforeEach( () => {
|
|
57
|
+
jest.clearAllMocks();
|
|
58
|
+
resetGuestAvatarCacheForTests();
|
|
59
|
+
// Default: apiFetch resolves with empty object (no avatar_urls).
|
|
60
|
+
apiFetch.mockResolvedValue( {} );
|
|
61
|
+
} );
|
|
62
|
+
|
|
63
|
+
describe( 'CAP store availability', () => {
|
|
64
|
+
it( 'should return isCapAvailable false when CAP store is not available', () => {
|
|
65
|
+
useSelect.mockImplementation( callback => callback( createMockSelect( { capStore: null } ) ) );
|
|
66
|
+
|
|
67
|
+
const { result } = renderHook( () => useCoAuthors( 123 ) );
|
|
68
|
+
|
|
69
|
+
expect( result.current.authors ).toEqual( [] );
|
|
70
|
+
expect( result.current.isCapAvailable ).toBe( false );
|
|
71
|
+
} );
|
|
72
|
+
|
|
73
|
+
it( 'should return isCapAvailable false when getAuthors is not a function', () => {
|
|
74
|
+
useSelect.mockImplementation( callback => callback( createMockSelect( { capStore: {} } ) ) );
|
|
75
|
+
|
|
76
|
+
const { result } = renderHook( () => useCoAuthors( 123 ) );
|
|
77
|
+
|
|
78
|
+
expect( result.current.authors ).toEqual( [] );
|
|
79
|
+
expect( result.current.isCapAvailable ).toBe( false );
|
|
80
|
+
} );
|
|
81
|
+
} );
|
|
82
|
+
|
|
83
|
+
describe( 'currently-edited post (uses CAP store)', () => {
|
|
84
|
+
it( 'should return empty authors when CAP is available but no authors assigned', () => {
|
|
85
|
+
useSelect.mockImplementation( callback =>
|
|
86
|
+
callback(
|
|
87
|
+
createMockSelect( {
|
|
88
|
+
capStore: { getAuthors: () => [] },
|
|
89
|
+
currentPostId: 123,
|
|
90
|
+
} )
|
|
91
|
+
)
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
const { result } = renderHook( () => useCoAuthors( 123 ) );
|
|
95
|
+
|
|
96
|
+
expect( result.current.authors ).toEqual( [] );
|
|
97
|
+
expect( result.current.isCapAvailable ).toBe( true );
|
|
98
|
+
} );
|
|
99
|
+
|
|
100
|
+
it( 'should return empty authors when postId is falsy', () => {
|
|
101
|
+
const getAuthorsMock = jest.fn( () => [] );
|
|
102
|
+
useSelect.mockImplementation( callback =>
|
|
103
|
+
callback(
|
|
104
|
+
createMockSelect( {
|
|
105
|
+
capStore: { getAuthors: getAuthorsMock },
|
|
106
|
+
currentPostId: 123,
|
|
107
|
+
} )
|
|
108
|
+
)
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
const { result } = renderHook( () => useCoAuthors( null ) );
|
|
112
|
+
|
|
113
|
+
expect( result.current.authors ).toEqual( [] );
|
|
114
|
+
expect( result.current.isCapAvailable ).toBe( true );
|
|
115
|
+
} );
|
|
116
|
+
|
|
117
|
+
it( 'should map CAP authors to expected format', () => {
|
|
118
|
+
const capAuthors = [
|
|
119
|
+
{ id: 1, display: 'Jane Doe', value: 'jane-doe', label: 'Jane' },
|
|
120
|
+
{ id: 2, display: 'John Smith', value: 'john-smith', label: 'John' },
|
|
121
|
+
];
|
|
122
|
+
|
|
123
|
+
useSelect.mockImplementation( callback =>
|
|
124
|
+
callback(
|
|
125
|
+
createMockSelect( {
|
|
126
|
+
capStore: { getAuthors: () => capAuthors },
|
|
127
|
+
currentPostId: 123,
|
|
128
|
+
} )
|
|
129
|
+
)
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
const { result } = renderHook( () => useCoAuthors( 123 ) );
|
|
133
|
+
|
|
134
|
+
expect( result.current.authors ).toEqual( [
|
|
135
|
+
{ id: 1, display_name: 'Jane Doe', user_nicename: 'jane-doe', isGuest: false },
|
|
136
|
+
{ id: 2, display_name: 'John Smith', user_nicename: 'john-smith', isGuest: false },
|
|
137
|
+
] );
|
|
138
|
+
expect( result.current.isCapAvailable ).toBe( true );
|
|
139
|
+
} );
|
|
140
|
+
|
|
141
|
+
it( 'should set isGuest to true for guest-author userType', () => {
|
|
142
|
+
const capAuthors = [
|
|
143
|
+
{ id: 1, display: 'Jane Doe', value: 'jane-doe', userType: 'wpuser' },
|
|
144
|
+
{ id: 1591, display: 'Guest Writer', value: 'guest-writer', userType: 'guest-author' },
|
|
145
|
+
];
|
|
146
|
+
|
|
147
|
+
useSelect.mockImplementation( callback =>
|
|
148
|
+
callback(
|
|
149
|
+
createMockSelect( {
|
|
150
|
+
capStore: { getAuthors: () => capAuthors },
|
|
151
|
+
currentPostId: 123,
|
|
152
|
+
} )
|
|
153
|
+
)
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
const { result } = renderHook( () => useCoAuthors( 123 ) );
|
|
157
|
+
|
|
158
|
+
expect( result.current.authors[ 0 ].isGuest ).toBe( false );
|
|
159
|
+
expect( result.current.authors[ 1 ].isGuest ).toBe( true );
|
|
160
|
+
} );
|
|
161
|
+
|
|
162
|
+
it( 'should fallback to value then label for display_name', () => {
|
|
163
|
+
const capAuthors = [
|
|
164
|
+
{ id: 1, value: 'from-value', label: 'From Label' }, // no display
|
|
165
|
+
{ id: 2, label: 'Only Label' }, // no display or value
|
|
166
|
+
];
|
|
167
|
+
|
|
168
|
+
useSelect.mockImplementation( callback =>
|
|
169
|
+
callback(
|
|
170
|
+
createMockSelect( {
|
|
171
|
+
capStore: { getAuthors: () => capAuthors },
|
|
172
|
+
currentPostId: 123,
|
|
173
|
+
} )
|
|
174
|
+
)
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
const { result } = renderHook( () => useCoAuthors( 123 ) );
|
|
178
|
+
|
|
179
|
+
expect( result.current.authors[ 0 ].display_name ).toBe( 'from-value' );
|
|
180
|
+
expect( result.current.authors[ 1 ].display_name ).toBe( 'Only Label' );
|
|
181
|
+
} );
|
|
182
|
+
} );
|
|
183
|
+
|
|
184
|
+
describe( 'Query Loop context (uses REST API)', () => {
|
|
185
|
+
it( 'should use REST API when postId differs from currently-edited post', () => {
|
|
186
|
+
const restAuthors = [
|
|
187
|
+
{ id: 1, display_name: 'Jane Doe', author_link: '/author/jane/' },
|
|
188
|
+
{ id: 2, display_name: 'John Smith', author_link: '/author/john/' },
|
|
189
|
+
];
|
|
190
|
+
|
|
191
|
+
useSelect.mockImplementation( callback =>
|
|
192
|
+
callback(
|
|
193
|
+
createMockSelect( {
|
|
194
|
+
capStore: { getAuthors: () => [] },
|
|
195
|
+
currentPostId: 123, // Editing post 123
|
|
196
|
+
entityRecords: {
|
|
197
|
+
456: { newspack_author_info: restAuthors }, // Query Loop post 456
|
|
198
|
+
},
|
|
199
|
+
} )
|
|
200
|
+
)
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
// Request authors for post 456 (different from current 123)
|
|
204
|
+
const { result } = renderHook( () => useCoAuthors( 456, 'post' ) );
|
|
205
|
+
|
|
206
|
+
expect( result.current.authors ).toEqual( [
|
|
207
|
+
{ id: 1, display_name: 'Jane Doe', author_link: '/author/jane/', user_nicename: 'jane' },
|
|
208
|
+
{ id: 2, display_name: 'John Smith', author_link: '/author/john/', user_nicename: 'john' },
|
|
209
|
+
] );
|
|
210
|
+
expect( result.current.isCapAvailable ).toBe( true );
|
|
211
|
+
} );
|
|
212
|
+
|
|
213
|
+
it( 'should return empty authors when REST API has no author info', () => {
|
|
214
|
+
useSelect.mockImplementation( callback =>
|
|
215
|
+
callback(
|
|
216
|
+
createMockSelect( {
|
|
217
|
+
capStore: { getAuthors: () => [] },
|
|
218
|
+
currentPostId: 123,
|
|
219
|
+
entityRecords: {
|
|
220
|
+
456: { newspack_author_info: null },
|
|
221
|
+
},
|
|
222
|
+
} )
|
|
223
|
+
)
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
const { result } = renderHook( () => useCoAuthors( 456, 'post' ) );
|
|
227
|
+
|
|
228
|
+
expect( result.current.authors ).toEqual( [] );
|
|
229
|
+
expect( result.current.isCapAvailable ).toBe( true );
|
|
230
|
+
} );
|
|
231
|
+
|
|
232
|
+
it( 'should extract user_nicename from author_link', () => {
|
|
233
|
+
const restAuthors = [
|
|
234
|
+
{ id: 1591, display_name: 'External Contributor', author_link: 'https://example.com/author/external-contributor/' },
|
|
235
|
+
];
|
|
236
|
+
|
|
237
|
+
useSelect.mockImplementation( callback =>
|
|
238
|
+
callback(
|
|
239
|
+
createMockSelect( {
|
|
240
|
+
capStore: { getAuthors: () => [] },
|
|
241
|
+
currentPostId: 123,
|
|
242
|
+
entityRecords: {
|
|
243
|
+
456: { newspack_author_info: restAuthors },
|
|
244
|
+
},
|
|
245
|
+
} )
|
|
246
|
+
)
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
const { result } = renderHook( () => useCoAuthors( 456, 'post' ) );
|
|
250
|
+
|
|
251
|
+
expect( result.current.authors[ 0 ].user_nicename ).toBe( 'external-contributor' );
|
|
252
|
+
} );
|
|
253
|
+
|
|
254
|
+
it( 'should strip query params and hash fragments from author_link', () => {
|
|
255
|
+
const restAuthors = [
|
|
256
|
+
{ id: 1, display_name: 'Jane', author_link: '/author/jane/?utm_source=feed' },
|
|
257
|
+
{ id: 2, display_name: 'John', author_link: '/author/john/#bio' },
|
|
258
|
+
{ id: 3, display_name: 'Jill', author_link: '/author/jill/?x=1#top' },
|
|
259
|
+
];
|
|
260
|
+
|
|
261
|
+
useSelect.mockImplementation( callback =>
|
|
262
|
+
callback(
|
|
263
|
+
createMockSelect( {
|
|
264
|
+
capStore: { getAuthors: () => [] },
|
|
265
|
+
currentPostId: 123,
|
|
266
|
+
entityRecords: {
|
|
267
|
+
456: { newspack_author_info: restAuthors },
|
|
268
|
+
},
|
|
269
|
+
} )
|
|
270
|
+
)
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
const { result } = renderHook( () => useCoAuthors( 456, 'post' ) );
|
|
274
|
+
|
|
275
|
+
expect( result.current.authors[ 0 ].user_nicename ).toBe( 'jane' );
|
|
276
|
+
expect( result.current.authors[ 1 ].user_nicename ).toBe( 'john' );
|
|
277
|
+
expect( result.current.authors[ 2 ].user_nicename ).toBe( 'jill' );
|
|
278
|
+
} );
|
|
279
|
+
|
|
280
|
+
it( 'should return undefined user_nicename when author_link is missing or unusable', () => {
|
|
281
|
+
const restAuthors = [
|
|
282
|
+
{ id: 1, display_name: 'No Link Author', author_link: null },
|
|
283
|
+
{ id: 2, display_name: 'Empty Link Author', author_link: '' },
|
|
284
|
+
{ id: 3, display_name: 'Root URL Author', author_link: 'https://example.com' },
|
|
285
|
+
{ id: 4, display_name: 'Plain Permalink Author', author_link: '/?author=123' },
|
|
286
|
+
];
|
|
287
|
+
|
|
288
|
+
useSelect.mockImplementation( callback =>
|
|
289
|
+
callback(
|
|
290
|
+
createMockSelect( {
|
|
291
|
+
capStore: { getAuthors: () => [] },
|
|
292
|
+
currentPostId: 123,
|
|
293
|
+
entityRecords: {
|
|
294
|
+
456: { newspack_author_info: restAuthors },
|
|
295
|
+
},
|
|
296
|
+
} )
|
|
297
|
+
)
|
|
298
|
+
);
|
|
299
|
+
|
|
300
|
+
const { result } = renderHook( () => useCoAuthors( 456, 'post' ) );
|
|
301
|
+
|
|
302
|
+
expect( result.current.authors[ 0 ].user_nicename ).toBeUndefined();
|
|
303
|
+
expect( result.current.authors[ 1 ].user_nicename ).toBeUndefined();
|
|
304
|
+
expect( result.current.authors[ 2 ].user_nicename ).toBeUndefined();
|
|
305
|
+
expect( result.current.authors[ 3 ].user_nicename ).toBeUndefined();
|
|
306
|
+
} );
|
|
307
|
+
|
|
308
|
+
it( 'should fetch avatars for Query Loop authors via CAP endpoint when not enriched', async () => {
|
|
309
|
+
const avatarUrls = { 96: 'https://example.com/guest-96.jpg' };
|
|
310
|
+
apiFetch.mockResolvedValue( { avatar_urls: avatarUrls } );
|
|
311
|
+
|
|
312
|
+
const restAuthors = [
|
|
313
|
+
{ id: 1591, display_name: 'External Contributor', author_link: 'https://example.com/author/external-contributor/' },
|
|
314
|
+
];
|
|
315
|
+
|
|
316
|
+
useSelect.mockImplementation( callback =>
|
|
317
|
+
callback(
|
|
318
|
+
createMockSelect( {
|
|
319
|
+
capStore: { getAuthors: () => [] },
|
|
320
|
+
currentPostId: 123,
|
|
321
|
+
entityRecords: {
|
|
322
|
+
456: { newspack_author_info: restAuthors },
|
|
323
|
+
},
|
|
324
|
+
} )
|
|
325
|
+
)
|
|
326
|
+
);
|
|
327
|
+
|
|
328
|
+
const { result } = renderHook( () => useCoAuthors( 456, 'post' ) );
|
|
329
|
+
|
|
330
|
+
await waitFor( () => {
|
|
331
|
+
expect( result.current.authors[ 0 ].avatar_urls ).toEqual( avatarUrls );
|
|
332
|
+
} );
|
|
333
|
+
|
|
334
|
+
expect( apiFetch ).toHaveBeenCalledWith( {
|
|
335
|
+
path: '/coauthors/v1/coauthors/external-contributor',
|
|
336
|
+
} );
|
|
337
|
+
} );
|
|
338
|
+
|
|
339
|
+
it( 'should use enriched user_nicename directly when available', () => {
|
|
340
|
+
const restAuthors = [ { id: 1, display_name: 'Jane Doe', author_link: '/author/jane/', user_nicename: 'jane-doe-enriched' } ];
|
|
341
|
+
|
|
342
|
+
useSelect.mockImplementation( callback =>
|
|
343
|
+
callback(
|
|
344
|
+
createMockSelect( {
|
|
345
|
+
capStore: { getAuthors: () => [] },
|
|
346
|
+
currentPostId: 123,
|
|
347
|
+
entityRecords: {
|
|
348
|
+
456: { newspack_author_info: restAuthors },
|
|
349
|
+
},
|
|
350
|
+
} )
|
|
351
|
+
)
|
|
352
|
+
);
|
|
353
|
+
|
|
354
|
+
const { result } = renderHook( () => useCoAuthors( 456, 'post' ) );
|
|
355
|
+
|
|
356
|
+
expect( result.current.authors[ 0 ].user_nicename ).toBe( 'jane-doe-enriched' );
|
|
357
|
+
} );
|
|
358
|
+
|
|
359
|
+
it( 'should map is_guest from enriched REST data', () => {
|
|
360
|
+
const restAuthors = [
|
|
361
|
+
{ id: 1, display_name: 'WP User', author_link: '/author/wp-user/', user_nicename: 'wp-user', is_guest: false },
|
|
362
|
+
{ id: 2, display_name: 'Guest', author_link: '/author/guest/', user_nicename: 'guest', is_guest: true },
|
|
363
|
+
];
|
|
364
|
+
|
|
365
|
+
useSelect.mockImplementation( callback =>
|
|
366
|
+
callback(
|
|
367
|
+
createMockSelect( {
|
|
368
|
+
capStore: { getAuthors: () => [] },
|
|
369
|
+
currentPostId: 123,
|
|
370
|
+
entityRecords: {
|
|
371
|
+
456: { newspack_author_info: restAuthors },
|
|
372
|
+
},
|
|
373
|
+
} )
|
|
374
|
+
)
|
|
375
|
+
);
|
|
376
|
+
|
|
377
|
+
const { result } = renderHook( () => useCoAuthors( 456, 'post' ) );
|
|
378
|
+
|
|
379
|
+
expect( result.current.authors[ 0 ].isGuest ).toBe( false );
|
|
380
|
+
expect( result.current.authors[ 1 ].isGuest ).toBe( true );
|
|
381
|
+
} );
|
|
382
|
+
|
|
383
|
+
it( 'should pass through avatar_urls from enriched REST data', () => {
|
|
384
|
+
const avatarUrls = { 96: 'https://example.com/avatar-96.jpg' };
|
|
385
|
+
const restAuthors = [
|
|
386
|
+
{ id: 1, display_name: 'Jane', author_link: '/author/jane/', user_nicename: 'jane', is_guest: false, avatar_urls: avatarUrls },
|
|
387
|
+
];
|
|
388
|
+
|
|
389
|
+
useSelect.mockImplementation( callback =>
|
|
390
|
+
callback(
|
|
391
|
+
createMockSelect( {
|
|
392
|
+
capStore: { getAuthors: () => [] },
|
|
393
|
+
currentPostId: 123,
|
|
394
|
+
entityRecords: {
|
|
395
|
+
456: { newspack_author_info: restAuthors },
|
|
396
|
+
},
|
|
397
|
+
} )
|
|
398
|
+
)
|
|
399
|
+
);
|
|
400
|
+
|
|
401
|
+
const { result } = renderHook( () => useCoAuthors( 456, 'post' ) );
|
|
402
|
+
|
|
403
|
+
expect( result.current.authors[ 0 ].avatar_urls ).toEqual( avatarUrls );
|
|
404
|
+
} );
|
|
405
|
+
|
|
406
|
+
it( 'should skip avatar fetch for enriched authors with is_guest false', async () => {
|
|
407
|
+
const restAuthors = [ { id: 1, display_name: 'WP User', author_link: '/author/wp-user/', user_nicename: 'wp-user', is_guest: false } ];
|
|
408
|
+
|
|
409
|
+
useSelect.mockImplementation( callback =>
|
|
410
|
+
callback(
|
|
411
|
+
createMockSelect( {
|
|
412
|
+
capStore: { getAuthors: () => [] },
|
|
413
|
+
currentPostId: 123,
|
|
414
|
+
entityRecords: {
|
|
415
|
+
456: { newspack_author_info: restAuthors },
|
|
416
|
+
},
|
|
417
|
+
} )
|
|
418
|
+
)
|
|
419
|
+
);
|
|
420
|
+
|
|
421
|
+
renderHook( () => useCoAuthors( 456, 'post' ) );
|
|
422
|
+
|
|
423
|
+
await act( () => Promise.resolve() );
|
|
424
|
+
|
|
425
|
+
expect( apiFetch ).not.toHaveBeenCalled();
|
|
426
|
+
} );
|
|
427
|
+
|
|
428
|
+
it( 'should skip avatar fetch when avatar_urls are already in REST data', async () => {
|
|
429
|
+
const avatarUrls = { 96: 'https://example.com/guest-96.jpg' };
|
|
430
|
+
const restAuthors = [
|
|
431
|
+
{
|
|
432
|
+
id: 1591,
|
|
433
|
+
display_name: 'Guest Author',
|
|
434
|
+
author_link: '/author/guest/',
|
|
435
|
+
user_nicename: 'guest',
|
|
436
|
+
is_guest: true,
|
|
437
|
+
avatar_urls: avatarUrls,
|
|
438
|
+
},
|
|
439
|
+
];
|
|
440
|
+
|
|
441
|
+
useSelect.mockImplementation( callback =>
|
|
442
|
+
callback(
|
|
443
|
+
createMockSelect( {
|
|
444
|
+
capStore: { getAuthors: () => [] },
|
|
445
|
+
currentPostId: 123,
|
|
446
|
+
entityRecords: {
|
|
447
|
+
456: { newspack_author_info: restAuthors },
|
|
448
|
+
},
|
|
449
|
+
} )
|
|
450
|
+
)
|
|
451
|
+
);
|
|
452
|
+
|
|
453
|
+
renderHook( () => useCoAuthors( 456, 'post' ) );
|
|
454
|
+
|
|
455
|
+
await act( () => Promise.resolve() );
|
|
456
|
+
|
|
457
|
+
expect( apiFetch ).not.toHaveBeenCalled();
|
|
458
|
+
} );
|
|
459
|
+
|
|
460
|
+
it( 'should return empty authors when post entity is not loaded', () => {
|
|
461
|
+
useSelect.mockImplementation( callback =>
|
|
462
|
+
callback(
|
|
463
|
+
createMockSelect( {
|
|
464
|
+
capStore: { getAuthors: () => [] },
|
|
465
|
+
currentPostId: 123,
|
|
466
|
+
entityRecords: {}, // No entity records
|
|
467
|
+
} )
|
|
468
|
+
)
|
|
469
|
+
);
|
|
470
|
+
|
|
471
|
+
const { result } = renderHook( () => useCoAuthors( 456, 'post' ) );
|
|
472
|
+
|
|
473
|
+
expect( result.current.authors ).toEqual( [] );
|
|
474
|
+
expect( result.current.isCapAvailable ).toBe( true );
|
|
475
|
+
} );
|
|
476
|
+
} );
|
|
477
|
+
|
|
478
|
+
describe( 'guest author avatar fetching', () => {
|
|
479
|
+
const GUEST_AVATAR_URLS = { 24: 'https://example.com/guest-24.jpg', 96: 'https://example.com/guest-96.jpg' };
|
|
480
|
+
|
|
481
|
+
const setupWithGuest = capAuthors => {
|
|
482
|
+
useSelect.mockImplementation( callback =>
|
|
483
|
+
callback(
|
|
484
|
+
createMockSelect( {
|
|
485
|
+
capStore: { getAuthors: () => capAuthors },
|
|
486
|
+
currentPostId: 123,
|
|
487
|
+
} )
|
|
488
|
+
)
|
|
489
|
+
);
|
|
490
|
+
};
|
|
491
|
+
|
|
492
|
+
it( 'should fetch avatar by nicename for guest authors', async () => {
|
|
493
|
+
apiFetch.mockResolvedValue( { avatar_urls: GUEST_AVATAR_URLS } );
|
|
494
|
+
setupWithGuest( [
|
|
495
|
+
{ id: 1, display: 'Jane', value: 'jane-doe', userType: 'wpuser' },
|
|
496
|
+
{ id: 1591, display: 'Guest Writer', value: 'guest-writer', userType: 'guest-author' },
|
|
497
|
+
] );
|
|
498
|
+
|
|
499
|
+
const { result } = renderHook( () => useCoAuthors( 123 ) );
|
|
500
|
+
|
|
501
|
+
await waitFor( () => {
|
|
502
|
+
const guest = result.current.authors.find( a => a.id === 1591 );
|
|
503
|
+
expect( guest.avatar_urls ).toEqual( GUEST_AVATAR_URLS );
|
|
504
|
+
} );
|
|
505
|
+
|
|
506
|
+
expect( apiFetch ).toHaveBeenCalledWith( {
|
|
507
|
+
path: '/coauthors/v1/coauthors/guest-writer',
|
|
508
|
+
} );
|
|
509
|
+
} );
|
|
510
|
+
|
|
511
|
+
it( 'should not fetch avatars for WP users', async () => {
|
|
512
|
+
setupWithGuest( [
|
|
513
|
+
{ id: 1, display: 'Jane', value: 'jane-doe', userType: 'wpuser' },
|
|
514
|
+
{ id: 2, display: 'John', value: 'john-smith', userType: 'wpuser' },
|
|
515
|
+
] );
|
|
516
|
+
|
|
517
|
+
renderHook( () => useCoAuthors( 123 ) );
|
|
518
|
+
|
|
519
|
+
// Allow any pending effects to flush.
|
|
520
|
+
await act( () => Promise.resolve() );
|
|
521
|
+
|
|
522
|
+
expect( apiFetch ).not.toHaveBeenCalled();
|
|
523
|
+
} );
|
|
524
|
+
|
|
525
|
+
it( 'should not fetch avatars when skip is true', async () => {
|
|
526
|
+
apiFetch.mockResolvedValue( { avatar_urls: GUEST_AVATAR_URLS } );
|
|
527
|
+
setupWithGuest( [ { id: 1591, display: 'Guest Writer', value: 'guest-writer', userType: 'guest-author' } ] );
|
|
528
|
+
|
|
529
|
+
renderHook( () => useCoAuthors( 123, 'post', true ) );
|
|
530
|
+
|
|
531
|
+
await act( () => Promise.resolve() );
|
|
532
|
+
|
|
533
|
+
expect( apiFetch ).not.toHaveBeenCalled();
|
|
534
|
+
} );
|
|
535
|
+
|
|
536
|
+
it( 'should not modify WP user authors when merging avatar data', async () => {
|
|
537
|
+
apiFetch.mockResolvedValue( { avatar_urls: GUEST_AVATAR_URLS } );
|
|
538
|
+
setupWithGuest( [
|
|
539
|
+
{ id: 1, display: 'Jane', value: 'jane-doe', userType: 'wpuser' },
|
|
540
|
+
{ id: 1591, display: 'Guest Writer', value: 'guest-writer', userType: 'guest-author' },
|
|
541
|
+
] );
|
|
542
|
+
|
|
543
|
+
const { result } = renderHook( () => useCoAuthors( 123 ) );
|
|
544
|
+
|
|
545
|
+
await waitFor( () => {
|
|
546
|
+
expect( result.current.authors.find( a => a.id === 1591 ).avatar_urls ).toEqual( GUEST_AVATAR_URLS );
|
|
547
|
+
} );
|
|
548
|
+
|
|
549
|
+
// WP user should not have avatar_urls added by the hook.
|
|
550
|
+
const wpUser = result.current.authors.find( a => a.id === 1 );
|
|
551
|
+
expect( wpUser.avatar_urls ).toBeUndefined();
|
|
552
|
+
} );
|
|
553
|
+
|
|
554
|
+
it( 'should not fetch avatar for guest author without user_nicename', async () => {
|
|
555
|
+
setupWithGuest( [ { id: 1591, display: 'Guest Writer', value: undefined, userType: 'guest-author' } ] );
|
|
556
|
+
|
|
557
|
+
renderHook( () => useCoAuthors( 123 ) );
|
|
558
|
+
|
|
559
|
+
await act( () => Promise.resolve() );
|
|
560
|
+
|
|
561
|
+
expect( apiFetch ).not.toHaveBeenCalled();
|
|
562
|
+
} );
|
|
563
|
+
|
|
564
|
+
it( 'should encode nicename in the REST path', async () => {
|
|
565
|
+
apiFetch.mockResolvedValue( { avatar_urls: GUEST_AVATAR_URLS } );
|
|
566
|
+
setupWithGuest( [ { id: 1591, display: 'Guest Writer', value: 'name with spaces', userType: 'guest-author' } ] );
|
|
567
|
+
|
|
568
|
+
renderHook( () => useCoAuthors( 123 ) );
|
|
569
|
+
|
|
570
|
+
await waitFor( () => {
|
|
571
|
+
expect( apiFetch ).toHaveBeenCalledWith( {
|
|
572
|
+
path: '/coauthors/v1/coauthors/name%20with%20spaces',
|
|
573
|
+
} );
|
|
574
|
+
} );
|
|
575
|
+
} );
|
|
576
|
+
|
|
577
|
+
it( 'should handle failed fetches gracefully', async () => {
|
|
578
|
+
apiFetch.mockRejectedValue( new Error( 'Not found' ) );
|
|
579
|
+
setupWithGuest( [ { id: 1591, display: 'Guest Writer', value: 'ghost-author', userType: 'guest-author' } ] );
|
|
580
|
+
|
|
581
|
+
const { result } = renderHook( () => useCoAuthors( 123 ) );
|
|
582
|
+
|
|
583
|
+
// Allow the failed promise to settle.
|
|
584
|
+
await act( () => Promise.resolve().then( () => Promise.resolve() ) );
|
|
585
|
+
|
|
586
|
+
// Guest author should still be in the list, just without avatar_urls.
|
|
587
|
+
expect( result.current.authors ).toHaveLength( 1 );
|
|
588
|
+
expect( result.current.authors[ 0 ].display_name ).toBe( 'Guest Writer' );
|
|
589
|
+
} );
|
|
590
|
+
|
|
591
|
+
it( 'should deduplicate concurrent avatar fetches for the same nicename', async () => {
|
|
592
|
+
apiFetch.mockResolvedValue( { avatar_urls: GUEST_AVATAR_URLS } );
|
|
593
|
+
|
|
594
|
+
// Two Query Loop posts sharing the same guest author.
|
|
595
|
+
const sharedAuthor = { id: 1591, display_name: 'Guest Writer', author_link: '/author/guest-writer/' };
|
|
596
|
+
|
|
597
|
+
useSelect.mockImplementation( callback =>
|
|
598
|
+
callback(
|
|
599
|
+
createMockSelect( {
|
|
600
|
+
capStore: { getAuthors: () => [] },
|
|
601
|
+
currentPostId: 123,
|
|
602
|
+
entityRecords: {
|
|
603
|
+
456: { newspack_author_info: [ sharedAuthor ] },
|
|
604
|
+
789: { newspack_author_info: [ sharedAuthor ] },
|
|
605
|
+
},
|
|
606
|
+
} )
|
|
607
|
+
)
|
|
608
|
+
);
|
|
609
|
+
|
|
610
|
+
// Render two hooks concurrently, simulating two avatar blocks in a Query Loop.
|
|
611
|
+
const { result: result1 } = renderHook( () => useCoAuthors( 456, 'post' ) );
|
|
612
|
+
const { result: result2 } = renderHook( () => useCoAuthors( 789, 'post' ) );
|
|
613
|
+
|
|
614
|
+
await waitFor( () => {
|
|
615
|
+
expect( result1.current.authors[ 0 ].avatar_urls ).toEqual( GUEST_AVATAR_URLS );
|
|
616
|
+
expect( result2.current.authors[ 0 ].avatar_urls ).toEqual( GUEST_AVATAR_URLS );
|
|
617
|
+
} );
|
|
618
|
+
|
|
619
|
+
// Only one apiFetch call should have been made despite two concurrent hooks.
|
|
620
|
+
const guestWriterCalls = apiFetch.mock.calls.filter( ( [ arg ] ) => arg.path === '/coauthors/v1/coauthors/guest-writer' );
|
|
621
|
+
expect( guestWriterCalls ).toHaveLength( 1 );
|
|
622
|
+
} );
|
|
623
|
+
} );
|
|
624
|
+
} );
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WordPress dependencies
|
|
3
|
+
*/
|
|
4
|
+
import { useSelect } from '@wordpress/data';
|
|
5
|
+
import { store as coreStore } from '@wordpress/core-data';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Hook to get custom byline data.
|
|
9
|
+
*
|
|
10
|
+
* @param {number} postId Post ID.
|
|
11
|
+
* @param {string} postType Post type.
|
|
12
|
+
* @return {Object} Custom byline data.
|
|
13
|
+
*/
|
|
14
|
+
export function useCustomByline( postId, postType ) {
|
|
15
|
+
const { bylineActive, bylineContent } = useSelect(
|
|
16
|
+
select => {
|
|
17
|
+
const { getEditedEntityRecord } = select( coreStore );
|
|
18
|
+
const postRecord = getEditedEntityRecord( 'postType', postType, postId );
|
|
19
|
+
return {
|
|
20
|
+
bylineActive: postRecord?.meta?._newspack_byline_active || false,
|
|
21
|
+
bylineContent: postRecord?.meta?._newspack_byline || '',
|
|
22
|
+
};
|
|
23
|
+
},
|
|
24
|
+
[ postId, postType ]
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
return { bylineActive, bylineContent };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Extract author IDs from custom byline shortcode content.
|
|
32
|
+
*
|
|
33
|
+
* @param {string} bylineContent Byline content with [Author id=X]...[/Author] shortcodes.
|
|
34
|
+
* @return {Array} Array of author IDs (integers).
|
|
35
|
+
*/
|
|
36
|
+
export function extractAuthorIdsFromByline( bylineContent ) {
|
|
37
|
+
if ( ! bylineContent ) {
|
|
38
|
+
return [];
|
|
39
|
+
}
|
|
40
|
+
const regex = /\[Author\s+id\s*=\s*(\d+)\]/gi;
|
|
41
|
+
const ids = [];
|
|
42
|
+
let match;
|
|
43
|
+
while ( ( match = regex.exec( bylineContent ) ) !== null ) {
|
|
44
|
+
ids.push( parseInt( match[ 1 ], 10 ) );
|
|
45
|
+
}
|
|
46
|
+
return ids;
|
|
47
|
+
}
|