@automattic/newspack-blocks 4.26.4 → 4.26.5-epic-editor-refactor.1

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.
@@ -0,0 +1,266 @@
1
+ /**
2
+ * Tests for the checkout-button URL trigger resolution helpers.
3
+ */
4
+
5
+ import { readCheckoutData, findCheckoutButtonForm, selectPickerForm, resolveCheckoutButtonForm, copyContextFields } from './checkout-button-trigger';
6
+
7
+ const VARIATION_MODAL_CLASS_PREFIX = 'newspack-blocks__modal-variation';
8
+ const IFRAME_NAME = 'newspack_modal_checkout_iframe';
9
+
10
+ const PICKER_OPTIONS = {
11
+ variationModalClassPrefix: VARIATION_MODAL_CLASS_PREFIX,
12
+ iframeName: IFRAME_NAME,
13
+ };
14
+
15
+ /**
16
+ * Build a checkout button block markup string.
17
+ *
18
+ * @param {Object|null} checkoutData Object to JSON-encode into data-checkout, or null to omit it.
19
+ * @param {string} label Button label.
20
+ * @return {string} HTML.
21
+ */
22
+ const checkoutButton = ( checkoutData, label = 'Buy' ) => {
23
+ const attr = checkoutData ? ` data-checkout='${ JSON.stringify( checkoutData ) }'` : '';
24
+ return `<div class="wp-block-newspack-blocks-checkout-button"><form${ attr }><button type="submit">${ label }</button></form></div>`;
25
+ };
26
+
27
+ /**
28
+ * Build a variation picker modal, mirroring Subscriptions_Tiers::render_form output:
29
+ * a single form with `target` set, radio inputs named product_id, and no data-checkout.
30
+ *
31
+ * @param {string} productId Parent product id for the picker container.
32
+ * @param {string[]} radioIds Radio values (variation/child ids).
33
+ * @return {string} HTML.
34
+ */
35
+ const variationPicker = ( productId, radioIds ) => {
36
+ const radios = radioIds.map( id => `<input type="radio" name="product_id" value="${ id }">` ).join( '' );
37
+ return `<div class="${ VARIATION_MODAL_CLASS_PREFIX }" data-product-id="${ productId }"><form target="${ IFRAME_NAME }">${ radios }<button type="submit">Purchase</button></form></div>`;
38
+ };
39
+
40
+ const render = html => {
41
+ document.body.innerHTML = html;
42
+ return document.body;
43
+ };
44
+
45
+ afterEach( () => {
46
+ document.body.innerHTML = '';
47
+ } );
48
+
49
+ describe( 'readCheckoutData', () => {
50
+ it( 'parses a valid data-checkout attribute', () => {
51
+ const root = render( checkoutButton( { product_id: '1406', variation_id: '1408' } ) );
52
+ const form = root.querySelector( 'form' );
53
+ expect( readCheckoutData( form ) ).toEqual( { product_id: '1406', variation_id: '1408' } );
54
+ } );
55
+
56
+ it( 'returns null without throwing when the attribute is missing', () => {
57
+ const root = render( variationPicker( '1434', [ '158' ] ) );
58
+ const form = root.querySelector( 'form' );
59
+ expect( () => readCheckoutData( form ) ).not.toThrow();
60
+ expect( readCheckoutData( form ) ).toBeNull();
61
+ } );
62
+
63
+ it( 'returns null without throwing on malformed JSON', () => {
64
+ const root = render( '<div class="wp-block-newspack-blocks-checkout-button"><form data-checkout="not json">x</form></div>' );
65
+ const form = root.querySelector( 'form' );
66
+ expect( () => readCheckoutData( form ) ).not.toThrow();
67
+ expect( readCheckoutData( form ) ).toBeNull();
68
+ } );
69
+
70
+ it( 'returns null for a null form', () => {
71
+ expect( readCheckoutData( null ) ).toBeNull();
72
+ } );
73
+ } );
74
+
75
+ describe( 'findCheckoutButtonForm', () => {
76
+ it( 'requires both product_id and variation_id to match when a variation is requested', () => {
77
+ const root = render( checkoutButton( { product_id: '1406', variation_id: '1408', is_variable: true } ) );
78
+ const form = root.querySelector( 'form' );
79
+ expect( findCheckoutButtonForm( root, '1406', '1408' ) ).toBe( form );
80
+ } );
81
+
82
+ it( 'does NOT match a locked button for a different requested variation', () => {
83
+ const root = render( checkoutButton( { product_id: '1406', variation_id: '1408', is_variable: true } ) );
84
+ // Request 1407 while only the 1408-locked button exists.
85
+ expect( findCheckoutButtonForm( root, '1406', '1407' ) ).toBeNull();
86
+ } );
87
+
88
+ it( 'matches by product_id only when no variation is requested', () => {
89
+ const root = render( checkoutButton( { product_id: '1406', variation_id: '1408', is_variable: true } ) );
90
+ const form = root.querySelector( 'form' );
91
+ expect( findCheckoutButtonForm( root, '1406', null ) ).toBe( form );
92
+ } );
93
+
94
+ it( 'does not match a grouped button (no variation_id) for a variation request', () => {
95
+ const root = render( checkoutButton( { product_id: '1434' }, 'Buy grouped' ) );
96
+ expect( findCheckoutButtonForm( root, '1434', '158' ) ).toBeNull();
97
+ } );
98
+
99
+ it( 'skips forms with missing or invalid data-checkout without throwing', () => {
100
+ const root = render( variationPicker( '1406', [ '1408' ] ) + checkoutButton( { product_id: '1406', variation_id: '1408' } ) );
101
+ expect( () => findCheckoutButtonForm( root, '1406', '1408' ) ).not.toThrow();
102
+ expect( findCheckoutButtonForm( root, '1406', '1408' ) ).not.toBeNull();
103
+ } );
104
+ } );
105
+
106
+ describe( 'selectPickerForm', () => {
107
+ it( 'checks the radio matching the variation and returns the picker form', () => {
108
+ const root = render( variationPicker( '1406', [ '1407', '1408', '1409' ] ) );
109
+ const form = selectPickerForm( root, '1406', '1407', PICKER_OPTIONS );
110
+ expect( form ).toBe( root.querySelector( `.${ VARIATION_MODAL_CLASS_PREFIX } form` ) );
111
+ expect( root.querySelector( 'input[value="1407"]' ).checked ).toBe( true );
112
+ expect( root.querySelector( 'input[value="1408"]' ).checked ).toBe( false );
113
+ } );
114
+
115
+ it( 'returns null when no picker exists for the product', () => {
116
+ const root = render( variationPicker( '1434', [ '158' ] ) );
117
+ expect( selectPickerForm( root, '1406', '1407', PICKER_OPTIONS ) ).toBeNull();
118
+ } );
119
+
120
+ it( 'returns null when no radio matches the variation', () => {
121
+ const root = render( variationPicker( '1406', [ '1407', '1409' ] ) );
122
+ expect( selectPickerForm( root, '1406', '1408', PICKER_OPTIONS ) ).toBeNull();
123
+ } );
124
+
125
+ it( 'only selects radio inputs inside the checkout iframe form', () => {
126
+ const root = render(
127
+ `<div class="${ VARIATION_MODAL_CLASS_PREFIX }" data-product-id="1406">` +
128
+ '<form target="other_iframe"><input type="radio" name="product_id" value="1407"></form>' +
129
+ `<form target="${ IFRAME_NAME }">` +
130
+ '<input type="hidden" name="product_id" value="1407">' +
131
+ '<input type="radio" name="product_id" value="1408">' +
132
+ '</form>' +
133
+ '</div>'
134
+ );
135
+ const checkoutForm = root.querySelector( `form[target="${ IFRAME_NAME }"]` );
136
+
137
+ expect( selectPickerForm( root, '1406', '1407', PICKER_OPTIONS ) ).toBeNull();
138
+ expect( selectPickerForm( root, '1406', '1408', PICKER_OPTIONS ) ).toBe( checkoutForm );
139
+ expect( checkoutForm.querySelector( 'input[type="radio"][value="1408"]' ).checked ).toBe( true );
140
+ } );
141
+ } );
142
+
143
+ describe( 'resolveCheckoutButtonForm', () => {
144
+ it( 'returns the picker form for a non-locked variation rather than the locked checkout button', () => {
145
+ const root = render(
146
+ checkoutButton( { product_id: '1406', variation_id: '1408', is_variable: true }, 'Subscribe' ) +
147
+ variationPicker( '1406', [ '1407', '1408', '1409' ] )
148
+ );
149
+ const pickerForm = root.querySelector( `.${ VARIATION_MODAL_CLASS_PREFIX } form` );
150
+ const result = resolveCheckoutButtonForm( root, '1406', '1407', PICKER_OPTIONS );
151
+ expect( result ).toBe( pickerForm );
152
+ expect( root.querySelector( 'input[value="1407"]' ).checked ).toBe( true );
153
+ } );
154
+
155
+ it( 'returns the exact checkout button form when the requested variation is the locked one', () => {
156
+ const root = render(
157
+ checkoutButton( { product_id: '1406', variation_id: '1408', is_variable: true }, 'Subscribe' ) +
158
+ variationPicker( '1406', [ '1407', '1408', '1409' ] )
159
+ );
160
+ const buttonForm = root.querySelector( '.wp-block-newspack-blocks-checkout-button form' );
161
+ expect( resolveCheckoutButtonForm( root, '1406', '1408', PICKER_OPTIONS ) ).toBe( buttonForm );
162
+ } );
163
+
164
+ it( 'drives the grouped picker without throwing on its data-checkout-less form', () => {
165
+ const root = render( checkoutButton( { product_id: '1434' }, 'Buy grouped' ) + variationPicker( '1434', [ '158' ] ) );
166
+ const pickerForm = root.querySelector( `.${ VARIATION_MODAL_CLASS_PREFIX } form` );
167
+ let result;
168
+ expect( () => {
169
+ result = resolveCheckoutButtonForm( root, '1434', '158', PICKER_OPTIONS );
170
+ } ).not.toThrow();
171
+ expect( result ).toBe( pickerForm );
172
+ expect( root.querySelector( 'input[value="158"]' ).checked ).toBe( true );
173
+ } );
174
+
175
+ it( 'returns null for an invalid variation when product-only fallback is off (default)', () => {
176
+ const root = render( checkoutButton( { product_id: '158' }, 'Checkout' ) );
177
+ expect( resolveCheckoutButtonForm( root, '158', '160', PICKER_OPTIONS ) ).toBeNull();
178
+ } );
179
+
180
+ it( 'treats a variation_id equal to product_id as a strict variation request', () => {
181
+ const root = render( checkoutButton( { product_id: '158' }, 'Checkout' ) );
182
+ expect( resolveCheckoutButtonForm( root, '158', '158', PICKER_OPTIONS ) ).toBeNull();
183
+ } );
184
+
185
+ it( 'returns the product-only button for an invalid variation only when fallback is explicitly enabled', () => {
186
+ const root = render( checkoutButton( { product_id: '158' }, 'Checkout' ) );
187
+ const buttonForm = root.querySelector( 'form' );
188
+ expect( resolveCheckoutButtonForm( root, '158', '160', { ...PICKER_OPTIONS, allowProductOnlyFallback: true } ) ).toBe( buttonForm );
189
+ } );
190
+
191
+ it( 'matches a checkout button by product_id when no variation is requested', () => {
192
+ const root = render( checkoutButton( { product_id: '1406', variation_id: '1408', is_variable: true } ) );
193
+ const buttonForm = root.querySelector( 'form' );
194
+ expect( resolveCheckoutButtonForm( root, '1406', null, PICKER_OPTIONS ) ).toBe( buttonForm );
195
+ } );
196
+
197
+ it( 'returns null without throwing when nothing matches', () => {
198
+ const root = render( checkoutButton( { product_id: '999' } ) );
199
+ let result;
200
+ expect( () => {
201
+ result = resolveCheckoutButtonForm( root, '1406', '1407', PICKER_OPTIONS );
202
+ } ).not.toThrow();
203
+ expect( result ).toBeNull();
204
+ } );
205
+
206
+ it( 'copies block context from the source button into the picker form without submitting the locked button', () => {
207
+ const button = `<div class="wp-block-newspack-blocks-checkout-button"><form data-checkout='${ JSON.stringify( {
208
+ product_id: '1406',
209
+ variation_id: '1408',
210
+ is_variable: true,
211
+ } ) }'><input type="hidden" name="after_success_button_label" value="Thanks!"><input type="hidden" name="after_success_url" value="/welcome/"><button type="submit">Subscribe</button></form></div>`;
212
+ const root = render( button + variationPicker( '1406', [ '1407', '1408', '1409' ] ) );
213
+ const pickerForm = root.querySelector( `.${ VARIATION_MODAL_CLASS_PREFIX } form` );
214
+ const buttonForm = root.querySelector( '.wp-block-newspack-blocks-checkout-button form' );
215
+
216
+ const result = resolveCheckoutButtonForm( root, '1406', '1407', PICKER_OPTIONS );
217
+
218
+ expect( result ).toBe( pickerForm );
219
+ expect( result ).not.toBe( buttonForm );
220
+ expect( pickerForm.querySelector( 'input[name="after_success_button_label"]' ).value ).toBe( 'Thanks!' );
221
+ expect( pickerForm.querySelector( 'input[name="after_success_url"]' ).value ).toBe( '/welcome/' );
222
+ } );
223
+ } );
224
+
225
+ describe( 'copyContextFields', () => {
226
+ it( 'copies present source fields, skips missing ones, and does not overwrite existing target fields', () => {
227
+ const root = render(
228
+ `<form id="src"><input type="hidden" name="after_success_url" value="/welcome/"><input type="hidden" name="prompt_title" value="Join"></form>` +
229
+ `<form id="dst"><input type="hidden" name="prompt_title" value="Existing"></form>`
230
+ );
231
+ const source = root.querySelector( '#src' );
232
+ const target = root.querySelector( '#dst' );
233
+
234
+ copyContextFields( source, target );
235
+
236
+ // Copied from source.
237
+ expect( target.querySelector( 'input[name="after_success_url"]' ).value ).toBe( '/welcome/' );
238
+ // Not overwritten.
239
+ expect( target.querySelector( 'input[name="prompt_title"]' ).value ).toBe( 'Existing' );
240
+ // Missing on source -> not added.
241
+ expect( target.querySelector( 'input[name="gate_post_id"]' ) ).toBeNull();
242
+ } );
243
+
244
+ it( 'copies the last source value when a context field is duplicated', () => {
245
+ const root = render(
246
+ '<form id="src">' +
247
+ '<input type="hidden" name="after_success_url" value="/first/">' +
248
+ '<input type="hidden" name="after_success_url" value="/last/">' +
249
+ '</form>' +
250
+ '<form id="dst"></form>'
251
+ );
252
+ const source = root.querySelector( '#src' );
253
+ const target = root.querySelector( '#dst' );
254
+
255
+ copyContextFields( source, target );
256
+
257
+ expect( target.querySelector( 'input[name="after_success_url"]' ).value ).toBe( '/last/' );
258
+ } );
259
+
260
+ it( 'does not throw when source or target is null', () => {
261
+ const root = render( '<form id="dst"></form>' );
262
+ const target = root.querySelector( '#dst' );
263
+ expect( () => copyContextFields( null, target ) ).not.toThrow();
264
+ expect( () => copyContextFields( target, null ) ).not.toThrow();
265
+ } );
266
+ } );
@@ -24,6 +24,7 @@ import {
24
24
  getCheckoutData,
25
25
  getFormattedAmount,
26
26
  } from './utils';
27
+ import { resolveCheckoutButtonForm, readCheckoutData } from './checkout-button-trigger';
27
28
 
28
29
  const CLASS_PREFIX = newspackBlocksModal.newspack_class_prefix;
29
30
  const IFRAME_NAME = 'newspack_modal_checkout_iframe';
@@ -376,9 +377,8 @@ domReady( () => {
376
377
  } );
377
378
 
378
379
  // Append the product data hidden inputs.
379
- const variationData = singleVariationForm.dataset.checkout;
380
- if ( variationData ) {
381
- const data = JSON.parse( variationData );
380
+ const data = readCheckoutData( singleVariationForm );
381
+ if ( data ) {
382
382
  Object.keys( data ).forEach( key => {
383
383
  const existingInputs = singleVariationForm.querySelectorAll( 'input[name="' + key + '"]' );
384
384
  if ( 0 === existingInputs.length ) {
@@ -799,41 +799,29 @@ domReady( () => {
799
799
  };
800
800
 
801
801
  /**
802
- * Handle checkout button form triggers.
802
+ * Handle checkout button URL triggers.
803
803
  *
804
- * @param {number} productId The product ID.
805
- * @param {number|null} variationId Optional. The variation ID.
804
+ * @param {string} productId The product ID.
805
+ * @param {string|null} variationId Optional. The variation ID.
806
+ *
807
+ * @return {boolean} Whether a matching form was submitted.
806
808
  */
807
809
  const triggerCheckoutButtonForm = ( productId, variationId = null ) => {
808
- let form;
809
- if ( variationId && variationId !== productId ) {
810
- const variationModals = document.querySelectorAll( `.${ VARIATON_MODAL_CLASS_PREFIX }` );
811
- const variationModal = [ ...variationModals ].find( modal => modal.dataset.productId === productId );
812
- if ( variationModal ) {
813
- const forms = variationModal.querySelectorAll( `form[target="${ IFRAME_NAME }"]` );
814
- forms.forEach( variationForm => {
815
- const productData = JSON.parse( variationForm.dataset.checkout );
816
- if ( productData?.variation_id === Number( variationId ) ) {
817
- form = variationForm;
818
- }
819
- } );
820
- }
821
- } else {
822
- const checkoutButtons = document.querySelectorAll( '.wp-block-newspack-blocks-checkout-button' );
823
- checkoutButtons.forEach( button => {
824
- const checkoutButtonForm = button.querySelector( 'form' );
825
- if ( ! checkoutButtonForm ) {
826
- return;
827
- }
828
- const productData = JSON.parse( checkoutButtonForm.dataset.checkout );
829
- if ( productData?.product_id === productId ) {
830
- form = checkoutButtonForm;
831
- }
832
- } );
833
- }
810
+ const form = resolveCheckoutButtonForm( document, productId, variationId, {
811
+ variationModalClassPrefix: VARIATON_MODAL_CLASS_PREFIX,
812
+ iframeName: IFRAME_NAME,
813
+ } );
834
814
  if ( form ) {
835
815
  triggerFormSubmit( form );
836
- }
816
+ return true;
817
+ }
818
+ const message =
819
+ `Newspack modal checkout: no checkout form found for product_id "${ productId }"` +
820
+ ( variationId ? ` and variation_id "${ variationId }"` : '' ) +
821
+ '. The checkout was not triggered.';
822
+ // eslint-disable-next-line no-console
823
+ console.warn( message );
824
+ return false;
837
825
  };
838
826
 
839
827
  /**
@@ -844,6 +832,10 @@ domReady( () => {
844
832
  if ( ! urlParams.has( 'checkout' ) ) {
845
833
  return;
846
834
  }
835
+ // Default to stripping the params after handling. The checkout button
836
+ // trigger overrides this so a link that matches no form stays visible
837
+ // and diagnosable rather than being silently dropped.
838
+ let shouldStripParams = true;
847
839
  const type = urlParams.get( 'type' );
848
840
  if ( type === 'donate' ) {
849
841
  const layout = urlParams.get( 'layout' );
@@ -857,7 +849,13 @@ domReady( () => {
857
849
  const productId = urlParams.get( 'product_id' );
858
850
  const variationId = urlParams.get( 'variation_id' );
859
851
  if ( productId ) {
860
- triggerCheckoutButtonForm( productId, variationId );
852
+ shouldStripParams = triggerCheckoutButtonForm( productId, variationId );
853
+ } else {
854
+ // A checkout_button trigger with no product_id cannot resolve a
855
+ // form; keep the params visible rather than dropping them silently.
856
+ shouldStripParams = false;
857
+ // eslint-disable-next-line no-console
858
+ console.warn( 'Newspack modal checkout: checkout_button trigger is missing product_id. The checkout was not triggered.' );
861
859
  }
862
860
  } else {
863
861
  const url = window.newspackReaderActivation?.getPendingCheckout?.();
@@ -866,8 +864,11 @@ domReady( () => {
866
864
  triggerFormSubmit( form );
867
865
  }
868
866
  }
869
- // Remove the URL param to prevent re-triggering.
870
- window.history.replaceState( null, null, window.location.pathname );
867
+ // Remove the URL params to prevent re-triggering, but only when the
868
+ // trigger succeeded.
869
+ if ( shouldStripParams ) {
870
+ window.history.replaceState( null, null, window.location.pathname );
871
+ }
871
872
  };
872
873
  handleModalCheckoutUrlParams();
873
874
 
@@ -3,7 +3,7 @@
3
3
  'name' => 'automattic/newspack-blocks',
4
4
  'pretty_version' => 'dev-main',
5
5
  'version' => 'dev-main',
6
- 'reference' => 'e6af2c45db24228ad7eca429986c1b42e24605f9',
6
+ 'reference' => 'd0609e32c676aa8d4a56dc527f306cc8d4f482a7',
7
7
  'type' => 'wordpress-plugin',
8
8
  'install_path' => __DIR__ . '/../../',
9
9
  'aliases' => array(),
@@ -13,7 +13,7 @@
13
13
  'automattic/newspack-blocks' => array(
14
14
  'pretty_version' => 'dev-main',
15
15
  'version' => 'dev-main',
16
- 'reference' => 'e6af2c45db24228ad7eca429986c1b42e24605f9',
16
+ 'reference' => 'd0609e32c676aa8d4a56dc527f306cc8d4f482a7',
17
17
  'type' => 'wordpress-plugin',
18
18
  'install_path' => __DIR__ . '/../../',
19
19
  'aliases' => array(),
package/phpcs.xml DELETED
@@ -1,38 +0,0 @@
1
- <?xml version="1.0"?>
2
- <ruleset name="WordPress Coding Standards for Plugins">
3
- <description>Generally-applicable sniffs for WordPress plugins</description>
4
-
5
- <rule ref="WordPress-Extra" />
6
- <rule ref="WordPress-Docs" />
7
- <rule ref="WordPress-VIP-Go" />
8
-
9
- <rule ref="WordPress">
10
- <exclude name="Generic.Arrays.DisallowShortArraySyntax.Found" />
11
- <exclude name="Universal.Arrays.DisallowShortArraySyntax.Found" />
12
- <exclude name="Universal.Operators.DisallowStandalonePostIncrementDecrement.PostIncrementFound" />
13
- <exclude name="Squiz.Functions.MultiLineFunctionDeclaration.SpaceAfterFunction" />
14
- <exclude name="WordPress.PHP.YodaConditions.NotYoda" />
15
- <exclude name="Generic.Formatting.MultipleStatementAlignment.NotSameWarning" />
16
- <exclude name="Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed" />
17
- <exclude name="Universal.NamingConventions.NoReservedKeywordParameterNames" />
18
- <exclude name="Generic.CodeAnalysis.UnusedFunctionParameter.Found" />
19
- </rule>
20
-
21
- <rule ref="PHPCompatibilityWP"/>
22
- <config name="testVersion" value="7.2-"/>
23
-
24
- <arg name="extensions" value="php"/>
25
-
26
- <!-- Show sniff codes in all reports -->
27
- <arg value="s"/>
28
-
29
- <!-- Allow invoking just `phpcs` on command line without assuming STDIN for file input. -->
30
- <file>.</file>
31
-
32
- <exclude-pattern>*/dev-lib/*</exclude-pattern>
33
- <exclude-pattern>*/node_modules/*</exclude-pattern>
34
- <exclude-pattern>*/vendor/*</exclude-pattern>
35
- <exclude-pattern>*/dist/*</exclude-pattern>
36
- <exclude-pattern>*/release/*</exclude-pattern>
37
- <exclude-pattern>*/tests/*</exclude-pattern>
38
- </ruleset>