@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
@@ -7,7 +7,7 @@
7
7
  * Author URI: https://newspack.com/
8
8
  * Text Domain: newspack-blocks
9
9
  * Domain Path: /languages
10
- * Version: 4.31.1
10
+ * Version: 4.32.0-alpha.2
11
11
  *
12
12
  * @package Newspack_Blocks
13
13
  */
@@ -15,7 +15,7 @@
15
15
  define( 'NEWSPACK_BLOCKS__PLUGIN_FILE', __FILE__ );
16
16
  define( 'NEWSPACK_BLOCKS__BLOCKS_DIRECTORY', 'dist/' );
17
17
  define( 'NEWSPACK_BLOCKS__PLUGIN_DIR', plugin_dir_path( NEWSPACK_BLOCKS__PLUGIN_FILE ) );
18
- define( 'NEWSPACK_BLOCKS__VERSION', '4.31.1' );
18
+ define( 'NEWSPACK_BLOCKS__VERSION', '4.32.0-alpha.2' );
19
19
 
20
20
  require_once NEWSPACK_BLOCKS__PLUGIN_DIR . 'includes/class-newspack-blocks.php';
21
21
  require_once NEWSPACK_BLOCKS__PLUGIN_DIR . 'includes/class-newspack-blocks-api.php';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@automattic/newspack-blocks",
3
- "version": "4.31.1",
3
+ "version": "4.32.0-alpha.2",
4
4
  "author": "Automattic",
5
5
  "description": "=== Newspack Blocks === Contributors: (this should be a list of wordpress.org userid's) Donate link: https://example.com/ Tags: comments, spam Requires at least: 4.5 Tested up to: 5.1.1 Stable tag: 0.1.0 License: GPLv2 or later License URI: https://www.gnu.org/licenses/gpl-2.0.html",
6
6
  "repository": {
@@ -45,6 +45,7 @@
45
45
  "extends @wordpress/browserslist-config"
46
46
  ],
47
47
  "dependencies": {
48
+ "@wordpress/ui": "^0.16.0",
48
49
  "classnames": "^2.5.1",
49
50
  "lodash": "^4.17.21",
50
51
  "newspack-colors": "1.1.4",
@@ -56,7 +57,7 @@
56
57
  },
57
58
  "devDependencies": {
58
59
  "@types/lodash": "^4.17.23",
59
- "newspack-components": "4.7.0",
60
+ "newspack-components": "4.8.0-alpha.1",
60
61
  "newspack-scripts": "5.11.0"
61
62
  },
62
63
  "overrides": {
@@ -413,7 +413,7 @@ class Edit extends Component {
413
413
  </div>
414
414
  ) }
415
415
  { showTagLabels && post.newspack_tag_labels && (
416
- <div className="cat-links tag-labels">
416
+ <div className="tag-labels">
417
417
  { post.newspack_tag_labels.map( ( newspack_tag_label, index ) => {
418
418
  return newspack_tag_label.link ? (
419
419
  <a key={ index } href="#" className="tag-label flag">
@@ -263,7 +263,11 @@
263
263
  margin-right: 1.5em;
264
264
  }
265
265
  }
266
- .cat-links {
266
+ // One source for the carousel's chip styling, shared so the two stay in step.
267
+ // A tag label carries only `tag-labels`, so a publisher rule written for
268
+ // `.cat-links a` cannot reach it.
269
+ .cat-links,
270
+ .tag-labels {
267
271
  color: np-colors.$neutral-000;
268
272
  font-size: 0.6em;
269
273
  font-weight: bold;
@@ -56,6 +56,9 @@
56
56
  },
57
57
  "coupon": {
58
58
  "type": "string"
59
+ },
60
+ "quantity": {
61
+ "type": "number"
59
62
  }
60
63
  },
61
64
  "supports": {
@@ -207,11 +207,17 @@ function ProductControl( props ) {
207
207
 
208
208
  function CheckoutButtonEdit( props ) {
209
209
  const { attributes, setAttributes, className } = props;
210
- const { placeholder, style, text, product, price, variation, width, coupon } = attributes;
210
+ const { placeholder, style, text, product, price, variation, width, coupon, quantity } = attributes;
211
211
 
212
212
  const [ productData, setProductData ] = useState( {} );
213
213
  const [ variations, setVariations ] = useState( [] );
214
214
  const [ nyp, setNYP ] = useState( false );
215
+ // Resolved server-side, like the donation flag: a seat count is only honoured
216
+ // for a product sold per seat, so offering the control anywhere else would
217
+ // invite a number that is discarded at checkout. Per-seat meta lives on the
218
+ // variation for a tiered plan, hence the separate state for the chosen one.
219
+ const [ variationHasSeats, setVariationHasSeats ] = useState( false );
220
+ const hasSeats = variation ? variationHasSeats : !! productData?.newspack_has_seats;
215
221
 
216
222
  // Resolved server-side, since a donation is not always identifiable from the
217
223
  // product meta. Variations inherit it from their parent, which is the product
@@ -249,10 +255,17 @@ function CheckoutButtonEdit( props ) {
249
255
  useEffect( () => {
250
256
  if ( variation ) {
251
257
  apiFetch( { path: `/wc/v2/products/${ product }/variations/${ variation }` } )
252
- .then( res => setNYP( getNYP( res ) ) )
253
- .catch( () => setNYP( {} ) );
258
+ .then( res => {
259
+ setNYP( getNYP( res ) );
260
+ setVariationHasSeats( !! res?.newspack_has_seats );
261
+ } )
262
+ .catch( () => {
263
+ setNYP( {} );
264
+ setVariationHasSeats( false );
265
+ } );
254
266
  } else {
255
267
  setNYP( getNYP( productData ) );
268
+ setVariationHasSeats( false );
256
269
  }
257
270
  }, [ variation ] );
258
271
 
@@ -312,7 +325,7 @@ function CheckoutButtonEdit( props ) {
312
325
  <ProductControl
313
326
  value={ product }
314
327
  price={ price }
315
- onChange={ value => setAttributes( { product: value, variation: '', price: '' } ) }
328
+ onChange={ value => setAttributes( { product: value, variation: '', price: '', quantity: undefined } ) }
316
329
  onProduct={ handleProduct }
317
330
  >
318
331
  { productData?.variations?.length > 0 && (
@@ -324,6 +337,7 @@ function CheckoutButtonEdit( props ) {
324
337
  setAttributes( {
325
338
  variation: value ? '' : variations[ 0 ].id.toString(),
326
339
  price: '',
340
+ quantity: undefined,
327
341
  } )
328
342
  }
329
343
  />
@@ -340,7 +354,7 @@ function CheckoutButtonEdit( props ) {
340
354
  value: item.id,
341
355
  } ) ),
342
356
  ] }
343
- onChange={ value => setAttributes( { variation: value.toString(), price: '' } ) }
357
+ onChange={ value => setAttributes( { variation: value.toString(), price: '', quantity: undefined } ) }
344
358
  />
345
359
  ) : (
346
360
  <Spinner />
@@ -410,6 +424,19 @@ function CheckoutButtonEdit( props ) {
410
424
  />
411
425
  </PanelBody>
412
426
  ) }
427
+ { hasSeats && (
428
+ <PanelBody title={ __( 'Seats', 'newspack-blocks' ) } initialOpen={ false }>
429
+ <p>{ __( 'Optional. Pre-fill the number of seats. Readers can change it before paying.', 'newspack-blocks' ) }</p>
430
+ <TextControl
431
+ type="number"
432
+ label={ __( 'Default seats', 'newspack-blocks' ) }
433
+ value={ quantity || '' }
434
+ min={ 1 }
435
+ onChange={ value => setAttributes( { quantity: value ? parseInt( value, 10 ) : undefined } ) }
436
+ __next40pxDefaultSize
437
+ />
438
+ </PanelBody>
439
+ ) }
413
440
  </InspectorControls>
414
441
  </>
415
442
  );
@@ -56,6 +56,37 @@ function register_donation_rest_field() {
56
56
  }
57
57
  add_action( 'rest_api_init', __NAMESPACE__ . '\\register_donation_rest_field' );
58
58
 
59
+ /**
60
+ * Expose whether a product is sold per seat on the products REST response.
61
+ *
62
+ * The block editor needs this to decide whether to offer a default seat count:
63
+ * a quantity on any other product is discarded at checkout, so a control for one
64
+ * would invite the publisher to set a number that never takes effect.
65
+ */
66
+ function register_seats_rest_field() {
67
+ if ( ! class_exists( '\Newspack\Group_Subscription_Seats' ) || ! method_exists( '\Newspack\Group_Subscription_Seats', 'get_field_args' ) ) {
68
+ return;
69
+ }
70
+ // Both object types: per-seat meta lives on the variation for a tiered plan, and
71
+ // the editor fetches the chosen variation separately from its parent.
72
+ register_rest_field(
73
+ [ 'product', 'product_variation' ],
74
+ 'newspack_has_seats',
75
+ [
76
+ 'get_callback' => function ( $product ) {
77
+ return ! empty( \Newspack\Group_Subscription_Seats::get_field_args( $product['id'] ) );
78
+ },
79
+ 'schema' => [
80
+ 'description' => __( 'Whether the product is sold per seat, so a seat count can be chosen for it.', 'newspack-blocks' ),
81
+ 'type' => 'boolean',
82
+ 'context' => [ 'view', 'edit' ],
83
+ 'readonly' => true,
84
+ ],
85
+ ]
86
+ );
87
+ }
88
+ add_action( 'rest_api_init', __NAMESPACE__ . '\\register_seats_rest_field' );
89
+
59
90
  /**
60
91
  * Render the block.
61
92
  *
@@ -95,6 +126,7 @@ function render_callback( $attributes ) {
95
126
  $after_success_button_label = $attributes['afterSuccessButtonLabel'] ?? '';
96
127
  $after_success_url = $attributes['afterSuccessURL'] ?? '';
97
128
  $coupon = $attributes['coupon'] ?? '';
129
+ $quantity = absint( $attributes['quantity'] ?? 0 );
98
130
  $is_variable = $attributes['is_variable'];
99
131
 
100
132
  if ( $is_variable && $variation_id ) {
@@ -157,6 +189,11 @@ function render_callback( $attributes ) {
157
189
  if ( '' !== $coupon ) {
158
190
  $hidden_fields .= '<input type="hidden" name="coupon" value="' . esc_attr( $coupon ) . '" />';
159
191
  }
192
+ // Only emitted above 1: the server already defaults to a single seat, and a
193
+ // reader-editable field inside the modal wins over this default anyway.
194
+ if ( $quantity > 1 ) {
195
+ $hidden_fields .= '<input type="hidden" name="quantity" value="' . esc_attr( $quantity ) . '" />';
196
+ }
160
197
 
161
198
  ob_start();
162
199
  /**
@@ -7,6 +7,7 @@ import { __ } from '@wordpress/i18n';
7
7
  * Internal dependencies
8
8
  */
9
9
  import type { DonationFrequencySlug, DonateBlockAttributes } from './types';
10
+ import { FREQUENCY_SLUGS as DONATE_FREQUENCY_SLUGS } from './frequency-slugs';
10
11
 
11
12
  export const FREQUENCIES: Record< DonationFrequencySlug, string > = {
12
13
  once: __( 'One-time', 'newspack-blocks' ),
@@ -14,7 +15,7 @@ export const FREQUENCIES: Record< DonationFrequencySlug, string > = {
14
15
  year: __( 'Annually', 'newspack-blocks' ),
15
16
  };
16
17
 
17
- export const FREQUENCY_SLUGS: DonationFrequencySlug[] = Object.keys( FREQUENCIES ) as DonationFrequencySlug[];
18
+ export const FREQUENCY_SLUGS = DONATE_FREQUENCY_SLUGS as DonationFrequencySlug[];
18
19
 
19
20
  export const LAYOUT_OPTIONS: { label: string; key: DonateBlockAttributes[ 'layoutOption' ] }[] = [
20
21
  { label: __( 'Frequency', 'newspack-blocks' ), key: 'frequency' },
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Donation frequency slugs, on their own so any bundle can consume them:
3
+ * consts.ts attaches translated labels (pulling in @wordpress/i18n at module
4
+ * scope), which the modal checkout's donate-trigger resolver must not drag
5
+ * into its bundles just to validate trigger params. The PHP renderers
6
+ * hardcode the same set (class-newspack-blocks-donate-renderer-base.php).
7
+ *
8
+ * @type {string[]}
9
+ */
10
+ export const FREQUENCY_SLUGS = [ 'once', 'month', 'year' ];
@@ -1,5 +1,6 @@
1
1
  import { setupSlider } from './slider';
2
2
  import { parseTiersBasedConfig } from './utils';
3
+ import { TIERS_BASED_READY_EVENT, TIERS_BASED_READY_ATTRIBUTE } from '../../../shared/js/tiers-based-ready';
3
4
 
4
5
  const BUTTON_ACTIVE_CLASSNAME = 'wpbnbd__button--active';
5
6
 
@@ -129,4 +130,11 @@ export default ( parentEl: HTMLElement ) => {
129
130
  backButton.style.display = 'none';
130
131
  }
131
132
  } );
133
+
134
+ // Mark the block as ready for programmatic triggering (see
135
+ // src/modal-checkout/donate-trigger.js): the listeners above are attached,
136
+ // so a tab click now synchronously updates the tier submit buttons. The
137
+ // event lets a URL trigger that ran before this init retry immediately.
138
+ parentEl.setAttribute( TIERS_BASED_READY_ATTRIBUTE, '' );
139
+ parentEl.dispatchEvent( new CustomEvent( TIERS_BASED_READY_EVENT, { bubbles: true } ) );
132
140
  };
@@ -115,7 +115,7 @@ class Edit extends Component< HomepageArticlesProps > {
115
115
  </div>
116
116
  ) }{ ' ' }
117
117
  { showTagLabels && post.newspack_tag_labels && (
118
- <div className="cat-links tag-labels">
118
+ <div className="tag-labels">
119
119
  { post.newspack_tag_labels.map( ( newspack_tag_label, index ) => {
120
120
  return newspack_tag_label.link ? (
121
121
  <a key={ index } href="#" className="tag-label flag">
@@ -39,7 +39,8 @@
39
39
  }
40
40
  }
41
41
 
42
- .cat-links {
42
+ .cat-links,
43
+ .tag-labels {
43
44
  font-size: variables.$font__size-xs;
44
45
  }
45
46
 
@@ -271,6 +271,7 @@
271
271
  .cat-links,
272
272
  .cat-links a,
273
273
  .cat-links a:visited,
274
+ .tag-labels,
274
275
  figcaption {
275
276
  color: inherit;
276
277
  }
@@ -429,7 +430,8 @@
429
430
  &.has-text-align-center {
430
431
  .cat-links,
431
432
  .entry-meta,
432
- .entry-sponsors {
433
+ .entry-sponsors,
434
+ .tag-labels {
433
435
  justify-content: center;
434
436
  }
435
437
 
@@ -444,7 +446,8 @@
444
446
  .cat-links,
445
447
  .entry-meta,
446
448
  .entry-sponsors,
447
- .sponsor-logos {
449
+ .sponsor-logos,
450
+ .tag-labels {
448
451
  justify-content: flex-end;
449
452
  text-align: right;
450
453
  }
@@ -463,7 +466,12 @@
463
466
  }
464
467
 
465
468
  /* Article meta */
466
- .cat-links {
469
+ // `.tag-labels` shares this rule rather than repeating it, so an edit to one
470
+ // cannot silently diverge from the other. The wrapper carries only
471
+ // `tag-labels`, which is what keeps a publisher's per-section `.cat-links a`
472
+ // rule from reaching a tag label; sharing layout here does not give that back.
473
+ .cat-links,
474
+ .tag-labels {
467
475
  display: flex;
468
476
  flex-wrap: wrap;
469
477
  font-size: variables.$font__size-xxs;
@@ -516,7 +524,8 @@
516
524
  .cat-links,
517
525
  .entry-meta,
518
526
  .entry-sponsors,
519
- .sponsor-logos {
527
+ .sponsor-logos,
528
+ .tag-labels {
520
529
  justify-content: flex-start;
521
530
  }
522
531
  }
@@ -525,7 +534,8 @@
525
534
  .cat-links,
526
535
  .entry-meta,
527
536
  .entry-sponsors,
528
- .sponsor-logos {
537
+ .sponsor-logos,
538
+ .tag-labels {
529
539
  justify-content: flex-end;
530
540
  }
531
541
  }
@@ -45,15 +45,29 @@ class AutocompleteTokenField extends Component {
45
45
  if ( this.isFetchingInfoOnLoad() ) {
46
46
  const { tokens, fetchSavedInfo } = this.props;
47
47
 
48
- fetchSavedInfo( tokens ).then( results => {
49
- const { validValues } = this.state;
48
+ fetchSavedInfo( tokens )
49
+ .then( results => {
50
+ const { validValues } = this.state;
50
51
 
51
- results.forEach( suggestion => {
52
- validValues[ suggestion.value ] = suggestion.label;
53
- } );
52
+ results.forEach( suggestion => {
53
+ validValues[ suggestion.value ] = suggestion.label;
54
+ } );
54
55
 
55
- this.setState( { validValues, loading: false } );
56
- } );
56
+ this.setState( { validValues, loading: false } );
57
+ } )
58
+ .catch( error => {
59
+ // eslint-disable-next-line no-console
60
+ console.error( 'Newspack Blocks: could not load the titles for the saved selection.', error );
61
+ // Without labels the field would render no tokens, and the next
62
+ // selection would then write back a list missing every saved ID.
63
+ const { validValues } = this.state;
64
+
65
+ tokens.forEach( token => {
66
+ validValues[ token ] = String( token );
67
+ } );
68
+
69
+ this.setState( { validValues, loading: false } );
70
+ } );
57
71
  }
58
72
  }
59
73
 
@@ -22,6 +22,6 @@
22
22
  .autocomplete-tokenfield__help {
23
23
  color: wp-colors.$gray-700;
24
24
  font-size: 12px;
25
- margin: 8px 0;
25
+ margin: 8px 0 0;
26
26
  }
27
27
  }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * External dependencies
3
+ */
4
+ import { fireEvent, render, screen } from '@testing-library/react';
5
+
6
+ /**
7
+ * Internal dependencies
8
+ */
9
+ import AutocompleteTokenField from './autocomplete-tokenfield';
10
+
11
+ const noop = () => {};
12
+
13
+ describe( 'AutocompleteTokenField when the saved titles cannot be loaded', () => {
14
+ let errorSpy;
15
+
16
+ beforeEach( () => {
17
+ errorSpy = jest.spyOn( console, 'error' ).mockImplementation( () => {} );
18
+ } );
19
+
20
+ afterEach( () => {
21
+ errorSpy.mockRestore();
22
+ } );
23
+
24
+ const renderField = () => {
25
+ const onChange = jest.fn();
26
+ render(
27
+ <AutocompleteTokenField
28
+ tokens={ [ 11, 22 ] }
29
+ onChange={ onChange }
30
+ fetchSuggestions={ noop }
31
+ fetchSavedInfo={ () => Promise.reject( new Error( 'unreachable' ) ) }
32
+ label="Content"
33
+ />
34
+ );
35
+ return { onChange };
36
+ };
37
+
38
+ it( 'shows the IDs rather than dropping the saved selection', async () => {
39
+ renderField();
40
+ expect( await screen.findByText( '11' ) ).toBeInTheDocument();
41
+ expect( screen.getByText( '22' ) ).toBeInTheDocument();
42
+ } );
43
+
44
+ // The field looks settled once the spinner goes, so the console is the only
45
+ // trace left of why the titles turned into numbers.
46
+ it( 'leaves the cause in the console', async () => {
47
+ renderField();
48
+ await screen.findByText( '11' );
49
+ expect( errorSpy ).toHaveBeenCalledWith( expect.stringContaining( 'could not load the titles' ), expect.any( Error ) );
50
+ } );
51
+
52
+ it( 'keeps the remaining IDs when one token is removed', async () => {
53
+ const { onChange } = renderField();
54
+ await screen.findByText( '11' );
55
+ fireEvent.click( document.querySelectorAll( '.components-form-token-field__remove-token' )[ 0 ] );
56
+ expect( onChange ).toHaveBeenCalledWith( [ '22' ] );
57
+ } );
58
+ } );
@@ -22,6 +22,7 @@ import { decodeEntities } from '@wordpress/html-entities';
22
22
  * Internal dependencies.
23
23
  */
24
24
  import AutocompleteTokenField from './autocomplete-tokenfield';
25
+ import SpecificPostsControl from './specific-posts-control';
25
26
 
26
27
  const getCategoryTitle = category => decodeEntities( category.name ) || __( '(no title)', 'newspack-blocks' );
27
28
 
@@ -50,19 +51,42 @@ class QueryControls extends Component {
50
51
  fetchSavedPosts = postIDs => {
51
52
  const { postType } = this.props;
52
53
  const restUrl = window.newspack_blocks_data.posts_rest_url;
54
+ // An empty `include` is dropped server-side, which would answer with the
55
+ // most recent post rather than nothing.
56
+ if ( ! postIDs.length ) {
57
+ return Promise.resolve( [] );
58
+ }
53
59
  return apiFetch( {
54
60
  url: addQueryArgs( restUrl, {
55
61
  // These params use the block query parameters (see Newspack_Blocks::build_articles_query).
56
- postsToShow: 100,
62
+ postsToShow: postIDs.length,
57
63
  include: postIDs.join( ',' ),
58
64
  _fields: 'id,title',
59
65
  postType,
60
66
  } ),
61
67
  } ).then( function ( posts ) {
62
- return posts.map( post => ( {
68
+ const allPosts = posts.map( post => ( {
63
69
  value: post.id,
64
70
  label: decodeEntities( post.title.rendered ) || __( '(no title)', 'newspack-blocks' ),
65
71
  } ) );
72
+ // An ID the endpoint did not return still needs an entry, or the token
73
+ // field drops it from the block on the next edit. The ID goes in the
74
+ // label because labels are matched back to IDs, and a shared label
75
+ // would collapse two items onto one.
76
+ postIDs.forEach( postID => {
77
+ const id = parseInt( postID );
78
+ if ( ! allPosts.find( post => post.value === id ) ) {
79
+ allPosts.push( {
80
+ value: id,
81
+ label: sprintf(
82
+ /* translators: %d: numeric ID of content that could not be found. */
83
+ __( 'Unavailable content (%d)', 'newspack-blocks' ),
84
+ id
85
+ ),
86
+ } );
87
+ }
88
+ } );
89
+ return allPosts;
66
90
  } );
67
91
  };
68
92
 
@@ -307,13 +331,11 @@ class QueryControls extends Component {
307
331
  </ToggleGroupControl>
308
332
  ) }
309
333
  { specificMode ? (
310
- <AutocompleteTokenField
311
- tokens={ specificPosts || [] }
334
+ <SpecificPostsControl
335
+ postIds={ specificPosts || [] }
312
336
  onChange={ onSpecificPostsChange }
313
337
  fetchSuggestions={ this.fetchPostSuggestions }
314
338
  fetchSavedInfo={ this.fetchSavedPosts }
315
- label={ __( 'Content', 'newspack-blocks' ) }
316
- help={ __( 'Begin typing any word in a title. Click on an autocomplete result to select it.', 'newspack-blocks' ) }
317
339
  />
318
340
  ) : (
319
341
  <>
@@ -0,0 +1,56 @@
1
+ /**
2
+ * WordPress dependencies
3
+ */
4
+ import apiFetch from '@wordpress/api-fetch';
5
+
6
+ /**
7
+ * Internal dependencies
8
+ */
9
+ import QueryControls from './query-controls';
10
+
11
+ jest.mock( '@wordpress/api-fetch' );
12
+
13
+ const fetchSavedPosts = ( postIDs, posts ) => {
14
+ apiFetch.mockResolvedValue( posts );
15
+ return new QueryControls( { postType: 'post' } ).fetchSavedPosts( postIDs );
16
+ };
17
+
18
+ describe( 'fetchSavedPosts', () => {
19
+ beforeAll( () => {
20
+ window.newspack_blocks_data = { posts_rest_url: 'https://example.test/wp-json/newspack-blocks/v1/newspack-blocks-posts' };
21
+ } );
22
+
23
+ beforeEach( () => {
24
+ apiFetch.mockClear();
25
+ } );
26
+
27
+ it( 'labels the content the endpoint returned', async () => {
28
+ await expect( fetchSavedPosts( [ 11 ], [ { id: 11, title: { rendered: 'Alpha' } } ] ) ).resolves.toEqual( [ { value: 11, label: 'Alpha' } ] );
29
+ } );
30
+
31
+ // Without an entry the token field renders no token for the ID, and the next
32
+ // selection writes the attribute back without it.
33
+ it( 'keeps an entry for an ID the endpoint did not return', async () => {
34
+ await expect( fetchSavedPosts( [ 11, 22 ], [ { id: 11, title: { rendered: 'Alpha' } } ] ) ).resolves.toEqual( [
35
+ { value: 11, label: 'Alpha' },
36
+ { value: 22, label: 'Unavailable content (22)' },
37
+ ] );
38
+ } );
39
+
40
+ // A label is matched back to its ID, so two unavailable items sharing one
41
+ // would collapse onto a single ID.
42
+ it( 'tells two unavailable IDs apart', async () => {
43
+ const posts = await fetchSavedPosts( [ 22, 33 ], [] );
44
+ expect( posts.map( post => post.label ) ).toEqual( [ 'Unavailable content (22)', 'Unavailable content (33)' ] );
45
+ } );
46
+
47
+ it( 'still marks the content unavailable when the title is empty', async () => {
48
+ const posts = await fetchSavedPosts( [ 11, 22 ], [ { id: 11, title: { rendered: '' } } ] );
49
+ expect( posts.map( post => post.label ) ).toEqual( [ '(no title)', 'Unavailable content (22)' ] );
50
+ } );
51
+
52
+ it( 'asks for nothing when nothing is saved', async () => {
53
+ await expect( fetchSavedPosts( [], [] ) ).resolves.toEqual( [] );
54
+ expect( apiFetch ).not.toHaveBeenCalled();
55
+ } );
56
+ } );