@automattic/newspack-blocks 4.31.1 → 4.32.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 (97) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/composer.lock +73 -994
  3. package/dist/blocks/checkout-button/block.json +3 -0
  4. package/dist/carousel/view-rtl.css +1 -1
  5. package/dist/carousel/view.asset.php +1 -1
  6. package/dist/carousel/view.css +1 -1
  7. package/dist/editor-rtl.css +20 -16
  8. package/dist/editor.asset.php +1 -1
  9. package/dist/editor.css +20 -16
  10. package/dist/editor.js +32 -28
  11. package/dist/homepage-articles/view-rtl.css +1 -1
  12. package/dist/homepage-articles/view.asset.php +1 -1
  13. package/dist/homepage-articles/view.css +1 -1
  14. package/dist/modal.asset.php +1 -1
  15. package/dist/modal.js +1 -1
  16. package/dist/modalCheckout-rtl.css +1 -1
  17. package/dist/modalCheckout.asset.php +1 -1
  18. package/dist/modalCheckout.css +1 -1
  19. package/dist/modalCheckout.js +1 -1
  20. package/dist/tiersBased.asset.php +1 -1
  21. package/dist/tiersBased.js +1 -1
  22. package/includes/class-modal-checkout.php +354 -5
  23. package/includes/modal-checkout/class-checkout-data.php +30 -6
  24. package/languages/newspack-blocks-de_DE-37552bb09e2a9fceec1970e3c6d46557.json +1 -1
  25. package/languages/newspack-blocks-de_DE-53e2a1d5945b8d2b1c35e81ae1e532f3.json +1 -1
  26. package/languages/newspack-blocks-de_DE-5c566531829f64486c13d7d8df1d6151.json +1 -0
  27. package/languages/newspack-blocks-de_DE-7c29764d5ef91723f5ce454f66431b9e.json +1 -1
  28. package/languages/newspack-blocks-de_DE-eeb28f99adedd5fad3081d2756d6f4a4.json +1 -1
  29. package/languages/newspack-blocks-de_DE-fef17c08abbcd3d3c4cdc0fd13a9bff5.json +1 -0
  30. package/languages/newspack-blocks-de_DE.po +427 -322
  31. package/languages/newspack-blocks-es_ES-37552bb09e2a9fceec1970e3c6d46557.json +1 -1
  32. package/languages/newspack-blocks-es_ES-53e2a1d5945b8d2b1c35e81ae1e532f3.json +1 -1
  33. package/languages/newspack-blocks-es_ES-5c566531829f64486c13d7d8df1d6151.json +1 -0
  34. package/languages/newspack-blocks-es_ES-7c29764d5ef91723f5ce454f66431b9e.json +1 -1
  35. package/languages/newspack-blocks-es_ES-eeb28f99adedd5fad3081d2756d6f4a4.json +1 -1
  36. package/languages/newspack-blocks-es_ES-fef17c08abbcd3d3c4cdc0fd13a9bff5.json +1 -0
  37. package/languages/newspack-blocks-es_ES.po +425 -322
  38. package/languages/newspack-blocks-fr_BE-37552bb09e2a9fceec1970e3c6d46557.json +1 -1
  39. package/languages/newspack-blocks-fr_BE-53e2a1d5945b8d2b1c35e81ae1e532f3.json +1 -1
  40. package/languages/newspack-blocks-fr_BE-5c566531829f64486c13d7d8df1d6151.json +1 -0
  41. package/languages/newspack-blocks-fr_BE-7c29764d5ef91723f5ce454f66431b9e.json +1 -1
  42. package/languages/newspack-blocks-fr_BE-eeb28f99adedd5fad3081d2756d6f4a4.json +1 -1
  43. package/languages/newspack-blocks-fr_BE-fef17c08abbcd3d3c4cdc0fd13a9bff5.json +1 -0
  44. package/languages/newspack-blocks-fr_BE.po +427 -322
  45. package/languages/newspack-blocks-nb_NO-37552bb09e2a9fceec1970e3c6d46557.json +1 -1
  46. package/languages/newspack-blocks-nb_NO-53e2a1d5945b8d2b1c35e81ae1e532f3.json +1 -1
  47. package/languages/newspack-blocks-nb_NO-5c566531829f64486c13d7d8df1d6151.json +1 -0
  48. package/languages/newspack-blocks-nb_NO-7c29764d5ef91723f5ce454f66431b9e.json +1 -1
  49. package/languages/newspack-blocks-nb_NO-eeb28f99adedd5fad3081d2756d6f4a4.json +1 -1
  50. package/languages/newspack-blocks-nb_NO-fef17c08abbcd3d3c4cdc0fd13a9bff5.json +1 -0
  51. package/languages/newspack-blocks-nb_NO.po +426 -322
  52. package/languages/newspack-blocks-pt_PT-37552bb09e2a9fceec1970e3c6d46557.json +1 -1
  53. package/languages/newspack-blocks-pt_PT-53e2a1d5945b8d2b1c35e81ae1e532f3.json +1 -1
  54. package/languages/newspack-blocks-pt_PT-5c566531829f64486c13d7d8df1d6151.json +1 -0
  55. package/languages/newspack-blocks-pt_PT-7c29764d5ef91723f5ce454f66431b9e.json +1 -1
  56. package/languages/newspack-blocks-pt_PT-eeb28f99adedd5fad3081d2756d6f4a4.json +1 -1
  57. package/languages/newspack-blocks-pt_PT-fef17c08abbcd3d3c4cdc0fd13a9bff5.json +1 -0
  58. package/languages/newspack-blocks-pt_PT.po +426 -322
  59. package/languages/newspack-blocks.pot +506 -379
  60. package/newspack-blocks.php +2 -2
  61. package/package.json +3 -2
  62. package/src/blocks/carousel/edit.js +1 -1
  63. package/src/blocks/carousel/view.scss +5 -1
  64. package/src/blocks/checkout-button/block.json +3 -0
  65. package/src/blocks/checkout-button/edit.js +32 -5
  66. package/src/blocks/checkout-button/view.php +37 -0
  67. package/src/blocks/donate/consts.ts +2 -1
  68. package/src/blocks/donate/frequency-slugs.js +10 -0
  69. package/src/blocks/donate/tiers-based/view.ts +8 -0
  70. package/src/blocks/homepage-articles/edit.tsx +1 -1
  71. package/src/blocks/homepage-articles/editor.scss +2 -1
  72. package/src/blocks/homepage-articles/view.scss +15 -5
  73. package/src/components/autocomplete-tokenfield.js +21 -7
  74. package/src/components/autocomplete-tokenfield.scss +1 -1
  75. package/src/components/autocomplete-tokenfield.test.js +58 -0
  76. package/src/components/query-controls.js +28 -6
  77. package/src/components/query-controls.test.js +56 -0
  78. package/src/components/reorder-modal.js +382 -0
  79. package/src/components/reorder-modal.scss +87 -0
  80. package/src/components/reorder-modal.test.js +487 -0
  81. package/src/components/specific-posts-control.js +91 -0
  82. package/src/components/specific-posts-control.scss +5 -0
  83. package/src/components/specific-posts-control.test.js +173 -0
  84. package/src/modal-checkout/analytics/ga4/utils/index.js +1 -0
  85. package/src/modal-checkout/checkout-button-trigger.js +29 -2
  86. package/src/modal-checkout/checkout-button-trigger.test.js +37 -0
  87. package/src/modal-checkout/checkout.scss +28 -0
  88. package/src/modal-checkout/donate-trigger.js +134 -0
  89. package/src/modal-checkout/donate-trigger.test.js +233 -0
  90. package/src/modal-checkout/index.js +91 -0
  91. package/src/modal-checkout/modal.js +129 -45
  92. package/src/modal-checkout/utils.test.js +49 -0
  93. package/src/shared/js/tiers-based-ready.js +26 -0
  94. package/vendor/autoload.php +1 -1
  95. package/vendor/composer/autoload_real.php +4 -4
  96. package/vendor/composer/autoload_static.php +2 -2
  97. package/vendor/composer/installed.php +2 -2
@@ -0,0 +1,487 @@
1
+ /**
2
+ * External dependencies
3
+ */
4
+ import { createEvent, fireEvent, render, screen } from '@testing-library/react';
5
+
6
+ /**
7
+ * WordPress dependencies
8
+ */
9
+ import { speak } from '@wordpress/a11y';
10
+
11
+ /**
12
+ * Internal dependencies
13
+ */
14
+ import ReorderModal, { findOverlay, moveItem } from './reorder-modal';
15
+
16
+ jest.mock( '@wordpress/a11y', () => ( { speak: jest.fn() } ) );
17
+
18
+ beforeEach( () => {
19
+ speak.mockClear();
20
+ } );
21
+
22
+ describe( 'moveItem', () => {
23
+ it( 'moves an item down', () => {
24
+ expect( moveItem( [ 'a', 'b', 'c' ], 0, 2 ) ).toEqual( [ 'b', 'c', 'a' ] );
25
+ } );
26
+
27
+ it( 'moves an item up', () => {
28
+ expect( moveItem( [ 'a', 'b', 'c' ], 2, 0 ) ).toEqual( [ 'c', 'a', 'b' ] );
29
+ } );
30
+
31
+ it( 'returns the original array when the indices match', () => {
32
+ const items = [ 'a', 'b' ];
33
+ expect( moveItem( items, 1, 1 ) ).toBe( items );
34
+ } );
35
+
36
+ it( 'returns the original array when an index is out of range', () => {
37
+ const items = [ 'a', 'b' ];
38
+ expect( moveItem( items, 0, 5 ) ).toBe( items );
39
+ expect( moveItem( items, -1, 0 ) ).toBe( items );
40
+ } );
41
+ } );
42
+
43
+ const ITEMS = [
44
+ { value: 11, label: 'Alpha' },
45
+ { value: 22, label: 'Beta' },
46
+ { value: 33, label: 'Gamma' },
47
+ ];
48
+
49
+ const renderModal = ( props = {} ) => {
50
+ const onSave = jest.fn();
51
+ const onClose = jest.fn();
52
+ render(
53
+ <ReorderModal
54
+ title="Reorder Content"
55
+ ids={ [ 11, 22, 33 ] }
56
+ fetchItems={ () => Promise.resolve( ITEMS ) }
57
+ onSave={ onSave }
58
+ onClose={ onClose }
59
+ { ...props }
60
+ />
61
+ );
62
+ return { onSave, onClose };
63
+ };
64
+
65
+ const titles = () => Array.from( document.querySelectorAll( '.newspack-blocks-reorder-modal__title' ) ).map( el => el.textContent );
66
+
67
+ const overlay = () => document.querySelector( '.components-modal__screen-overlay' );
68
+
69
+ const discardPrompt = () => screen.queryByRole( 'dialog', { name: 'Discard the new order?' } );
70
+
71
+ // jsdom has no `PointerEvent`, and `fireEvent.pointerDown` would fall back to a
72
+ // plain `Event` and drop `button`.
73
+ const press = ( from, to, button = 0 ) => {
74
+ fireEvent( from, new MouseEvent( 'pointerdown', { bubbles: true, button } ) );
75
+ fireEvent( to, new MouseEvent( 'pointerup', { bubbles: true, button } ) );
76
+ };
77
+
78
+ describe( 'ReorderModal', () => {
79
+ it( 'lists the items in the order the IDs were given', async () => {
80
+ renderModal();
81
+ expect( await screen.findByText( 'Alpha' ) ).toBeInTheDocument();
82
+ expect( titles() ).toEqual( [ 'Alpha', 'Beta', 'Gamma' ] );
83
+ } );
84
+
85
+ it( 'falls back to (no title) for IDs the fetch does not resolve', async () => {
86
+ renderModal( { ids: [ 11, 99 ], fetchItems: () => Promise.resolve( [ ITEMS[ 0 ] ] ) } );
87
+ expect( await screen.findByText( 'Alpha' ) ).toBeInTheDocument();
88
+ expect( titles() ).toEqual( [ 'Alpha', '(no title)' ] );
89
+ } );
90
+
91
+ it( 'moves an item up with the chevron', async () => {
92
+ renderModal();
93
+ fireEvent.click( await screen.findByLabelText( 'Move Up: Gamma' ) );
94
+ expect( titles() ).toEqual( [ 'Alpha', 'Gamma', 'Beta' ] );
95
+ } );
96
+
97
+ it( 'moves an item down with the chevron', async () => {
98
+ renderModal();
99
+ fireEvent.click( await screen.findByLabelText( 'Move Down: Alpha' ) );
100
+ expect( titles() ).toEqual( [ 'Beta', 'Alpha', 'Gamma' ] );
101
+ } );
102
+
103
+ it( 'disables the chevrons at the ends of the list', async () => {
104
+ renderModal();
105
+ expect( await screen.findByLabelText( 'Move Up: Alpha' ) ).toHaveAttribute( 'aria-disabled', 'true' );
106
+ expect( screen.getByLabelText( 'Move Down: Gamma' ) ).toHaveAttribute( 'aria-disabled', 'true' );
107
+ expect( screen.getByLabelText( 'Move Down: Alpha' ) ).not.toHaveAttribute( 'aria-disabled' );
108
+ } );
109
+
110
+ it( 'keeps a disabled chevron focusable but inert', async () => {
111
+ renderModal();
112
+ const chevron = await screen.findByLabelText( 'Move Up: Alpha' );
113
+ chevron.focus();
114
+ expect( document.activeElement ).toBe( chevron );
115
+ fireEvent.click( chevron );
116
+ expect( titles() ).toEqual( [ 'Alpha', 'Beta', 'Gamma' ] );
117
+ } );
118
+
119
+ it( 'keeps the disabled Save button focusable but inert', async () => {
120
+ const { onSave } = renderModal();
121
+ const save = await screen.findByRole( 'button', { name: 'Save' } );
122
+ save.focus();
123
+ expect( document.activeElement ).toBe( save );
124
+ fireEvent.click( save );
125
+ expect( onSave ).not.toHaveBeenCalled();
126
+ } );
127
+
128
+ it( 'keeps the item title in the accessible name and out of the tooltip', async () => {
129
+ renderModal();
130
+ const chevron = await screen.findByLabelText( 'Move Up: Gamma' );
131
+ expect( screen.queryAllByLabelText( 'Move Up' ) ).toHaveLength( 0 );
132
+
133
+ chevron.focus();
134
+ const tooltip = await screen.findByRole( 'tooltip' );
135
+ expect( tooltip ).toHaveTextContent( 'Move Up' );
136
+ expect( tooltip ).not.toHaveTextContent( 'Gamma' );
137
+ } );
138
+
139
+ it( 'starts the accessible name with the visible label', async () => {
140
+ renderModal();
141
+ const chevron = await screen.findByLabelText( 'Move Up: Gamma' );
142
+ expect( chevron.getAttribute( 'aria-label' ) ).toContain( 'Move Up' );
143
+ expect( screen.getByLabelText( 'Move Down: Alpha' ).getAttribute( 'aria-label' ) ).toContain( 'Move Down' );
144
+ } );
145
+
146
+ it( 'keeps focus on the pressed chevron while it stays enabled', async () => {
147
+ renderModal();
148
+ fireEvent.click( await screen.findByLabelText( 'Move Up: Gamma' ) );
149
+ expect( document.activeElement ).toBe( screen.getByLabelText( 'Move Up: Gamma' ) );
150
+ } );
151
+
152
+ it( 'moves focus to the other chevron when the pressed one disables', async () => {
153
+ renderModal();
154
+ fireEvent.click( await screen.findByLabelText( 'Move Up: Beta' ) );
155
+ expect( screen.getByLabelText( 'Move Up: Beta' ) ).toHaveAttribute( 'aria-disabled', 'true' );
156
+ expect( document.activeElement ).toBe( screen.getByLabelText( 'Move Down: Beta' ) );
157
+ } );
158
+
159
+ it( 'names the loading state and announces the list once it arrives', async () => {
160
+ renderModal();
161
+ expect( screen.getByText( 'Loading content…' ) ).toBeInTheDocument();
162
+ await screen.findByText( 'Alpha' );
163
+ expect( screen.queryByText( 'Loading content…' ) ).not.toBeInTheDocument();
164
+ expect( speak ).toHaveBeenCalledWith( '3 items ready to reorder.' );
165
+ } );
166
+
167
+ it( 'names the row it moved, not just the position', async () => {
168
+ renderModal();
169
+ fireEvent.click( await screen.findByLabelText( 'Move Up: Gamma' ) );
170
+ expect( speak ).toHaveBeenCalledWith( 'Gamma moved to position 2 of 3.' );
171
+ } );
172
+
173
+ it( 'saves the reordered IDs', async () => {
174
+ const { onSave } = renderModal();
175
+ fireEvent.click( await screen.findByLabelText( 'Move Up: Gamma' ) );
176
+ fireEvent.click( screen.getByRole( 'button', { name: 'Save' } ) );
177
+ expect( onSave ).toHaveBeenCalledWith( [ 11, 33, 22 ] );
178
+ } );
179
+
180
+ it( 'discards the new order once the discard is confirmed', async () => {
181
+ const { onSave, onClose } = renderModal();
182
+ fireEvent.click( await screen.findByLabelText( 'Move Up: Gamma' ) );
183
+ fireEvent.click( screen.getByRole( 'button', { name: 'Cancel' } ) );
184
+ fireEvent.click( screen.getByRole( 'button', { name: 'Discard' } ) );
185
+ expect( onSave ).not.toHaveBeenCalled();
186
+ expect( onClose ).toHaveBeenCalled();
187
+ } );
188
+
189
+ it( 'disables saving until the order changes', async () => {
190
+ renderModal();
191
+ expect( await screen.findByRole( 'button', { name: 'Save' } ) ).toHaveAttribute( 'aria-disabled', 'true' );
192
+ fireEvent.click( screen.getByLabelText( 'Move Up: Gamma' ) );
193
+ expect( screen.getByRole( 'button', { name: 'Save' } ) ).not.toHaveAttribute( 'aria-disabled' );
194
+ } );
195
+
196
+ it( 'disables saving again when the order is moved back', async () => {
197
+ renderModal();
198
+ fireEvent.click( await screen.findByLabelText( 'Move Up: Gamma' ) );
199
+ expect( screen.getByRole( 'button', { name: 'Save' } ) ).not.toHaveAttribute( 'aria-disabled' );
200
+ fireEvent.click( screen.getByLabelText( 'Move Down: Gamma' ) );
201
+ expect( titles() ).toEqual( [ 'Alpha', 'Beta', 'Gamma' ] );
202
+ expect( screen.getByRole( 'button', { name: 'Save' } ) ).toHaveAttribute( 'aria-disabled', 'true' );
203
+ } );
204
+
205
+ it( 'closes without confirming when nothing was reordered', async () => {
206
+ const { onClose } = renderModal();
207
+ fireEvent.click( await screen.findByRole( 'button', { name: 'Cancel' } ) );
208
+ expect( onClose ).toHaveBeenCalled();
209
+ expect( discardPrompt() ).not.toBeInTheDocument();
210
+ } );
211
+
212
+ it( 'asks before discarding and holds the modal open until answered', async () => {
213
+ const { onSave, onClose } = renderModal();
214
+ fireEvent.click( await screen.findByLabelText( 'Move Up: Gamma' ) );
215
+ fireEvent.click( screen.getByRole( 'button', { name: 'Cancel' } ) );
216
+ expect( discardPrompt() ).toBeInTheDocument();
217
+ expect( onClose ).not.toHaveBeenCalled();
218
+ expect( onSave ).not.toHaveBeenCalled();
219
+ } );
220
+
221
+ // The modal takes Escape over from `Modal`, whose own handler defers the close
222
+ // request behind an exit animation, so the confirmation is up synchronously.
223
+ it( 'asks before discarding when Escape dismisses the modal', async () => {
224
+ const { onClose } = renderModal();
225
+ fireEvent.click( await screen.findByLabelText( 'Move Up: Gamma' ) );
226
+ fireEvent.keyDown( document.activeElement, { key: 'Escape' } );
227
+ expect( discardPrompt() ).toBeInTheDocument();
228
+ expect( onClose ).not.toHaveBeenCalled();
229
+ } );
230
+
231
+ it( 'closes on Escape without confirming when nothing was reordered', async () => {
232
+ const { onClose } = renderModal();
233
+ await screen.findByText( 'Alpha' );
234
+ fireEvent.keyDown( screen.getByRole( 'dialog' ), { key: 'Escape' } );
235
+ expect( onClose ).toHaveBeenCalled();
236
+ expect( discardPrompt() ).not.toBeInTheDocument();
237
+ } );
238
+
239
+ it( 'asks before discarding when the header close button dismisses the modal', async () => {
240
+ const { onClose } = renderModal();
241
+ fireEvent.click( await screen.findByLabelText( 'Move Up: Gamma' ) );
242
+ fireEvent.click( screen.getByRole( 'button', { name: 'Close' } ) );
243
+ expect( discardPrompt() ).toBeInTheDocument();
244
+ expect( onClose ).not.toHaveBeenCalled();
245
+ } );
246
+
247
+ it( 'closes when a press starts and ends on the overlay', async () => {
248
+ const { onClose } = renderModal();
249
+ await screen.findByText( 'Alpha' );
250
+ press( overlay(), overlay() );
251
+ expect( onClose ).toHaveBeenCalled();
252
+ } );
253
+
254
+ it( 'asks before discarding when an overlay press dismisses a reordered list', async () => {
255
+ const { onClose } = renderModal();
256
+ fireEvent.click( await screen.findByLabelText( 'Move Up: Gamma' ) );
257
+ press( overlay(), overlay() );
258
+ expect( discardPrompt() ).toBeInTheDocument();
259
+ expect( onClose ).not.toHaveBeenCalled();
260
+ } );
261
+
262
+ it( 'ignores a press that starts on a row and ends on the overlay', async () => {
263
+ const { onClose } = renderModal();
264
+ await screen.findByText( 'Alpha' );
265
+ press( document.querySelector( '.newspack-blocks-reorder-modal__item' ), overlay() );
266
+ expect( onClose ).not.toHaveBeenCalled();
267
+ expect( discardPrompt() ).not.toBeInTheDocument();
268
+ } );
269
+
270
+ it( 'ignores a secondary-button press on the overlay', async () => {
271
+ const { onClose } = renderModal();
272
+ await screen.findByText( 'Alpha' );
273
+ press( overlay(), overlay(), 2 );
274
+ expect( onClose ).not.toHaveBeenCalled();
275
+ } );
276
+
277
+ // `ConfirmDialog` announces `contentLabel` as the dialog's name and hides its
278
+ // header, so repeating it in the body would have it read out twice.
279
+ it( 'states the consequence in the discard prompt rather than repeating its name', async () => {
280
+ renderModal();
281
+ fireEvent.click( await screen.findByLabelText( 'Move Up: Gamma' ) );
282
+ fireEvent.click( screen.getByRole( 'button', { name: 'Cancel' } ) );
283
+ expect( discardPrompt() ).toHaveTextContent( 'The order you set will be lost.' );
284
+ expect( discardPrompt() ).not.toHaveTextContent( 'Discard the new order?' );
285
+ } );
286
+
287
+ it( 'keeps the reordered list when the discard is dismissed', async () => {
288
+ const { onSave, onClose } = renderModal();
289
+ fireEvent.click( await screen.findByLabelText( 'Move Up: Gamma' ) );
290
+ fireEvent.click( screen.getByRole( 'button', { name: 'Cancel' } ) );
291
+ fireEvent.click( screen.getByRole( 'button', { name: 'Keep editing' } ) );
292
+ expect( discardPrompt() ).not.toBeInTheDocument();
293
+ expect( titles() ).toEqual( [ 'Alpha', 'Gamma', 'Beta' ] );
294
+ expect( onClose ).not.toHaveBeenCalled();
295
+ expect( onSave ).not.toHaveBeenCalled();
296
+ } );
297
+
298
+ it( 'exposes the rows as a list', async () => {
299
+ renderModal();
300
+ await screen.findByText( 'Alpha' );
301
+ expect( screen.getByRole( 'list' ) ).toBeInTheDocument();
302
+ expect( screen.getAllByRole( 'listitem' ) ).toHaveLength( 3 );
303
+ } );
304
+
305
+ it( 'carries the full title on a row whose text is clipped', async () => {
306
+ renderModal();
307
+ await screen.findByText( 'Alpha' );
308
+ expect( document.querySelector( '.newspack-blocks-reorder-modal__title' ) ).toHaveAttribute( 'title', 'Alpha' );
309
+ } );
310
+ } );
311
+
312
+ // jsdom ships no `DataTransfer`, so the drag payload is stubbed.
313
+ const dataTransfer = () => ( {
314
+ data: {},
315
+ dropEffect: 'none',
316
+ effectAllowed: 'none',
317
+ setData( type, value ) {
318
+ this.data[ type ] = value;
319
+ },
320
+ getData( type ) {
321
+ return this.data[ type ];
322
+ },
323
+ } );
324
+
325
+ const rows = () => Array.from( document.querySelectorAll( '.newspack-blocks-reorder-modal__item' ) );
326
+
327
+ describe( 'ReorderModal drag and drop', () => {
328
+ it( 'reorders as a row is dragged over another', async () => {
329
+ renderModal();
330
+ await screen.findByText( 'Alpha' );
331
+ const dt = dataTransfer();
332
+ fireEvent.dragStart( rows()[ 2 ], { dataTransfer: dt } );
333
+ expect( dt.getData( 'text/plain' ) ).toBe( '33' );
334
+ fireEvent.dragOver( rows()[ 0 ], { dataTransfer: dt } );
335
+ expect( titles() ).toEqual( [ 'Gamma', 'Alpha', 'Beta' ] );
336
+ } );
337
+
338
+ it( 'keeps the new order when the drag is dropped', async () => {
339
+ renderModal();
340
+ await screen.findByText( 'Alpha' );
341
+ const dt = dataTransfer();
342
+ fireEvent.dragStart( rows()[ 2 ], { dataTransfer: dt } );
343
+ fireEvent.dragOver( rows()[ 0 ], { dataTransfer: dt } );
344
+ dt.dropEffect = 'move';
345
+ fireEvent.dragEnd( rows()[ 0 ], { dataTransfer: dt } );
346
+ expect( titles() ).toEqual( [ 'Gamma', 'Alpha', 'Beta' ] );
347
+ } );
348
+
349
+ it( 'puts the order back when the drag is cancelled', async () => {
350
+ renderModal();
351
+ await screen.findByText( 'Alpha' );
352
+ const dt = dataTransfer();
353
+ fireEvent.dragStart( rows()[ 2 ], { dataTransfer: dt } );
354
+ fireEvent.dragOver( rows()[ 0 ], { dataTransfer: dt } );
355
+ expect( titles() ).toEqual( [ 'Gamma', 'Alpha', 'Beta' ] );
356
+ dt.dropEffect = 'none';
357
+ fireEvent.dragEnd( rows()[ 0 ], { dataTransfer: dt } );
358
+ expect( titles() ).toEqual( [ 'Alpha', 'Beta', 'Gamma' ] );
359
+ } );
360
+
361
+ it( 'reorders as a row is dragged down past the rows below it', async () => {
362
+ renderModal();
363
+ await screen.findByText( 'Alpha' );
364
+ const dt = dataTransfer();
365
+ fireEvent.dragStart( rows()[ 0 ], { dataTransfer: dt } );
366
+ fireEvent.dragOver( rows()[ 1 ], { dataTransfer: dt } );
367
+ expect( titles() ).toEqual( [ 'Beta', 'Alpha', 'Gamma' ] );
368
+
369
+ // The dragged row now sits under the cursor, so the next `dragover` reaches
370
+ // it rather than the row it swapped with: it has to leave the order alone.
371
+ fireEvent.dragOver( rows()[ 1 ], { dataTransfer: dt } );
372
+ expect( titles() ).toEqual( [ 'Beta', 'Alpha', 'Gamma' ] );
373
+
374
+ fireEvent.dragOver( rows()[ 2 ], { dataTransfer: dt } );
375
+ expect( titles() ).toEqual( [ 'Beta', 'Gamma', 'Alpha' ] );
376
+ } );
377
+
378
+ // `dragstart` fires at the draggable row even when the gesture began on a
379
+ // chevron inside it, so the guard keys off where the pointer went down.
380
+ it( 'does not start a drag that began on a chevron', async () => {
381
+ renderModal();
382
+ const chevron = await screen.findByLabelText( 'Move Up: Gamma' );
383
+ fireEvent( chevron, new MouseEvent( 'pointerdown', { bubbles: true } ) );
384
+
385
+ const dt = dataTransfer();
386
+ fireEvent.dragStart( rows()[ 2 ], { dataTransfer: dt } );
387
+ expect( dt.getData( 'text/plain' ) ).toBeUndefined();
388
+
389
+ fireEvent.dragOver( rows()[ 0 ], { dataTransfer: dt } );
390
+ expect( titles() ).toEqual( [ 'Alpha', 'Beta', 'Gamma' ] );
391
+ } );
392
+
393
+ it( 'starts a drag that began on the row itself', async () => {
394
+ renderModal();
395
+ await screen.findByText( 'Alpha' );
396
+ fireEvent( rows()[ 2 ], new MouseEvent( 'pointerdown', { bubbles: true } ) );
397
+
398
+ const dt = dataTransfer();
399
+ fireEvent.dragStart( rows()[ 2 ], { dataTransfer: dt } );
400
+ expect( dt.getData( 'text/plain' ) ).toBe( '33' );
401
+ } );
402
+
403
+ // Rows are the only drop targets, so the dialog has to accept the drop as well:
404
+ // otherwise the gaps between rows, and the frame's own padding, read as a
405
+ // cancelled drag and undo the reorder.
406
+ it.each( [
407
+ [ 'frame', '.components-modal__frame' ],
408
+ [ 'body', '.newspack-blocks-reorder-modal__body' ],
409
+ ] )( 'accepts a drop released on the %s', async ( _name, selector ) => {
410
+ renderModal();
411
+ await screen.findByText( 'Alpha' );
412
+ const target = document.querySelector( selector );
413
+
414
+ const over = createEvent.dragOver( target, { dataTransfer: dataTransfer() } );
415
+ fireEvent( target, over );
416
+ expect( over.defaultPrevented ).toBe( true );
417
+
418
+ const drop = createEvent.drop( target, { dataTransfer: dataTransfer() } );
419
+ fireEvent( target, drop );
420
+ expect( drop.defaultPrevented ).toBe( true );
421
+ } );
422
+
423
+ it( 'leaves a drop released on the overlay cancelling the drag', async () => {
424
+ renderModal();
425
+ await screen.findByText( 'Alpha' );
426
+
427
+ const over = createEvent.dragOver( overlay(), { dataTransfer: dataTransfer() } );
428
+ fireEvent( overlay(), over );
429
+ expect( over.defaultPrevented ).toBe( false );
430
+ } );
431
+ } );
432
+
433
+ describe( 'ReorderModal when the titles cannot be loaded', () => {
434
+ let errorSpy;
435
+
436
+ beforeEach( () => {
437
+ errorSpy = jest.spyOn( console, 'error' ).mockImplementation( () => {} );
438
+ } );
439
+
440
+ afterEach( () => {
441
+ errorSpy.mockRestore();
442
+ } );
443
+
444
+ it( 'says so instead of offering rows nobody can tell apart', async () => {
445
+ const { onClose } = renderModal( { fetchItems: () => Promise.reject( new Error( 'unreachable' ) ) } );
446
+ // The same wording also lands in the live region core announces it through.
447
+ expect(
448
+ await screen.findByText( 'The content could not be loaded, so the order cannot be saved.', {
449
+ selector: '.components-notice__content',
450
+ } )
451
+ ).toBeInTheDocument();
452
+
453
+ expect( titles() ).toEqual( [] );
454
+ expect( screen.queryByRole( 'list' ) ).not.toBeInTheDocument();
455
+ expect( screen.queryByRole( 'button', { name: 'Save' } ) ).not.toBeInTheDocument();
456
+
457
+ fireEvent.click( screen.getByRole( 'button', { name: 'Cancel' } ) );
458
+ expect( onClose ).toHaveBeenCalled();
459
+ } );
460
+ } );
461
+
462
+ describe( 'findOverlay', () => {
463
+ const markup = html => {
464
+ const root = document.createElement( 'div' );
465
+ root.innerHTML = html;
466
+ return root;
467
+ };
468
+
469
+ it( 'takes the node itself when it is the overlay', () => {
470
+ const overlayNode = markup( '<div class="components-modal__screen-overlay"></div>' ).firstChild;
471
+ expect( findOverlay( overlayNode ) ).toBe( overlayNode );
472
+ } );
473
+
474
+ it( 'finds the overlay from a node inside it', () => {
475
+ const root = markup( '<div class="components-modal__screen-overlay"><div class="components-modal__frame"></div></div>' );
476
+ expect( findOverlay( root.querySelector( '.components-modal__frame' ) ) ).toBe( root.firstChild );
477
+ } );
478
+
479
+ it( 'finds the overlay from a node wrapping it', () => {
480
+ const root = markup( '<div class="components-modal__screen-overlay"></div>' );
481
+ expect( findOverlay( root ) ).toBe( root.firstChild );
482
+ } );
483
+
484
+ it( 'reports nothing rather than guessing', () => {
485
+ expect( findOverlay( null ) ).toBe( null );
486
+ } );
487
+ } );
@@ -0,0 +1,91 @@
1
+ /**
2
+ * WordPress dependencies
3
+ */
4
+ import { __ } from '@wordpress/i18n';
5
+ import { useCallback, useRef, useState } from '@wordpress/element';
6
+ import {
7
+ Button,
8
+ // eslint-disable-next-line @wordpress/no-unsafe-wp-apis
9
+ __experimentalVStack as VStack,
10
+ } from '@wordpress/components';
11
+
12
+ /**
13
+ * Internal dependencies
14
+ */
15
+ import AutocompleteTokenField from './autocomplete-tokenfield';
16
+ import ReorderModal from './reorder-modal';
17
+ import './specific-posts-control.scss';
18
+
19
+ const SpecificPostsControl = ( { postIds = [], onChange, fetchSuggestions, fetchSavedInfo } ) => {
20
+ const [ isReordering, setIsReordering ] = useState( false );
21
+ const savedInfo = useRef( { key: null, promise: null } );
22
+ const canReorder = 1 < postIds.length;
23
+ const reorderLabel = __( 'Reorder Content', 'newspack-blocks' );
24
+
25
+ // The token field and the modal ask for the same titles, so the request is
26
+ // shared rather than made twice, and a failure leaves the slot open to retry.
27
+ const fetchSavedInfoOnce = useCallback(
28
+ ids => {
29
+ const key = [ ...ids ].sort().join( ',' );
30
+ if ( savedInfo.current.key !== key ) {
31
+ savedInfo.current = {
32
+ key,
33
+ promise: fetchSavedInfo( ids ).catch( error => {
34
+ // A late rejection must not evict an entry a newer request owns.
35
+ if ( savedInfo.current.key === key ) {
36
+ savedInfo.current = { key: null, promise: null };
37
+ }
38
+ throw error;
39
+ } ),
40
+ };
41
+ }
42
+ return savedInfo.current.promise;
43
+ },
44
+ [ fetchSavedInfo ]
45
+ );
46
+
47
+ return (
48
+ <>
49
+ <VStack spacing={ 2 }>
50
+ <AutocompleteTokenField
51
+ tokens={ postIds }
52
+ onChange={ onChange }
53
+ fetchSuggestions={ fetchSuggestions }
54
+ fetchSavedInfo={ fetchSavedInfoOnce }
55
+ label={ __( 'Content', 'newspack-blocks' ) }
56
+ help={ __( 'Begin typing any word in a title. Click on an autocomplete result to select it.', 'newspack-blocks' ) }
57
+ />
58
+ <Button
59
+ className="newspack-blocks-specific-posts-control__reorder"
60
+ variant="secondary"
61
+ __next40pxDefaultSize
62
+ disabled={ ! canReorder }
63
+ accessibleWhenDisabled
64
+ // A button with visible children shows no tooltip unless asked, and
65
+ // only shows one at all when it also carries a label. Matching the
66
+ // label to the visible text leaves the accessible name unchanged.
67
+ label={ reorderLabel }
68
+ showTooltip={ ! canReorder }
69
+ description={ canReorder ? undefined : __( 'Pick at least two items to reorder them.', 'newspack-blocks' ) }
70
+ onClick={ () => setIsReordering( true ) }
71
+ >
72
+ { reorderLabel }
73
+ </Button>
74
+ </VStack>
75
+ { isReordering && (
76
+ <ReorderModal
77
+ title={ reorderLabel }
78
+ ids={ postIds }
79
+ fetchItems={ fetchSavedInfoOnce }
80
+ onSave={ ids => {
81
+ onChange( ids );
82
+ setIsReordering( false );
83
+ } }
84
+ onClose={ () => setIsReordering( false ) }
85
+ />
86
+ ) }
87
+ </>
88
+ );
89
+ };
90
+
91
+ export default SpecificPostsControl;
@@ -0,0 +1,5 @@
1
+ .newspack-blocks-specific-posts-control {
2
+ &__reorder {
3
+ justify-content: center;
4
+ }
5
+ }