newspack-components 4.2.0 → 4.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. package/dist/cjs/action-card/index.js +21 -12
  2. package/dist/cjs/action-card/style.scss +25 -2
  3. package/dist/cjs/autocomplete-tokenfield/index.js +2 -2
  4. package/dist/cjs/autocomplete-with-latest-posts/index.js +289 -0
  5. package/dist/cjs/autocomplete-with-latest-posts/style.scss +150 -0
  6. package/dist/cjs/badge/index.js +32 -0
  7. package/dist/cjs/badge/style.scss +36 -0
  8. package/dist/cjs/button/index.js +2 -0
  9. package/dist/cjs/card/core-card.js +141 -0
  10. package/dist/cjs/card/index.js +33 -0
  11. package/dist/cjs/card/style-core.scss +227 -0
  12. package/dist/cjs/card/style.scss +2 -2
  13. package/dist/cjs/card-settings-group/index.js +49 -0
  14. package/dist/cjs/card-settings-group/style.scss +33 -0
  15. package/dist/cjs/card-sortable-list/index.js +390 -0
  16. package/dist/cjs/card-sortable-list/style.scss +48 -0
  17. package/dist/cjs/confirm-dialog/index.js +59 -0
  18. package/dist/cjs/divider/index.js +42 -0
  19. package/dist/cjs/divider/style.scss +51 -0
  20. package/dist/cjs/form-token-field/style.scss +0 -5
  21. package/dist/cjs/grid/index.js +2 -1
  22. package/dist/cjs/grid/style.scss +63 -9
  23. package/dist/cjs/index.js +42 -0
  24. package/dist/cjs/modal/index.js +11 -4
  25. package/dist/cjs/modal/style.scss +43 -13
  26. package/dist/cjs/newspack-icon/style.scss +5 -3
  27. package/dist/cjs/section-header/index.js +76 -17
  28. package/dist/cjs/section-header/style.scss +109 -4
  29. package/dist/cjs/style.scss +2 -0
  30. package/dist/cjs/waiting/index.js +3 -1
  31. package/dist/cjs/waiting/style.scss +4 -0
  32. package/dist/cjs/web-preview/index.js +98 -39
  33. package/dist/cjs/web-preview/style.scss +20 -53
  34. package/dist/cjs/with-wizard/index.js +1 -1
  35. package/dist/cjs/with-wizard/style.scss +24 -6
  36. package/dist/cjs/with-wizard-screen/style.scss +39 -10
  37. package/dist/cjs/wizard/components/WizardSnackbar.js +70 -0
  38. package/dist/cjs/wizard/components/style.scss +48 -0
  39. package/dist/cjs/wizard/index.js +97 -16
  40. package/dist/cjs/wizard/store/index.js +46 -0
  41. package/dist/esm/action-card/index.js +21 -12
  42. package/dist/esm/action-card/style.scss +25 -2
  43. package/dist/esm/autocomplete-tokenfield/index.js +2 -2
  44. package/dist/esm/autocomplete-with-latest-posts/index.js +281 -0
  45. package/dist/esm/autocomplete-with-latest-posts/style.scss +150 -0
  46. package/dist/esm/badge/index.js +24 -0
  47. package/dist/esm/badge/style.scss +36 -0
  48. package/dist/esm/button/index.js +2 -0
  49. package/dist/esm/card/core-card.js +133 -0
  50. package/dist/esm/card/index.js +33 -0
  51. package/dist/esm/card/style-core.scss +227 -0
  52. package/dist/esm/card/style.scss +2 -2
  53. package/dist/esm/card-settings-group/index.js +42 -0
  54. package/dist/esm/card-settings-group/style.scss +33 -0
  55. package/dist/esm/card-sortable-list/index.js +381 -0
  56. package/dist/esm/card-sortable-list/style.scss +48 -0
  57. package/dist/esm/confirm-dialog/index.js +49 -0
  58. package/dist/esm/divider/index.js +34 -0
  59. package/dist/esm/divider/style.scss +51 -0
  60. package/dist/esm/form-token-field/style.scss +0 -5
  61. package/dist/esm/grid/index.js +2 -1
  62. package/dist/esm/grid/style.scss +63 -9
  63. package/dist/esm/index.js +6 -0
  64. package/dist/esm/modal/index.js +11 -4
  65. package/dist/esm/modal/style.scss +43 -13
  66. package/dist/esm/newspack-icon/style.scss +5 -3
  67. package/dist/esm/section-header/index.js +75 -18
  68. package/dist/esm/section-header/style.scss +109 -4
  69. package/dist/esm/style.scss +2 -0
  70. package/dist/esm/waiting/index.js +3 -1
  71. package/dist/esm/waiting/style.scss +4 -0
  72. package/dist/esm/web-preview/index.js +101 -42
  73. package/dist/esm/web-preview/style.scss +20 -53
  74. package/dist/esm/with-wizard/index.js +1 -1
  75. package/dist/esm/with-wizard/style.scss +24 -6
  76. package/dist/esm/with-wizard-screen/style.scss +39 -10
  77. package/dist/esm/wizard/components/WizardSnackbar.js +64 -0
  78. package/dist/esm/wizard/components/style.scss +48 -0
  79. package/dist/esm/wizard/index.js +101 -20
  80. package/dist/esm/wizard/store/index.js +46 -0
  81. package/package.json +5 -6
  82. package/shared/author-context.js +26 -0
  83. package/shared/hooks/use-coauthors.js +220 -0
  84. package/shared/hooks/use-coauthors.test.js +624 -0
  85. package/shared/hooks/use-custom-byline.js +47 -0
  86. package/shared/hooks/use-custom-byline.test.js +133 -0
  87. package/shared/scss/_mixins.scss +46 -0
  88. package/src/action-card/action-card.d.ts +7 -3
  89. package/src/action-card/index.js +24 -11
  90. package/src/action-card/style.scss +25 -2
  91. package/src/autocomplete-tokenfield/index.js +2 -2
  92. package/src/autocomplete-with-latest-posts/index.js +290 -0
  93. package/src/autocomplete-with-latest-posts/style.scss +150 -0
  94. package/src/badge/index.tsx +24 -0
  95. package/src/badge/style.scss +36 -0
  96. package/src/button/index.tsx +3 -2
  97. package/src/card/core-card.js +147 -0
  98. package/src/card/index.js +39 -1
  99. package/src/card/style-core.scss +227 -0
  100. package/src/card/style.scss +2 -2
  101. package/src/card-settings-group/README.md +43 -0
  102. package/src/card-settings-group/index.tsx +53 -0
  103. package/src/card-settings-group/style.scss +33 -0
  104. package/src/card-sortable-list/README.md +51 -0
  105. package/src/card-sortable-list/index.tsx +412 -0
  106. package/src/card-sortable-list/style.scss +48 -0
  107. package/src/confirm-dialog/index.tsx +64 -0
  108. package/src/divider/README.md +25 -0
  109. package/src/divider/index.js +31 -0
  110. package/src/divider/style.scss +51 -0
  111. package/src/form-token-field/style.scss +0 -5
  112. package/src/grid/index.tsx +2 -1
  113. package/src/grid/style.scss +63 -9
  114. package/src/index.js +6 -0
  115. package/src/modal/index.js +12 -3
  116. package/src/modal/style.scss +43 -13
  117. package/src/newspack-icon/style.scss +5 -3
  118. package/src/section-header/index.js +72 -13
  119. package/src/section-header/style.scss +109 -4
  120. package/src/style.scss +2 -0
  121. package/src/waiting/index.js +2 -1
  122. package/src/waiting/style.scss +4 -0
  123. package/src/web-preview/index.js +90 -35
  124. package/src/web-preview/style.scss +20 -53
  125. package/src/with-wizard/index.js +1 -1
  126. package/src/with-wizard/style.scss +24 -6
  127. package/src/with-wizard-screen/style.scss +39 -10
  128. package/src/wizard/components/WizardSnackbar.js +58 -0
  129. package/src/wizard/components/style.scss +48 -0
  130. package/src/wizard/index.js +108 -14
  131. package/src/wizard/store/index.js +26 -0
@@ -0,0 +1,133 @@
1
+ /**
2
+ * External dependencies
3
+ */
4
+ import { renderHook } from '@testing-library/react';
5
+
6
+ /**
7
+ * WordPress dependencies
8
+ */
9
+ import { useSelect } from '@wordpress/data';
10
+
11
+ /**
12
+ * Internal dependencies
13
+ */
14
+ import { useCustomByline, extractAuthorIdsFromByline } from './use-custom-byline';
15
+
16
+ jest.mock( '@wordpress/data', () => ( {
17
+ useSelect: jest.fn(),
18
+ } ) );
19
+
20
+ jest.mock( '@wordpress/core-data', () => ( {
21
+ store: 'core',
22
+ } ) );
23
+
24
+ describe( 'useCustomByline', () => {
25
+ beforeEach( () => {
26
+ jest.clearAllMocks();
27
+ } );
28
+
29
+ it( 'should return inactive byline when meta is not set', () => {
30
+ useSelect.mockImplementation( callback =>
31
+ callback( () => ( {
32
+ getEditedEntityRecord: () => ( {} ),
33
+ } ) )
34
+ );
35
+
36
+ const { result } = renderHook( () => useCustomByline( 123, 'post' ) );
37
+
38
+ expect( result.current.bylineActive ).toBe( false );
39
+ expect( result.current.bylineContent ).toBe( '' );
40
+ } );
41
+
42
+ it( 'should return inactive byline when meta exists but byline is inactive', () => {
43
+ useSelect.mockImplementation( callback =>
44
+ callback( () => ( {
45
+ getEditedEntityRecord: () => ( {
46
+ meta: {
47
+ _newspack_byline_active: false,
48
+ _newspack_byline: 'Some content',
49
+ },
50
+ } ),
51
+ } ) )
52
+ );
53
+
54
+ const { result } = renderHook( () => useCustomByline( 123, 'post' ) );
55
+
56
+ expect( result.current.bylineActive ).toBe( false );
57
+ expect( result.current.bylineContent ).toBe( 'Some content' );
58
+ } );
59
+
60
+ it( 'should return active byline with content', () => {
61
+ const bylineContent = 'By [Author id=5]Jane Doe[/Author]';
62
+
63
+ useSelect.mockImplementation( callback =>
64
+ callback( () => ( {
65
+ getEditedEntityRecord: () => ( {
66
+ meta: {
67
+ _newspack_byline_active: true,
68
+ _newspack_byline: bylineContent,
69
+ },
70
+ } ),
71
+ } ) )
72
+ );
73
+
74
+ const { result } = renderHook( () => useCustomByline( 123, 'post' ) );
75
+
76
+ expect( result.current.bylineActive ).toBe( true );
77
+ expect( result.current.bylineContent ).toBe( bylineContent );
78
+ } );
79
+
80
+ it( 'should handle null post record gracefully', () => {
81
+ useSelect.mockImplementation( callback =>
82
+ callback( () => ( {
83
+ getEditedEntityRecord: () => null,
84
+ } ) )
85
+ );
86
+
87
+ const { result } = renderHook( () => useCustomByline( 123, 'post' ) );
88
+
89
+ expect( result.current.bylineActive ).toBe( false );
90
+ expect( result.current.bylineContent ).toBe( '' );
91
+ } );
92
+ } );
93
+
94
+ describe( 'extractAuthorIdsFromByline', () => {
95
+ it( 'should return empty array for empty content', () => {
96
+ expect( extractAuthorIdsFromByline( '' ) ).toEqual( [] );
97
+ expect( extractAuthorIdsFromByline( null ) ).toEqual( [] );
98
+ expect( extractAuthorIdsFromByline( undefined ) ).toEqual( [] );
99
+ } );
100
+
101
+ it( 'should extract single author ID', () => {
102
+ const byline = 'By [Author id=5]Jane Doe[/Author]';
103
+ expect( extractAuthorIdsFromByline( byline ) ).toEqual( [ 5 ] );
104
+ } );
105
+
106
+ it( 'should extract multiple author IDs', () => {
107
+ const byline = 'By [Author id=5]Jane Doe[/Author] and [Author id=12]John Smith[/Author]';
108
+ expect( extractAuthorIdsFromByline( byline ) ).toEqual( [ 5, 12 ] );
109
+ } );
110
+
111
+ it( 'should handle content with no author shortcodes', () => {
112
+ const byline = 'By Some Random Text';
113
+ expect( extractAuthorIdsFromByline( byline ) ).toEqual( [] );
114
+ } );
115
+
116
+ it( 'should handle byline with HTML entities', () => {
117
+ const byline = 'By [Author id=17]Darth Vader[/Author] with love';
118
+ expect( extractAuthorIdsFromByline( byline ) ).toEqual( [ 17 ] );
119
+ } );
120
+
121
+ it( 'should return integers, not strings', () => {
122
+ const byline = '[Author id=123]Test[/Author]';
123
+ const result = extractAuthorIdsFromByline( byline );
124
+ expect( result ).toEqual( [ 123 ] );
125
+ expect( typeof result[ 0 ] ).toBe( 'number' );
126
+ } );
127
+
128
+ it( 'should tolerate whitespace and case variations in shortcode', () => {
129
+ expect( extractAuthorIdsFromByline( '[author id=7]Name[/Author]' ) ).toEqual( [ 7 ] );
130
+ expect( extractAuthorIdsFromByline( '[Author id = 5]Name[/Author]' ) ).toEqual( [ 5 ] );
131
+ expect( extractAuthorIdsFromByline( '[AUTHOR ID=3]Name[/Author]' ) ).toEqual( [ 3 ] );
132
+ } );
133
+ } );
@@ -10,3 +10,49 @@
10
10
  position: absolute !important;
11
11
  word-wrap: normal !important; /* Many screen reader and browser combinations announce broken words as they would appear visually. */
12
12
  }
13
+
14
+ // Circular social button style
15
+ // Used by: author-profile-social, author-social-link blocks
16
+ // Hex fallbacks from newspack-block-theme theme.json defaults.
17
+ @mixin socialButton {
18
+ background: var(--wp--preset--color--base-2, #f0f0f0);
19
+ border-radius: 50%; // Circular
20
+ color: var(--wp--preset--color--contrast, #1e1e1e);
21
+ display: grid;
22
+ place-items: center;
23
+ text-decoration: none;
24
+ transition: background 125ms ease-in-out, color 125ms ease-in-out;
25
+
26
+ // Sizing - makes circular by using equal width/height
27
+ // --icon-size controls the circle diameter (e.g., 36px)
28
+ // Icon inside is 2/3 of circle size (e.g., 24px icon in 36px circle)
29
+ aspect-ratio: 1;
30
+ width: calc(var(--icon-size, 24px) * 1.5);
31
+ height: calc(var(--icon-size, 24px) * 1.5);
32
+ padding: 0;
33
+
34
+ &:hover {
35
+ background: var(--wp--preset--color--base-3, #ddd);
36
+ color: var(--wp--preset--color--contrast, #1e1e1e);
37
+ text-decoration: none;
38
+ }
39
+
40
+ &:focus {
41
+ outline: none;
42
+ }
43
+
44
+ &:focus-visible {
45
+ outline: 2px solid;
46
+ outline-offset: 1px;
47
+ }
48
+
49
+ // SVG icon sizing - icon is 2/3 of button size
50
+ // If button is 36px, icon is 24px (24/36 = 2/3)
51
+ svg,
52
+ span {
53
+ display: block;
54
+ fill: currentcolor;
55
+ width: var(--icon-size, 24px);
56
+ height: var(--icon-size, 24px);
57
+ }
58
+ }
@@ -6,6 +6,7 @@ export interface ActionCardProps {
6
6
  description?: string | React.ReactNode;
7
7
  actionText?: React.ReactNode | string | null;
8
8
  badge?: string;
9
+ badgeLevel?: 'success' | 'info' | 'warning' | 'error';
9
10
  className?: string;
10
11
  indent?: string;
11
12
  notification?: string | Error | null;
@@ -13,8 +14,10 @@ export interface ActionCardProps {
13
14
  isMedium?: boolean;
14
15
  disabled?: boolean | string;
15
16
  hasGreyHeader?: boolean;
17
+ heading?: number;
16
18
  toggleChecked?: boolean;
17
- toggleOnChange?: ( a: boolean ) => void;
19
+ toggleOnChange?: (a: boolean) => void;
20
+ togglePosition?: 'leading' | 'trailing';
18
21
  actionContent?: boolean | React.ReactNode | null;
19
22
  error?: Error | string | null;
20
23
  handoff?: string | null;
@@ -33,8 +36,9 @@ export interface ActionCardProps {
33
36
  noMargin?: boolean;
34
37
  collapse?: boolean;
35
38
  expandable?: boolean;
39
+ isExpanded?: boolean;
36
40
  draggable?: boolean;
37
41
  dragIndex?: number;
38
- onDragCallback?: ( index: number ) => void;
39
- dragWrapperRef?: React.MutableRefObject< HTMLDivElement | null >;
42
+ onDragCallback?: (index: number) => void;
43
+ dragWrapperRef?: React.MutableRefObject<HTMLDivElement | null>;
40
44
  }
@@ -29,13 +29,14 @@ import classnames from 'classnames';
29
29
  */
30
30
  const ActionCard = ( {
31
31
  badge,
32
- badgeLevel = 'info',
32
+ badgeLevel,
33
33
  className,
34
34
  checkbox,
35
35
  children,
36
36
  collapse,
37
37
  disabled,
38
38
  title,
39
+ heading = 2,
39
40
  description,
40
41
  handoff,
41
42
  editLink,
@@ -60,11 +61,13 @@ const ActionCard = ( {
60
61
  titleLink,
61
62
  toggleChecked = false,
62
63
  toggleOnChange,
64
+ togglePosition = 'leading',
63
65
  hasGreyHeader,
64
66
  hasWhiteHeader,
65
67
  noBorder,
66
68
  isPending,
67
69
  expandable = false,
70
+ isExpanded,
68
71
  isButtonEnabled = false,
69
72
  // Draggable props. All are required to enable drag sorting.
70
73
  draggable = false,
@@ -72,11 +75,17 @@ const ActionCard = ( {
72
75
  dragWrapperRef,
73
76
  onDragCallback,
74
77
  } ) => {
75
- const [ expanded, setExpanded ] = useState( false );
78
+ const [ expanded, setExpanded ] = useState( Boolean( isExpanded ) );
76
79
  const [ dragging, setDragging ] = useState( false );
77
80
  const [ targetIndex, setTargetIndex ] = useState( null );
78
81
  const [ dragRef, setDragRef ] = useState( null );
79
82
 
83
+ useEffect( () => {
84
+ if ( typeof isExpanded === 'boolean' ) {
85
+ setExpanded( isExpanded );
86
+ }
87
+ }, [ isExpanded ] );
88
+
80
89
  useEffect( () => {
81
90
  if ( dragWrapperRef && ! dragRef ) {
82
91
  setDragRef( dragWrapperRef );
@@ -109,14 +118,18 @@ const ActionCard = ( {
109
118
  return url ? { backgroundImage: `url(${ url })` } : {};
110
119
  };
111
120
  const titleProps = toggleOnChange && ! titleLink && ! disabled ? { onClick: () => toggleOnChange( ! toggleChecked ), tabIndex: '0' } : {};
121
+ const togglePositionClass = togglePosition === 'trailing' ? 'is-toggle-trailing' : 'is-toggle-leading';
112
122
  const hasInternalLink = href && href.indexOf( 'http' ) !== 0;
113
123
  const isDisplayingSecondaryAction = secondaryActionText && onSecondaryActionClick;
114
124
  const badges = ! Array.isArray( badge ) && badge ? [ badge ] : badge;
125
+ const HeadingTag = `h${ heading }`;
115
126
 
116
- const Component = () => (
127
+ const cardContent = (
117
128
  <>
118
129
  <div className="newspack-action-card__region newspack-action-card__region-top">
119
- { toggleOnChange && <ToggleControl checked={ toggleChecked } onChange={ toggleOnChange } disabled={ disabled } /> }
130
+ { toggleOnChange && (
131
+ <ToggleControl checked={ toggleChecked } onChange={ toggleOnChange } disabled={ disabled } className={ togglePositionClass } />
132
+ ) }
120
133
  { image && ! toggleOnChange && (
121
134
  <div className="newspack-action-card__region newspack-action-card__region-left">
122
135
  <a href={ imageLink }>
@@ -140,7 +153,7 @@ const ActionCard = ( {
140
153
  ) }
141
154
  <div className="newspack-action-card__region newspack-action-card__region-center">
142
155
  <Grid columns={ 1 } gutter={ 8 } noMargin>
143
- <h2>
156
+ <HeadingTag>
144
157
  <span className="newspack-action-card__title" { ...titleProps }>
145
158
  { titleLink && <a href={ titleLink }>{ title }</a> }
146
159
  { ! titleLink && expandable && (
@@ -159,7 +172,7 @@ const ActionCard = ( {
159
172
  { badgeText }
160
173
  </span>
161
174
  ) ) }
162
- </h2>
175
+ </HeadingTag>
163
176
  { description && (
164
177
  <p>
165
178
  { typeof description === 'string' && description }
@@ -224,9 +237,9 @@ const ActionCard = ( {
224
237
  { 'warning' === notificationLevel && <Notice noticeText={ notification } isWarning rawHTML={ notificationHTML } /> }
225
238
  </div>
226
239
  ) }
227
- { children && ( ( expandable && expanded ) || ! expandable ) && (
240
+ { children && ( ( expandable && expanded ) || ! expandable ) ? (
228
241
  <div className="newspack-action-card__region-children">{ children }</div>
229
- ) }
242
+ ) : null }
230
243
  </>
231
244
  );
232
245
 
@@ -289,7 +302,7 @@ const ActionCard = ( {
289
302
  <Card className={ classes } onClick={ simple && onClick } id={ id ?? null } noBorder={ noBorder }>
290
303
  <div className="newspack-action-card__draggable-controls">
291
304
  <div className="drag-handle" draggable onDragStart={ onDraggableStart } onDragEnd={ onDraggableEnd }>
292
- <Icon icon={ dragHandle } height={ 18 } width={ 18 } />
305
+ <Icon icon={ dragHandle } />
293
306
  </div>
294
307
  <div className="movers">
295
308
  <Button
@@ -306,7 +319,7 @@ const ActionCard = ( {
306
319
  />
307
320
  </div>
308
321
  </div>
309
- <Component />
322
+ { cardContent }
310
323
  </Card>
311
324
  ) }
312
325
  </Draggable>
@@ -316,7 +329,7 @@ const ActionCard = ( {
316
329
 
317
330
  return (
318
331
  <Card className={ classes } onClick={ simple && onClick } id={ id ?? null } noBorder={ noBorder }>
319
- <Component />
332
+ { cardContent }
320
333
  </Card>
321
334
  );
322
335
  };
@@ -260,6 +260,16 @@
260
260
  @media screen and (min-width: 744px) {
261
261
  margin-bottom: 0;
262
262
  margin-right: 24px;
263
+
264
+ &.is-toggle-trailing {
265
+ margin-left: 24px;
266
+ margin-right: 0;
267
+ order: 999;
268
+ }
269
+ }
270
+
271
+ label {
272
+ display: none;
263
273
  }
264
274
 
265
275
  .components-base-control__field {
@@ -433,13 +443,21 @@
433
443
  text-decoration: inherit;
434
444
  }
435
445
 
436
- .newspack-action-card__region-children {
437
- border-top: 1px solid wp-colors.$gray-100;
446
+ > .newspack-action-card__region-children {
447
+ border-top: 1px solid wp-colors.$gray-300;
438
448
  padding-top: 24px;
439
449
 
440
450
  + .newspack-action-card__region-children {
441
451
  border-top: none;
442
452
  }
453
+
454
+ > * + * {
455
+ margin-top: 24px !important;
456
+ }
457
+
458
+ .newspack-action-card__region-children:has(> div:not(:empty)) {
459
+ margin-top: 16px;
460
+ }
443
461
  }
444
462
 
445
463
  &.is-medium .newspack-action-card__region-children {
@@ -469,6 +487,11 @@
469
487
  margin-left: 8px;
470
488
  }
471
489
  }
490
+
491
+ hr:not(.newspack-divider) {
492
+ background: wp-colors.$gray-100;
493
+ margin: 0;
494
+ }
472
495
  }
473
496
 
474
497
  .newspack-card {
@@ -120,7 +120,7 @@ class AutocompleteTokenField extends Component {
120
120
  Object.keys( validValues ).forEach( key => {
121
121
  if ( validValues[ key ] === label ) {
122
122
  // Preserve numeric or string type of values. Object.keys will convert numbers to strings.
123
- const value = isNaN( parseInt( key ) ) ? key.toString() : parseInt( key );
123
+ const value = /^\d+$/.test( key.toString() ) ? parseInt( key ) : key.toString();
124
124
  acc.push( { value, label } );
125
125
  }
126
126
  } );
@@ -131,7 +131,7 @@ class AutocompleteTokenField extends Component {
131
131
 
132
132
  return labels.map( label =>
133
133
  Object.keys( validValues )
134
- .map( key => ( isNaN( parseInt( key ) ) ? key.toString() : parseInt( key ) ) )
134
+ .map( key => ( /^\d+$/.test( key.toString() ) ? parseInt( key ) : key.toString() ) )
135
135
  .find( key => validValues[ key ] === label )
136
136
  );
137
137
  }
@@ -0,0 +1,290 @@
1
+ /**
2
+ * External dependencies
3
+ */
4
+ import debounce from 'lodash/debounce';
5
+
6
+ /**
7
+ * WordPress dependencies
8
+ */
9
+ import apiFetch from '@wordpress/api-fetch';
10
+ import { Button, CheckboxControl, FormTokenField, Spinner } from '@wordpress/components';
11
+ import { decodeEntities } from '@wordpress/html-entities';
12
+ import { useEffect, useState } from '@wordpress/element';
13
+ import { __ } from '@wordpress/i18n';
14
+ import { addQueryArgs } from '@wordpress/url';
15
+
16
+ import './style.scss';
17
+
18
+ const AutocompleteWithLatestPosts = ( {
19
+ fetchSuggestions = false, // If passed, will use this function to fetch suggestions data.
20
+ help = __( 'Begin typing search term, click autocomplete result to select.', 'newspack-plugin' ),
21
+ hideHelp = true, // If true, all help text will be hidden.
22
+ hideFormTokenHelp = true, // If true, hides FormTokenField's built-in help text.
23
+ label = __( 'Search', 'newspack-plugin' ),
24
+ maxItemsToSuggest = 0, // If passed, will be used to determine "load more" state. Necessary if you want "load more" functionality when using a custom `fetchSuggestions` function.
25
+ multiSelect = false, // If true, component can select multiple values at once.
26
+ onChange = false, // Function to call when selections change.
27
+ selectedItems = [], // Array of saved items.
28
+ suggestionsToFetch = 20, // Number of suggestions to fetch per query.
29
+ } ) => {
30
+ const [ isLoading, setIsLoading ] = useState( true );
31
+ const [ isLoadingMore, setIsLoadingMore ] = useState( false );
32
+ const [ suggestions, setSuggestions ] = useState( [] );
33
+ const [ maxSuggestions, setMaxSuggestions ] = useState( 0 );
34
+ const [ searchSuggestions, setSearchSuggestions ] = useState( [] );
35
+ const [ validValues, setValidValues ] = useState( {} );
36
+ const [ isSearching, setIsSearching ] = useState( false );
37
+ // Leaving this in here so we can expand to use different post types in the future; right now it defaults to 'post'.
38
+ const postTypeToSearch = 'post';
39
+ const classNames = [ 'newspack-autocomplete-with-latest-posts' ];
40
+
41
+ if ( hideFormTokenHelp ) {
42
+ classNames.push( 'hide-form-token-help' );
43
+ }
44
+
45
+ if ( hideHelp ) {
46
+ classNames.push( 'hide-help' );
47
+ }
48
+
49
+ /**
50
+ * Debounced function to update search suggestions.
51
+ */
52
+ const debouncedUpdateSuggestions = debounce( input => {
53
+ if ( ! input || input.length < 2 ) {
54
+ setSearchSuggestions( [] );
55
+ setIsSearching( false );
56
+ return;
57
+ }
58
+
59
+ setIsSearching( true );
60
+ handleFetchSuggestions( input, 0, postTypeToSearch )
61
+ .then( _suggestions => {
62
+ setSearchSuggestions( _suggestions );
63
+ // Update valid values for token conversion
64
+ const newValidValues = { ...validValues };
65
+ _suggestions.forEach( suggestion => {
66
+ newValidValues[ suggestion.value ] = suggestion.label;
67
+ } );
68
+ setValidValues( newValidValues );
69
+ } )
70
+ .finally( () => setIsSearching( false ) );
71
+ }, 500 );
72
+
73
+ /**
74
+ * Fetch recent posts to show as suggestions.
75
+ */
76
+ useEffect( () => {
77
+ setIsLoading( true );
78
+ handleFetchSuggestions( null, 0, postTypeToSearch )
79
+ .then( _suggestions => {
80
+ if ( 0 < _suggestions.length ) {
81
+ setSuggestions( _suggestions );
82
+ // Update valid values for token conversion
83
+ const newValidValues = { ...validValues };
84
+ _suggestions.forEach( suggestion => {
85
+ newValidValues[ suggestion.value ] = suggestion.label;
86
+ } );
87
+ setValidValues( newValidValues );
88
+ }
89
+ } )
90
+ .finally( () => setIsLoading( false ) );
91
+ }, [] );
92
+
93
+ /**
94
+ * Fetch more suggestions.
95
+ */
96
+ useEffect( () => {
97
+ if ( isLoadingMore ) {
98
+ handleFetchSuggestions( null, suggestions.length, postTypeToSearch )
99
+ .then( _suggestions => {
100
+ if ( 0 < _suggestions.length ) {
101
+ setSuggestions( suggestions.concat( _suggestions ) );
102
+ }
103
+ } )
104
+ .finally( () => setIsLoadingMore( false ) );
105
+ }
106
+ }, [ isLoadingMore ] );
107
+
108
+ /**
109
+ * If passed a `fetchSuggestions` prop, use that, otherwise, build it based on the selected post type.
110
+ */
111
+ const handleFetchSuggestions = fetchSuggestions
112
+ ? fetchSuggestions
113
+ : async ( search = null, offset = 0, searchSlug = null ) => {
114
+ const postTypeSlug = searchSlug || postTypeToSearch;
115
+ const endpoint =
116
+ 'post' === postTypeSlug || 'page' === postTypeSlug
117
+ ? postTypeSlug + 's' // Default post type endpoints are plural.
118
+ : postTypeSlug; // Custom post type endpoints are singular.
119
+ const response = await apiFetch( {
120
+ parse: false,
121
+ path: addQueryArgs( '/wp/v2/' + endpoint, {
122
+ search,
123
+ offset,
124
+ per_page: suggestionsToFetch,
125
+ _fields: 'id,title',
126
+ } ),
127
+ } );
128
+
129
+ const total = parseInt( response.headers.get( 'x-wp-total' ) || 0 );
130
+ const posts = await response.json();
131
+
132
+ setMaxSuggestions( total );
133
+
134
+ // Format suggestions for FormTokenField display.
135
+ return posts.reduce( ( acc, post ) => {
136
+ acc.push( {
137
+ value: parseInt( post.id ),
138
+ label: decodeEntities( post?.title.rendered ) || __( '(no title)', 'newspack-plugin' ),
139
+ } );
140
+
141
+ return acc;
142
+ }, [] );
143
+ };
144
+
145
+ /**
146
+ * Get labels for token values.
147
+ */
148
+ const getLabelsForValues = values => {
149
+ return values.reduce( ( accumulator, value ) => {
150
+ if ( ! value ) {
151
+ return accumulator;
152
+ }
153
+ if ( value.label ) {
154
+ return [ ...accumulator, value.label ];
155
+ }
156
+ return validValues[ value ] ? [ ...accumulator, validValues[ value ] ] : accumulator;
157
+ }, [] );
158
+ };
159
+
160
+ /**
161
+ * Get values for token labels.
162
+ */
163
+ const getValuesForLabels = labels => {
164
+ // eslint-disable-next-line @typescript-eslint/no-shadow
165
+ return labels.reduce( ( acc, label ) => {
166
+ Object.keys( validValues ).forEach( key => {
167
+ if ( validValues[ key ] === label ) {
168
+ const value = isNaN( parseInt( key ) ) ? key.toString() : parseInt( key );
169
+ acc.push( { value, label } );
170
+ }
171
+ } );
172
+ return acc;
173
+ }, [] );
174
+ };
175
+
176
+ /**
177
+ * Handle FormTokenField onChange.
178
+ */
179
+ const handleTokenChange = tokenStrings => {
180
+ const newSelections = getValuesForLabels( tokenStrings );
181
+
182
+ // If only allowing one selection, just return the one selected item.
183
+ if ( ! multiSelect ) {
184
+ const newSelection = newSelections[ newSelections.length - 1 ]; // Get the last selected item
185
+ if ( newSelection ) {
186
+ return onChange( [ { ...newSelection, postType: postTypeToSearch } ] );
187
+ }
188
+ return onChange( [] );
189
+ }
190
+
191
+ // For multi-select, include currently selected post type in selection results.
192
+ onChange( newSelections.map( selection => ( { ...selection, postType: postTypeToSearch } ) ) );
193
+ };
194
+
195
+ /**
196
+ * Get tokens for FormTokenField.
197
+ */
198
+ const getTokens = () => {
199
+ return getLabelsForValues( selectedItems );
200
+ };
201
+
202
+ /**
203
+ * Render a single suggestion object that can be clicked to select it immediately.
204
+ *
205
+ * @param {Object} suggestion Suggestion object with value and label keys.
206
+ */
207
+ const renderSuggestion = suggestion => {
208
+ if ( multiSelect ) {
209
+ const isSelected = !! selectedItems.find(
210
+ _selection => parseInt( _selection.value ) === parseInt( suggestion.value ) && _selection.label === suggestion.label
211
+ );
212
+ return (
213
+ <CheckboxControl
214
+ key={ suggestion.value }
215
+ checked={ isSelected }
216
+ onChange={ () => {
217
+ // For multi-select, we need to toggle the selection
218
+ const currentTokens = getTokens();
219
+ const suggestionLabel = suggestion.label;
220
+
221
+ if ( isSelected ) {
222
+ // Remove the suggestion
223
+ const newTokens = currentTokens.filter( token => token !== suggestionLabel );
224
+ handleTokenChange( newTokens );
225
+ } else {
226
+ // Add the suggestion
227
+ handleTokenChange( [ ...currentTokens, suggestionLabel ] );
228
+ }
229
+ } }
230
+ label={ suggestion.label }
231
+ />
232
+ );
233
+ }
234
+ return (
235
+ <Button isLink key={ suggestion.value } onClick={ () => handleTokenChange( [ ...getTokens(), suggestion.label ] ) }>
236
+ { suggestion.label }
237
+ </Button>
238
+ );
239
+ };
240
+
241
+ /**
242
+ * Render a list of suggestions that can be clicked to select instead of searching by title.
243
+ */
244
+ const renderSuggestions = () => {
245
+ if ( 0 === suggestions.length ) {
246
+ return null;
247
+ }
248
+
249
+ const className = multiSelect
250
+ ? 'newspack-autocomplete-with-latest-posts__search-suggestions-multiselect'
251
+ : 'newspack-autocomplete-with-latest-posts__search-suggestions';
252
+
253
+ return (
254
+ <>
255
+ <div className="newspack-autocomplete-with-latest-posts__search-suggestions-container">
256
+ <p className="newspack-autocomplete-with-suggestions__label">{ __( 'Latest Posts', 'newspack-plugin' ) }</p>
257
+ <div className={ className }>
258
+ { suggestions.map( renderSuggestion ) }
259
+ { suggestions.length < ( maxItemsToSuggest || maxSuggestions ) && (
260
+ <Button disabled={ isLoadingMore } isSecondary onClick={ () => setIsLoadingMore( true ) }>
261
+ { isLoadingMore ? __( 'Loading…', 'newspack-plugin' ) : __( 'Load more', 'newspack-plugin' ) }
262
+ </Button>
263
+ ) }
264
+ </div>
265
+ </div>
266
+ </>
267
+ );
268
+ };
269
+
270
+ return (
271
+ <div className={ classNames.join( ' ' ) }>
272
+ <div className="newspack-autocomplete-with-latest-posts__input-container">
273
+ <FormTokenField
274
+ value={ getTokens() }
275
+ suggestions={ searchSuggestions.map( suggestion => suggestion.label ) }
276
+ onChange={ handleTokenChange }
277
+ onInputChange={ debouncedUpdateSuggestions }
278
+ label={ label }
279
+ help={ ! hideHelp && help }
280
+ __next40pxDefaultSize={ true }
281
+ __nextHasNoMarginBottom={ true }
282
+ />
283
+ { ( isLoading || isSearching ) && <Spinner /> }
284
+ </div>
285
+ { renderSuggestions() }
286
+ </div>
287
+ );
288
+ };
289
+
290
+ export default AutocompleteWithLatestPosts;