@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.
- package/CHANGELOG.md +7 -0
- package/composer.lock +73 -994
- package/dist/blocks/checkout-button/block.json +3 -0
- package/dist/carousel/view-rtl.css +1 -1
- package/dist/carousel/view.asset.php +1 -1
- package/dist/carousel/view.css +1 -1
- package/dist/editor-rtl.css +20 -16
- package/dist/editor.asset.php +1 -1
- package/dist/editor.css +20 -16
- package/dist/editor.js +32 -28
- package/dist/homepage-articles/view-rtl.css +1 -1
- package/dist/homepage-articles/view.asset.php +1 -1
- package/dist/homepage-articles/view.css +1 -1
- package/dist/modal.asset.php +1 -1
- package/dist/modal.js +1 -1
- package/dist/modalCheckout-rtl.css +1 -1
- package/dist/modalCheckout.asset.php +1 -1
- package/dist/modalCheckout.css +1 -1
- package/dist/modalCheckout.js +1 -1
- package/dist/tiersBased.asset.php +1 -1
- package/dist/tiersBased.js +1 -1
- package/includes/class-modal-checkout.php +354 -5
- package/includes/modal-checkout/class-checkout-data.php +30 -6
- package/languages/newspack-blocks-de_DE-37552bb09e2a9fceec1970e3c6d46557.json +1 -1
- package/languages/newspack-blocks-de_DE-53e2a1d5945b8d2b1c35e81ae1e532f3.json +1 -1
- package/languages/newspack-blocks-de_DE-5c566531829f64486c13d7d8df1d6151.json +1 -0
- package/languages/newspack-blocks-de_DE-7c29764d5ef91723f5ce454f66431b9e.json +1 -1
- package/languages/newspack-blocks-de_DE-eeb28f99adedd5fad3081d2756d6f4a4.json +1 -1
- package/languages/newspack-blocks-de_DE-fef17c08abbcd3d3c4cdc0fd13a9bff5.json +1 -0
- package/languages/newspack-blocks-de_DE.po +427 -322
- package/languages/newspack-blocks-es_ES-37552bb09e2a9fceec1970e3c6d46557.json +1 -1
- package/languages/newspack-blocks-es_ES-53e2a1d5945b8d2b1c35e81ae1e532f3.json +1 -1
- package/languages/newspack-blocks-es_ES-5c566531829f64486c13d7d8df1d6151.json +1 -0
- package/languages/newspack-blocks-es_ES-7c29764d5ef91723f5ce454f66431b9e.json +1 -1
- package/languages/newspack-blocks-es_ES-eeb28f99adedd5fad3081d2756d6f4a4.json +1 -1
- package/languages/newspack-blocks-es_ES-fef17c08abbcd3d3c4cdc0fd13a9bff5.json +1 -0
- package/languages/newspack-blocks-es_ES.po +425 -322
- package/languages/newspack-blocks-fr_BE-37552bb09e2a9fceec1970e3c6d46557.json +1 -1
- package/languages/newspack-blocks-fr_BE-53e2a1d5945b8d2b1c35e81ae1e532f3.json +1 -1
- package/languages/newspack-blocks-fr_BE-5c566531829f64486c13d7d8df1d6151.json +1 -0
- package/languages/newspack-blocks-fr_BE-7c29764d5ef91723f5ce454f66431b9e.json +1 -1
- package/languages/newspack-blocks-fr_BE-eeb28f99adedd5fad3081d2756d6f4a4.json +1 -1
- package/languages/newspack-blocks-fr_BE-fef17c08abbcd3d3c4cdc0fd13a9bff5.json +1 -0
- package/languages/newspack-blocks-fr_BE.po +427 -322
- package/languages/newspack-blocks-nb_NO-37552bb09e2a9fceec1970e3c6d46557.json +1 -1
- package/languages/newspack-blocks-nb_NO-53e2a1d5945b8d2b1c35e81ae1e532f3.json +1 -1
- package/languages/newspack-blocks-nb_NO-5c566531829f64486c13d7d8df1d6151.json +1 -0
- package/languages/newspack-blocks-nb_NO-7c29764d5ef91723f5ce454f66431b9e.json +1 -1
- package/languages/newspack-blocks-nb_NO-eeb28f99adedd5fad3081d2756d6f4a4.json +1 -1
- package/languages/newspack-blocks-nb_NO-fef17c08abbcd3d3c4cdc0fd13a9bff5.json +1 -0
- package/languages/newspack-blocks-nb_NO.po +426 -322
- package/languages/newspack-blocks-pt_PT-37552bb09e2a9fceec1970e3c6d46557.json +1 -1
- package/languages/newspack-blocks-pt_PT-53e2a1d5945b8d2b1c35e81ae1e532f3.json +1 -1
- package/languages/newspack-blocks-pt_PT-5c566531829f64486c13d7d8df1d6151.json +1 -0
- package/languages/newspack-blocks-pt_PT-7c29764d5ef91723f5ce454f66431b9e.json +1 -1
- package/languages/newspack-blocks-pt_PT-eeb28f99adedd5fad3081d2756d6f4a4.json +1 -1
- package/languages/newspack-blocks-pt_PT-fef17c08abbcd3d3c4cdc0fd13a9bff5.json +1 -0
- package/languages/newspack-blocks-pt_PT.po +426 -322
- package/languages/newspack-blocks.pot +506 -379
- package/newspack-blocks.php +2 -2
- package/package.json +3 -2
- package/src/blocks/carousel/edit.js +1 -1
- package/src/blocks/carousel/view.scss +5 -1
- package/src/blocks/checkout-button/block.json +3 -0
- package/src/blocks/checkout-button/edit.js +32 -5
- package/src/blocks/checkout-button/view.php +37 -0
- package/src/blocks/donate/consts.ts +2 -1
- package/src/blocks/donate/frequency-slugs.js +10 -0
- package/src/blocks/donate/tiers-based/view.ts +8 -0
- package/src/blocks/homepage-articles/edit.tsx +1 -1
- package/src/blocks/homepage-articles/editor.scss +2 -1
- package/src/blocks/homepage-articles/view.scss +15 -5
- package/src/components/autocomplete-tokenfield.js +21 -7
- package/src/components/autocomplete-tokenfield.scss +1 -1
- package/src/components/autocomplete-tokenfield.test.js +58 -0
- package/src/components/query-controls.js +28 -6
- package/src/components/query-controls.test.js +56 -0
- package/src/components/reorder-modal.js +382 -0
- package/src/components/reorder-modal.scss +87 -0
- package/src/components/reorder-modal.test.js +487 -0
- package/src/components/specific-posts-control.js +91 -0
- package/src/components/specific-posts-control.scss +5 -0
- package/src/components/specific-posts-control.test.js +173 -0
- package/src/modal-checkout/analytics/ga4/utils/index.js +1 -0
- package/src/modal-checkout/checkout-button-trigger.js +29 -2
- package/src/modal-checkout/checkout-button-trigger.test.js +37 -0
- package/src/modal-checkout/checkout.scss +28 -0
- package/src/modal-checkout/donate-trigger.js +134 -0
- package/src/modal-checkout/donate-trigger.test.js +233 -0
- package/src/modal-checkout/index.js +91 -0
- package/src/modal-checkout/modal.js +129 -45
- package/src/modal-checkout/utils.test.js +49 -0
- package/src/shared/js/tiers-based-ready.js +26 -0
- package/vendor/autoload.php +1 -1
- package/vendor/composer/autoload_real.php +4 -4
- package/vendor/composer/autoload_static.php +2 -2
- package/vendor/composer/installed.php +2 -2
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* External dependencies
|
|
3
|
+
*/
|
|
4
|
+
import { act, fireEvent, render, screen } from '@testing-library/react';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Internal dependencies
|
|
8
|
+
*/
|
|
9
|
+
import SpecificPostsControl from './specific-posts-control';
|
|
10
|
+
|
|
11
|
+
const noop = () => {};
|
|
12
|
+
const noItems = () => Promise.resolve( [] );
|
|
13
|
+
|
|
14
|
+
const ITEMS = [
|
|
15
|
+
{ value: 11, label: 'Alpha' },
|
|
16
|
+
{ value: 22, label: 'Beta' },
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
const titles = () => Array.from( document.querySelectorAll( '.newspack-blocks-reorder-modal__title' ) ).map( el => el.textContent );
|
|
20
|
+
|
|
21
|
+
describe( 'SpecificPostsControl', () => {
|
|
22
|
+
it( 'exposes the token field to assistive technology as "Content"', () => {
|
|
23
|
+
render( <SpecificPostsControl postIds={ [] } onChange={ noop } fetchSuggestions={ noop } fetchSavedInfo={ noop } /> );
|
|
24
|
+
expect( screen.getByRole( 'combobox', { name: 'Content' } ) ).toBeInTheDocument();
|
|
25
|
+
} );
|
|
26
|
+
|
|
27
|
+
it( 'keeps the reorder button focusable while too little content is selected', async () => {
|
|
28
|
+
render( <SpecificPostsControl postIds={ [ 11 ] } onChange={ noop } fetchSuggestions={ noop } fetchSavedInfo={ noItems } /> );
|
|
29
|
+
const reorder = await screen.findByRole( 'button', { name: 'Reorder Content' } );
|
|
30
|
+
expect( reorder ).toHaveAttribute( 'aria-disabled', 'true' );
|
|
31
|
+
reorder.focus();
|
|
32
|
+
expect( document.activeElement ).toBe( reorder );
|
|
33
|
+
} );
|
|
34
|
+
|
|
35
|
+
it( 'describes why the reorder button is unavailable without renaming it', async () => {
|
|
36
|
+
render( <SpecificPostsControl postIds={ [ 11 ] } onChange={ noop } fetchSuggestions={ noop } fetchSavedInfo={ noItems } /> );
|
|
37
|
+
const reorder = await screen.findByRole( 'button', { name: 'Reorder Content' } );
|
|
38
|
+
const description = document.getElementById( reorder.getAttribute( 'aria-describedby' ) );
|
|
39
|
+
expect( description ).toHaveTextContent( 'Pick at least two items to reorder them.' );
|
|
40
|
+
} );
|
|
41
|
+
|
|
42
|
+
// A button with visible children shows no tooltip by default, so the reason
|
|
43
|
+
// would otherwise reach assistive technology only.
|
|
44
|
+
it( 'shows the reason to sighted users too', async () => {
|
|
45
|
+
render( <SpecificPostsControl postIds={ [ 11 ] } onChange={ noop } fetchSuggestions={ noop } fetchSavedInfo={ noItems } /> );
|
|
46
|
+
( await screen.findByRole( 'button', { name: 'Reorder Content' } ) ).focus();
|
|
47
|
+
expect( await screen.findByRole( 'tooltip' ) ).toHaveTextContent( 'Pick at least two items to reorder them.' );
|
|
48
|
+
} );
|
|
49
|
+
|
|
50
|
+
it( 'shows no tooltip once the button is usable', async () => {
|
|
51
|
+
render(
|
|
52
|
+
<SpecificPostsControl
|
|
53
|
+
postIds={ [ 11, 22 ] }
|
|
54
|
+
onChange={ noop }
|
|
55
|
+
fetchSuggestions={ noop }
|
|
56
|
+
fetchSavedInfo={ () => Promise.resolve( ITEMS ) }
|
|
57
|
+
/>
|
|
58
|
+
);
|
|
59
|
+
( await screen.findByRole( 'button', { name: 'Reorder Content' } ) ).focus();
|
|
60
|
+
expect( screen.queryByRole( 'tooltip' ) ).not.toBeInTheDocument();
|
|
61
|
+
} );
|
|
62
|
+
|
|
63
|
+
it( 'enables the reorder button once two items are selected', async () => {
|
|
64
|
+
render(
|
|
65
|
+
<SpecificPostsControl
|
|
66
|
+
postIds={ [ 11, 22 ] }
|
|
67
|
+
onChange={ noop }
|
|
68
|
+
fetchSuggestions={ noop }
|
|
69
|
+
fetchSavedInfo={ () => Promise.resolve( ITEMS ) }
|
|
70
|
+
/>
|
|
71
|
+
);
|
|
72
|
+
expect( await screen.findByRole( 'button', { name: 'Reorder Content' } ) ).not.toHaveAttribute( 'aria-disabled' );
|
|
73
|
+
} );
|
|
74
|
+
|
|
75
|
+
it( 'writes the reordered IDs back through onChange', async () => {
|
|
76
|
+
const onChange = jest.fn();
|
|
77
|
+
render(
|
|
78
|
+
<SpecificPostsControl
|
|
79
|
+
postIds={ [ 11, 22 ] }
|
|
80
|
+
onChange={ onChange }
|
|
81
|
+
fetchSuggestions={ noop }
|
|
82
|
+
fetchSavedInfo={ () => Promise.resolve( ITEMS ) }
|
|
83
|
+
/>
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
fireEvent.click( await screen.findByRole( 'button', { name: 'Reorder Content' } ) );
|
|
87
|
+
await screen.findByLabelText( 'Move Up: Beta' );
|
|
88
|
+
expect( titles() ).toEqual( [ 'Alpha', 'Beta' ] );
|
|
89
|
+
|
|
90
|
+
fireEvent.click( screen.getByLabelText( 'Move Up: Beta' ) );
|
|
91
|
+
fireEvent.click( screen.getByRole( 'button', { name: 'Save' } ) );
|
|
92
|
+
|
|
93
|
+
expect( onChange ).toHaveBeenCalledWith( [ 22, 11 ] );
|
|
94
|
+
expect( screen.queryByRole( 'dialog' ) ).not.toBeInTheDocument();
|
|
95
|
+
} );
|
|
96
|
+
|
|
97
|
+
it( 'closes the modal without changing anything when the reorder is cancelled', async () => {
|
|
98
|
+
const onChange = jest.fn();
|
|
99
|
+
render(
|
|
100
|
+
<SpecificPostsControl
|
|
101
|
+
postIds={ [ 11, 22 ] }
|
|
102
|
+
onChange={ onChange }
|
|
103
|
+
fetchSuggestions={ noop }
|
|
104
|
+
fetchSavedInfo={ () => Promise.resolve( ITEMS ) }
|
|
105
|
+
/>
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
fireEvent.click( await screen.findByRole( 'button', { name: 'Reorder Content' } ) );
|
|
109
|
+
await screen.findByLabelText( 'Move Up: Beta' );
|
|
110
|
+
fireEvent.click( screen.getByRole( 'button', { name: 'Cancel' } ) );
|
|
111
|
+
|
|
112
|
+
expect( onChange ).not.toHaveBeenCalled();
|
|
113
|
+
expect( screen.queryByRole( 'dialog' ) ).not.toBeInTheDocument();
|
|
114
|
+
} );
|
|
115
|
+
|
|
116
|
+
it( 'asks for the titles once and shares them with the modal', async () => {
|
|
117
|
+
const fetchSavedInfo = jest.fn( () => Promise.resolve( ITEMS ) );
|
|
118
|
+
render( <SpecificPostsControl postIds={ [ 11, 22 ] } onChange={ noop } fetchSuggestions={ noop } fetchSavedInfo={ fetchSavedInfo } /> );
|
|
119
|
+
|
|
120
|
+
fireEvent.click( await screen.findByRole( 'button', { name: 'Reorder Content' } ) );
|
|
121
|
+
await screen.findByLabelText( 'Move Up: Beta' );
|
|
122
|
+
|
|
123
|
+
expect( fetchSavedInfo ).toHaveBeenCalledTimes( 1 );
|
|
124
|
+
} );
|
|
125
|
+
|
|
126
|
+
describe( 'when a request fails', () => {
|
|
127
|
+
let errorSpy;
|
|
128
|
+
|
|
129
|
+
beforeEach( () => {
|
|
130
|
+
errorSpy = jest.spyOn( console, 'error' ).mockImplementation( () => {} );
|
|
131
|
+
} );
|
|
132
|
+
|
|
133
|
+
afterEach( () => {
|
|
134
|
+
errorSpy.mockRestore();
|
|
135
|
+
} );
|
|
136
|
+
|
|
137
|
+
it( 'asks again rather than sharing the failure', async () => {
|
|
138
|
+
const fetchSavedInfo = jest.fn().mockRejectedValueOnce( new Error( 'unreachable' ) ).mockResolvedValue( ITEMS );
|
|
139
|
+
render( <SpecificPostsControl postIds={ [ 11, 22 ] } onChange={ noop } fetchSuggestions={ noop } fetchSavedInfo={ fetchSavedInfo } /> );
|
|
140
|
+
|
|
141
|
+
// The token field falls back to the IDs, which is where the failure lands.
|
|
142
|
+
await screen.findByText( '11' );
|
|
143
|
+
|
|
144
|
+
fireEvent.click( screen.getByRole( 'button', { name: 'Reorder Content' } ) );
|
|
145
|
+
expect( await screen.findByLabelText( 'Move Up: Beta' ) ).toBeInTheDocument();
|
|
146
|
+
expect( fetchSavedInfo ).toHaveBeenCalledTimes( 2 );
|
|
147
|
+
} );
|
|
148
|
+
|
|
149
|
+
it( 'leaves a newer request in place when an older one fails late', async () => {
|
|
150
|
+
let failFirst;
|
|
151
|
+
const first = new Promise( ( resolve, reject ) => {
|
|
152
|
+
failFirst = reject;
|
|
153
|
+
} );
|
|
154
|
+
const fetchSavedInfo = jest.fn().mockReturnValueOnce( first ).mockResolvedValue( ITEMS );
|
|
155
|
+
const props = { onChange: noop, fetchSuggestions: noop, fetchSavedInfo };
|
|
156
|
+
const { rerender } = render( <SpecificPostsControl postIds={ [ 11 ] } { ...props } /> );
|
|
157
|
+
|
|
158
|
+
rerender( <SpecificPostsControl postIds={ [ 11, 22 ] } { ...props } /> );
|
|
159
|
+
fireEvent.click( screen.getByRole( 'button', { name: 'Reorder Content' } ) );
|
|
160
|
+
await screen.findByLabelText( 'Move Up: Beta' );
|
|
161
|
+
|
|
162
|
+
await act( async () => {
|
|
163
|
+
failFirst( new Error( 'too late' ) );
|
|
164
|
+
} );
|
|
165
|
+
|
|
166
|
+
fireEvent.click( screen.getByRole( 'button', { name: 'Cancel' } ) );
|
|
167
|
+
fireEvent.click( screen.getByRole( 'button', { name: 'Reorder Content' } ) );
|
|
168
|
+
await screen.findByLabelText( 'Move Up: Beta' );
|
|
169
|
+
|
|
170
|
+
expect( fetchSavedInfo ).toHaveBeenCalledTimes( 2 );
|
|
171
|
+
} );
|
|
172
|
+
} );
|
|
173
|
+
} );
|
|
@@ -150,8 +150,27 @@ export const PICKER_CONTEXT_FIELDS = [
|
|
|
150
150
|
'newspack_popup_id',
|
|
151
151
|
'prompt_title',
|
|
152
152
|
'coupon',
|
|
153
|
+
'quantity',
|
|
153
154
|
];
|
|
154
155
|
|
|
156
|
+
/**
|
|
157
|
+
* Whether the form already offers this field as a control the reader can use.
|
|
158
|
+
*
|
|
159
|
+
* Context fields are carried as hidden inputs, but a picker can render one of
|
|
160
|
+
* the same names as a real control — the per-seat group plans' seats field is
|
|
161
|
+
* `quantity`. That control is the reader's, not the block's: it must never be
|
|
162
|
+
* removed, and a hidden input of the same name alongside it would submit a
|
|
163
|
+
* second, competing value.
|
|
164
|
+
*
|
|
165
|
+
* @param {HTMLFormElement} form The form to inspect.
|
|
166
|
+
* @param {string} name The field name.
|
|
167
|
+
*
|
|
168
|
+
* @return {boolean} True when a non-hidden input of that name exists.
|
|
169
|
+
*/
|
|
170
|
+
function hasVisibleField( form, name ) {
|
|
171
|
+
return !! form.querySelector( `input[name="${ name }"]:not([type="hidden"])` );
|
|
172
|
+
}
|
|
173
|
+
|
|
155
174
|
/**
|
|
156
175
|
* Stamp context fields onto a picker form from the button that opened it.
|
|
157
176
|
*
|
|
@@ -174,8 +193,14 @@ export function applyContextFields( targetForm, data, fields = PICKER_CONTEXT_FI
|
|
|
174
193
|
}
|
|
175
194
|
const doc = targetForm.ownerDocument;
|
|
176
195
|
fields.forEach( name => {
|
|
177
|
-
// Drop whatever a previous open left behind before writing this one's value
|
|
178
|
-
|
|
196
|
+
// Drop whatever a previous open left behind before writing this one's value
|
|
197
|
+
// — but only the hidden inputs this function stamps. A visible control of
|
|
198
|
+
// the same name is the picker's own, and removing it takes the field away
|
|
199
|
+
// from the reader.
|
|
200
|
+
targetForm.querySelectorAll( `input[type="hidden"][name="${ name }"]` ).forEach( input => input.remove() );
|
|
201
|
+
if ( hasVisibleField( targetForm, name ) ) {
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
179
204
|
const raw = data[ name ];
|
|
180
205
|
const value = raw === undefined || raw === null ? '' : String( raw );
|
|
181
206
|
if ( ! value ) {
|
|
@@ -210,6 +235,8 @@ export function copyContextFields( sourceForm, targetForm, fields = PICKER_CONTE
|
|
|
210
235
|
const doc = targetForm.ownerDocument;
|
|
211
236
|
const sourceData = new FormData( sourceForm );
|
|
212
237
|
fields.forEach( name => {
|
|
238
|
+
// A field the picker already carries wins, whether that is a value left by
|
|
239
|
+
// an earlier copy or a control the reader fills in themselves.
|
|
213
240
|
if ( targetForm.querySelector( `input[name="${ name }"]` ) ) {
|
|
214
241
|
return;
|
|
215
242
|
}
|
|
@@ -371,6 +371,42 @@ describe( 'applyContextFields', () => {
|
|
|
371
371
|
expect( form.querySelector( 'input[name="prompt_title"]' ) ).toBeNull();
|
|
372
372
|
} );
|
|
373
373
|
|
|
374
|
+
it( 'stamps the quantity', () => {
|
|
375
|
+
const form = picker();
|
|
376
|
+
|
|
377
|
+
applyContextFields( form, { quantity: 3 } );
|
|
378
|
+
|
|
379
|
+
expect( form.querySelector( 'input[name="quantity"]' ).value ).toBe( '3' );
|
|
380
|
+
} );
|
|
381
|
+
|
|
382
|
+
it( 'leaves the picker’s own seats field in place and does not shadow it', () => {
|
|
383
|
+
// Per-seat group plans render a visible number input named `quantity`, the
|
|
384
|
+
// same name the block stamps its own quantity into. Removing it leaves the
|
|
385
|
+
// reader with no way to choose seats at all.
|
|
386
|
+
const form = render( '<form id="picker"><input type="number" name="quantity" id="group_seats" min="2" value="2"></form>' ).querySelector(
|
|
387
|
+
'#picker'
|
|
388
|
+
);
|
|
389
|
+
|
|
390
|
+
applyContextFields( form, { quantity: 3 } );
|
|
391
|
+
|
|
392
|
+
const inputs = form.querySelectorAll( 'input[name="quantity"]' );
|
|
393
|
+
expect( inputs ).toHaveLength( 1 );
|
|
394
|
+
expect( inputs[ 0 ].id ).toBe( 'group_seats' );
|
|
395
|
+
expect( inputs[ 0 ].value ).toBe( '2' );
|
|
396
|
+
} );
|
|
397
|
+
|
|
398
|
+
it( 'clears its own stale hidden field without touching a visible one', () => {
|
|
399
|
+
const form = picker();
|
|
400
|
+
|
|
401
|
+
applyContextFields( form, { quantity: 3 } );
|
|
402
|
+
form.insertAdjacentHTML( 'beforeend', '<input type="number" name="quantity" id="group_seats" value="4">' );
|
|
403
|
+
applyContextFields( form, { quantity: 5 } );
|
|
404
|
+
|
|
405
|
+
const inputs = form.querySelectorAll( 'input[name="quantity"]' );
|
|
406
|
+
expect( inputs ).toHaveLength( 1 );
|
|
407
|
+
expect( inputs[ 0 ].id ).toBe( 'group_seats' );
|
|
408
|
+
} );
|
|
409
|
+
|
|
374
410
|
it( 'leaves the picker’s own fields alone', () => {
|
|
375
411
|
const form = render(
|
|
376
412
|
'<form id="picker"><input type="hidden" name="newspack_checkout" value="1"><input type="radio" name="product_id" value="9"></form>'
|
|
@@ -402,6 +438,7 @@ describe( 'PICKER_CONTEXT_FIELDS', () => {
|
|
|
402
438
|
'newspack_popup_id',
|
|
403
439
|
'prompt_title',
|
|
404
440
|
'coupon',
|
|
441
|
+
'quantity',
|
|
405
442
|
] )
|
|
406
443
|
);
|
|
407
444
|
} );
|
|
@@ -333,6 +333,33 @@
|
|
|
333
333
|
}
|
|
334
334
|
}
|
|
335
335
|
|
|
336
|
+
// Quantity (e.g. group subscription seats) styles
|
|
337
|
+
.modal_checkout_quantity {
|
|
338
|
+
margin-bottom: var(--newspack-ui-spacer-5, 24px);
|
|
339
|
+
|
|
340
|
+
h3 {
|
|
341
|
+
color: var(--newspack-ui-label-color, inherit);
|
|
342
|
+
margin-bottom: var(--newspack-ui-spacer-base, 8px);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
.input-quantity {
|
|
346
|
+
align-items: center;
|
|
347
|
+
display: flex;
|
|
348
|
+
flex-wrap: nowrap;
|
|
349
|
+
gap: 0.5em;
|
|
350
|
+
margin: 0 0 8px;
|
|
351
|
+
|
|
352
|
+
input {
|
|
353
|
+
margin: 0;
|
|
354
|
+
width: 100%;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
button {
|
|
358
|
+
margin-top: 0;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
336
363
|
// Coupon Styles
|
|
337
364
|
.woocommerce-form-coupon {
|
|
338
365
|
margin-bottom: var(--newspack-ui-spacer-5, 24px);
|
|
@@ -360,6 +387,7 @@
|
|
|
360
387
|
|
|
361
388
|
// Processing styles
|
|
362
389
|
.modal_checkout_nyp,
|
|
390
|
+
.modal_checkout_quantity,
|
|
363
391
|
.modal_checkout_coupon {
|
|
364
392
|
&.modal-processing {
|
|
365
393
|
opacity: 0.5;
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve the form acted on by a modal checkout `donate` URL trigger.
|
|
3
|
+
*
|
|
4
|
+
* Resolution is side-effect free: no clicks, no input mutation. The caller
|
|
5
|
+
* performs the clicks/submission described by the returned resolution.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { FREQUENCY_SLUGS } from '../blocks/donate/frequency-slugs';
|
|
9
|
+
import { TIERS_BASED_READY_ATTRIBUTE } from '../shared/js/tiers-based-ready';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Layouts a donate URL trigger can request.
|
|
13
|
+
*
|
|
14
|
+
* @type {string[]}
|
|
15
|
+
*/
|
|
16
|
+
export const VALID_LAYOUTS = [ 'tiered', 'frequency', 'untiered' ];
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Donation frequency slugs a trigger can request.
|
|
20
|
+
*
|
|
21
|
+
* @type {string[]}
|
|
22
|
+
*/
|
|
23
|
+
export const VALID_FREQUENCIES = FREQUENCY_SLUGS;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Validate donate URL trigger parameters.
|
|
27
|
+
*
|
|
28
|
+
* The values are interpolated into attribute selectors, so anything outside
|
|
29
|
+
* the known closed sets (or a plain number) is rejected rather than escaped.
|
|
30
|
+
* The `other` amount only exists in the frequency layout.
|
|
31
|
+
*
|
|
32
|
+
* @param {string} layout The donation layout.
|
|
33
|
+
* @param {string} frequency The donation frequency.
|
|
34
|
+
* @param {string} amount The donation amount.
|
|
35
|
+
*
|
|
36
|
+
* @return {boolean} Whether the parameters are usable.
|
|
37
|
+
*/
|
|
38
|
+
export function validateDonationTriggerParams( layout, frequency, amount ) {
|
|
39
|
+
if ( ! VALID_LAYOUTS.includes( layout ) || ! VALID_FREQUENCIES.includes( frequency ) ) {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
if ( amount === 'other' ) {
|
|
43
|
+
return layout === 'frequency';
|
|
44
|
+
}
|
|
45
|
+
return /^\d+(\.\d+)?$/.test( amount );
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Resolve which donate form a URL trigger should act on, and how.
|
|
50
|
+
*
|
|
51
|
+
* The first form in DOM order that can fully satisfy the trigger wins; forms
|
|
52
|
+
* that cannot are skipped untouched. (Before this resolver existed, the last
|
|
53
|
+
* match won for the frequency/untiered layouts, so on a page with several
|
|
54
|
+
* matching Donate blocks a published link may now act on a different block
|
|
55
|
+
* than it used to — and land on that block's own after-success settings.)
|
|
56
|
+
* Tiered forms are matched against the
|
|
57
|
+
* server-rendered tier markup (`data-frequency-slug`/`data-amount` spans), so
|
|
58
|
+
* no tab needs to be clicked to find out whether the amount exists. A tiered
|
|
59
|
+
* block whose view script has not initialized yet (no `data-tiers-based-ready`
|
|
60
|
+
* on the container) cannot be acted on — clicking its tabs or tiers would do
|
|
61
|
+
* nothing — so the scan continues past it; `not-ready` (carrying the first
|
|
62
|
+
* such form) is returned only when no initialized form matched.
|
|
63
|
+
*
|
|
64
|
+
* @param {Document|HTMLElement} root The DOM root to search.
|
|
65
|
+
* @param {Object} params Trigger parameters.
|
|
66
|
+
* @param {string} params.layout The donation layout.
|
|
67
|
+
* @param {string} params.frequency The donation frequency.
|
|
68
|
+
* @param {string} params.amount The donation amount.
|
|
69
|
+
*
|
|
70
|
+
* @return {Object} Resolution object. `status` is one of `invalid-params`,
|
|
71
|
+
* `no-match`, `not-ready`, `tiered`, `frequency` or
|
|
72
|
+
* `untiered`. Tiered resolutions carry `form`, `tierIndex`
|
|
73
|
+
* and `frequencyButton` (null when the block renders a single
|
|
74
|
+
* frequency and no tab click is needed); frequency/untiered
|
|
75
|
+
* resolutions carry `form`, `frequencyInput` and `amountInput`.
|
|
76
|
+
*/
|
|
77
|
+
export function resolveDonationTrigger( root, { layout, frequency, amount } ) {
|
|
78
|
+
if ( ! validateDonationTriggerParams( layout, frequency, amount ) ) {
|
|
79
|
+
return { status: 'invalid-params' };
|
|
80
|
+
}
|
|
81
|
+
let notReadyForm = null;
|
|
82
|
+
for ( const form of root.querySelectorAll( '.wpbnbd.wpbnbd--platform-wc form' ) ) {
|
|
83
|
+
const tiersContainer = form.closest( '.wpbnbd--tiers-based' );
|
|
84
|
+
if ( layout === 'tiered' ) {
|
|
85
|
+
if ( ! tiersContainer ) {
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
const tierSpan = form.querySelector( `.wpbnbd__tiers__amount [data-frequency-slug="${ frequency }"][data-amount="${ amount }"]` );
|
|
89
|
+
if ( ! tierSpan ) {
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
const tierIndex = parseInt( tierSpan.getAttribute( 'data-tier-index' ) );
|
|
93
|
+
if ( isNaN( tierIndex ) ) {
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
// Blocks with a single enabled frequency render no tab buttons; the
|
|
97
|
+
// hidden frequency input then decides whether this form matches.
|
|
98
|
+
const frequencyButton = form.querySelector( `button[data-frequency-slug="${ frequency }"]` );
|
|
99
|
+
if ( ! frequencyButton && ! form.querySelector( `input[name="donation_frequency"][value="${ frequency }"]` ) ) {
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if ( ! tiersContainer.hasAttribute( TIERS_BASED_READY_ATTRIBUTE ) ) {
|
|
103
|
+
// Remember the first not-ready match but keep scanning: a later,
|
|
104
|
+
// already-initialized block that satisfies the trigger beats waiting
|
|
105
|
+
// on one that may never become ready (the view script bails without
|
|
106
|
+
// stamping readiness on config or markup failures).
|
|
107
|
+
if ( ! notReadyForm ) {
|
|
108
|
+
notReadyForm = form;
|
|
109
|
+
}
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
return { status: 'tiered', form, frequencyButton, tierIndex };
|
|
113
|
+
}
|
|
114
|
+
if ( tiersContainer ) {
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
const frequencyInput = form.querySelector( `input[name="donation_frequency"][value="${ frequency }"]` );
|
|
118
|
+
if ( ! frequencyInput ) {
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
const amountInput =
|
|
122
|
+
layout === 'untiered'
|
|
123
|
+
? form.querySelector( `input[name="donation_value_${ frequency }_untiered"]` )
|
|
124
|
+
: form.querySelector( `input[name="donation_value_${ frequency }"][value="${ amount }"]` );
|
|
125
|
+
if ( ! amountInput ) {
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
return { status: layout, form, frequencyInput, amountInput };
|
|
129
|
+
}
|
|
130
|
+
if ( notReadyForm ) {
|
|
131
|
+
return { status: 'not-ready', form: notReadyForm };
|
|
132
|
+
}
|
|
133
|
+
return { status: 'no-match' };
|
|
134
|
+
}
|