newspack-components 4.3.0 → 4.4.0-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.
Files changed (262) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/DEVELOPMENT.md +868 -0
  3. package/copy-styles.js +3 -2
  4. package/dist/cjs/accordion/index.js +17 -12
  5. package/dist/cjs/action-card/index.js +176 -139
  6. package/dist/cjs/autocomplete-tokenfield/index.js +254 -234
  7. package/dist/cjs/autocomplete-with-latest-posts/index.js +172 -110
  8. package/dist/cjs/autocomplete-with-suggestions/index.js +229 -126
  9. package/dist/cjs/badge/index.js +7 -7
  10. package/dist/cjs/box-contrast/index.js +19 -15
  11. package/dist/cjs/button/index.js +41 -26
  12. package/dist/cjs/button-props/button-props.js +5 -5
  13. package/dist/cjs/button-props/index.js +2 -2
  14. package/dist/cjs/card/core-card.js +110 -52
  15. package/dist/cjs/card/index.js +74 -67
  16. package/dist/cjs/card/style-core.scss +93 -25
  17. package/dist/cjs/card/style.scss +2 -2
  18. package/dist/cjs/card-feature/index.js +141 -0
  19. package/dist/cjs/card-feature/style.scss +75 -0
  20. package/dist/cjs/card-form/index.js +144 -0
  21. package/dist/cjs/card-form/style.scss +20 -0
  22. package/dist/cjs/card-settings-group/index.js +36 -16
  23. package/dist/cjs/card-sortable-list/index.js +176 -140
  24. package/dist/cjs/category-autocomplete/index.js +175 -155
  25. package/dist/cjs/color-picker/index.js +41 -23
  26. package/dist/cjs/color-picker/index.test.js +82 -0
  27. package/dist/cjs/color-picker/style.scss +11 -13
  28. package/dist/cjs/confirm-dialog/index.js +86 -22
  29. package/dist/cjs/consts.js +1 -1
  30. package/dist/cjs/custom-select-control/index.js +39 -28
  31. package/dist/cjs/dataviews/index.js +38 -0
  32. package/dist/cjs/dataviews/style.scss +9 -0
  33. package/dist/cjs/divider/index.js +26 -21
  34. package/dist/cjs/footer/index.js +36 -30
  35. package/dist/cjs/form-token-field/index.js +51 -40
  36. package/dist/cjs/global-notices/index.js +5 -5
  37. package/dist/cjs/grid/index.js +25 -19
  38. package/dist/cjs/grid/style.scss +6 -1
  39. package/dist/cjs/handoff/index.js +204 -156
  40. package/dist/cjs/handoff-message/index.js +12 -7
  41. package/dist/cjs/hooks/index.js +10 -8
  42. package/dist/cjs/hooks/use-confirm-dialog.js +79 -0
  43. package/dist/cjs/hooks/use-unsaved-changes-dialog.js +144 -0
  44. package/dist/cjs/hooks/useObjectState.js +21 -12
  45. package/dist/cjs/hooks/useObjectState.test.js +55 -33
  46. package/dist/cjs/hooks/useOnClickOutside.js +6 -6
  47. package/dist/cjs/hooks/usePrompt.js +10 -13
  48. package/dist/cjs/image-upload/index.js +114 -123
  49. package/dist/cjs/image-upload/index.test.js +11 -13
  50. package/dist/cjs/image-upload/style.scss +12 -40
  51. package/dist/cjs/index.js +136 -104
  52. package/dist/cjs/info-button/index.js +43 -32
  53. package/dist/cjs/integration-icons/active-campaign.js +22 -0
  54. package/dist/cjs/integration-icons/constant-contact.js +28 -0
  55. package/dist/cjs/integration-icons/fundraise-up.js +25 -0
  56. package/dist/cjs/integration-icons/index.js +48 -0
  57. package/dist/cjs/integration-icons/mailchimp.js +19 -0
  58. package/dist/cjs/integration-icons/salesforce.js +17 -0
  59. package/dist/cjs/integration-icons/wisepops.js +20 -0
  60. package/dist/cjs/modal/index.js +22 -19
  61. package/dist/cjs/modal/style.scss +4 -0
  62. package/dist/cjs/newspack-icon/index.js +45 -38
  63. package/dist/cjs/notice/index.js +64 -55
  64. package/dist/cjs/plugin-installer/index.js +259 -253
  65. package/dist/cjs/plugin-settings/SettingsSection.js +66 -60
  66. package/dist/cjs/plugin-settings/index.js +217 -200
  67. package/dist/cjs/plugin-toggle/index.js +192 -199
  68. package/dist/cjs/popover/index.js +41 -28
  69. package/dist/cjs/position-control/index.js +24 -23
  70. package/dist/cjs/progress-bar/index.js +71 -59
  71. package/dist/cjs/progress-bar/index.test.js +62 -68
  72. package/dist/cjs/proxied-imports/router.js +3 -3
  73. package/dist/cjs/radio-control/index.js +40 -27
  74. package/dist/cjs/section-header/index.js +103 -58
  75. package/dist/cjs/section-header/style.scss +31 -11
  76. package/dist/cjs/select-control/ButtonGroupControl.js +26 -21
  77. package/dist/cjs/select-control/GroupedSelectControl.js +41 -35
  78. package/dist/cjs/select-control/index.js +52 -40
  79. package/dist/cjs/settings/MinMaxSetting.js +30 -20
  80. package/dist/cjs/settings/SettingsCard.js +20 -17
  81. package/dist/cjs/settings/SettingsSection.js +22 -21
  82. package/dist/cjs/settings/index.js +5 -5
  83. package/dist/cjs/sortable-newsletter-list-control/index.js +78 -57
  84. package/dist/cjs/steps-list/index.js +44 -34
  85. package/dist/cjs/steps-list-item/index.js +47 -39
  86. package/dist/cjs/style-card/index.js +70 -63
  87. package/dist/cjs/tabbed-navigation/index.js +38 -36
  88. package/dist/cjs/text-control/index.js +27 -25
  89. package/dist/cjs/utils/color.js +4 -4
  90. package/dist/cjs/utils/editor-toolbar-back-button.js +61 -0
  91. package/dist/cjs/utils/index.js +31 -19
  92. package/dist/cjs/utils/style.scss +8 -0
  93. package/dist/cjs/waiting/index.js +48 -37
  94. package/dist/cjs/web-preview/index.js +217 -211
  95. package/dist/cjs/with-wizard/index.js +362 -364
  96. package/dist/cjs/with-wizard/style.scss +4 -54
  97. package/dist/cjs/with-wizard-screen/index.js +54 -58
  98. package/dist/cjs/with-wizard-screen/style.scss +41 -3
  99. package/dist/cjs/wizard/components/WizardError.js +20 -18
  100. package/dist/cjs/wizard/components/WizardSnackbar.js +26 -23
  101. package/dist/cjs/wizard/index.js +176 -114
  102. package/dist/cjs/wizard/store/index.js +171 -123
  103. package/dist/cjs/wizard/store/utils.js +13 -8
  104. package/dist/esm/accordion/index.js +14 -9
  105. package/dist/esm/action-card/index.js +168 -123
  106. package/dist/esm/autocomplete-tokenfield/index.js +248 -222
  107. package/dist/esm/autocomplete-with-latest-posts/index.js +169 -103
  108. package/dist/esm/autocomplete-with-suggestions/index.js +226 -119
  109. package/dist/esm/badge/index.js +5 -5
  110. package/dist/esm/box-contrast/index.js +15 -11
  111. package/dist/esm/button/index.js +39 -21
  112. package/dist/esm/button-props/button-props.js +1 -1
  113. package/dist/esm/card/core-card.js +109 -47
  114. package/dist/esm/card/index.js +69 -54
  115. package/dist/esm/card/style-core.scss +93 -25
  116. package/dist/esm/card/style.scss +2 -2
  117. package/dist/esm/card-feature/index.js +131 -0
  118. package/dist/esm/card-feature/style.scss +75 -0
  119. package/dist/esm/card-form/index.js +134 -0
  120. package/dist/esm/card-form/style.scss +20 -0
  121. package/dist/esm/card-settings-group/index.js +33 -14
  122. package/dist/esm/card-sortable-list/index.js +170 -126
  123. package/dist/esm/category-autocomplete/index.js +168 -140
  124. package/dist/esm/color-picker/index.js +39 -21
  125. package/dist/esm/color-picker/index.test.js +78 -0
  126. package/dist/esm/color-picker/style.scss +11 -13
  127. package/dist/esm/confirm-dialog/index.js +85 -18
  128. package/dist/esm/consts.js +1 -1
  129. package/dist/esm/custom-select-control/index.js +34 -15
  130. package/dist/esm/dataviews/index.js +34 -0
  131. package/dist/esm/dataviews/style.scss +9 -0
  132. package/dist/esm/divider/index.js +24 -16
  133. package/dist/esm/footer/index.js +34 -28
  134. package/dist/esm/form-token-field/index.js +46 -27
  135. package/dist/esm/global-notices/index.js +3 -3
  136. package/dist/esm/grid/index.js +23 -14
  137. package/dist/esm/grid/style.scss +6 -1
  138. package/dist/esm/handoff/index.js +199 -143
  139. package/dist/esm/handoff-message/index.js +10 -6
  140. package/dist/esm/hooks/index.js +8 -6
  141. package/dist/esm/hooks/use-confirm-dialog.js +73 -0
  142. package/dist/esm/hooks/use-unsaved-changes-dialog.js +136 -0
  143. package/dist/esm/hooks/useObjectState.js +19 -9
  144. package/dist/esm/hooks/useObjectState.test.js +54 -32
  145. package/dist/esm/hooks/useOnClickOutside.js +4 -4
  146. package/dist/esm/hooks/usePrompt.js +9 -11
  147. package/dist/esm/image-upload/index.js +110 -111
  148. package/dist/esm/image-upload/index.test.js +11 -13
  149. package/dist/esm/image-upload/style.scss +12 -40
  150. package/dist/esm/index.js +7 -1
  151. package/dist/esm/info-button/index.js +38 -19
  152. package/dist/esm/integration-icons/active-campaign.js +16 -0
  153. package/dist/esm/integration-icons/constant-contact.js +22 -0
  154. package/dist/esm/integration-icons/fundraise-up.js +19 -0
  155. package/dist/esm/integration-icons/index.js +6 -0
  156. package/dist/esm/integration-icons/mailchimp.js +13 -0
  157. package/dist/esm/integration-icons/salesforce.js +11 -0
  158. package/dist/esm/integration-icons/wisepops.js +14 -0
  159. package/dist/esm/modal/index.js +20 -13
  160. package/dist/esm/modal/style.scss +4 -0
  161. package/dist/esm/newspack-icon/index.js +40 -25
  162. package/dist/esm/notice/index.js +59 -42
  163. package/dist/esm/plugin-installer/index.js +255 -243
  164. package/dist/esm/plugin-settings/SettingsSection.js +61 -50
  165. package/dist/esm/plugin-settings/index.js +212 -189
  166. package/dist/esm/plugin-toggle/index.js +187 -190
  167. package/dist/esm/popover/index.js +35 -16
  168. package/dist/esm/position-control/index.js +22 -18
  169. package/dist/esm/progress-bar/index.js +66 -49
  170. package/dist/esm/progress-bar/index.test.js +61 -67
  171. package/dist/esm/radio-control/index.js +34 -15
  172. package/dist/esm/section-header/index.js +102 -57
  173. package/dist/esm/section-header/style.scss +31 -11
  174. package/dist/esm/select-control/ButtonGroupControl.js +23 -18
  175. package/dist/esm/select-control/GroupedSelectControl.js +38 -30
  176. package/dist/esm/select-control/index.js +47 -27
  177. package/dist/esm/settings/MinMaxSetting.js +27 -16
  178. package/dist/esm/settings/SettingsCard.js +18 -13
  179. package/dist/esm/settings/SettingsSection.js +20 -19
  180. package/dist/esm/settings/index.js +3 -3
  181. package/dist/esm/sortable-newsletter-list-control/index.js +74 -53
  182. package/dist/esm/steps-list/index.js +39 -21
  183. package/dist/esm/steps-list-item/index.js +42 -26
  184. package/dist/esm/style-card/index.js +65 -50
  185. package/dist/esm/tabbed-navigation/index.js +35 -33
  186. package/dist/esm/text-control/index.js +25 -19
  187. package/dist/esm/utils/color.js +4 -4
  188. package/dist/esm/utils/editor-toolbar-back-button.js +53 -0
  189. package/dist/esm/utils/index.js +29 -16
  190. package/dist/esm/utils/style.scss +8 -0
  191. package/dist/esm/waiting/index.js +43 -24
  192. package/dist/esm/web-preview/index.js +213 -201
  193. package/dist/esm/with-wizard/index.js +360 -358
  194. package/dist/esm/with-wizard/style.scss +4 -54
  195. package/dist/esm/with-wizard-screen/index.js +48 -46
  196. package/dist/esm/with-wizard-screen/style.scss +41 -3
  197. package/dist/esm/wizard/components/WizardError.js +18 -16
  198. package/dist/esm/wizard/components/WizardSnackbar.js +23 -17
  199. package/dist/esm/wizard/index.js +171 -103
  200. package/dist/esm/wizard/store/index.js +169 -116
  201. package/dist/esm/wizard/store/utils.js +13 -6
  202. package/package.json +30 -16
  203. package/release.config.js +1 -0
  204. package/shared/hooks/use-coauthors.js +195 -27
  205. package/shared/hooks/use-coauthors.test.js +232 -6
  206. package/shared/scss/_mixins.scss +13 -5
  207. package/src/accordion/index.js +8 -7
  208. package/src/action-card/index.js +15 -1
  209. package/src/badge/index.tsx +3 -1
  210. package/src/card/core-card.js +95 -18
  211. package/src/card/index.js +1 -0
  212. package/src/card/style-core.scss +93 -25
  213. package/src/card/style.scss +2 -2
  214. package/src/card-feature/README.md +188 -0
  215. package/src/card-feature/index.tsx +186 -0
  216. package/src/card-feature/style.scss +75 -0
  217. package/src/card-form/README.md +113 -0
  218. package/src/card-form/index.tsx +128 -0
  219. package/src/card-form/style.scss +20 -0
  220. package/src/card-settings-group/index.tsx +28 -3
  221. package/src/card-sortable-list/index.tsx +27 -4
  222. package/src/color-picker/index.js +15 -8
  223. package/src/color-picker/index.test.js +52 -0
  224. package/src/color-picker/style.scss +11 -13
  225. package/src/confirm-dialog/README.md +128 -0
  226. package/src/confirm-dialog/index.tsx +74 -6
  227. package/src/dataviews/index.tsx +34 -0
  228. package/src/dataviews/style.scss +9 -0
  229. package/src/grid/style.scss +6 -1
  230. package/src/handoff/README.md +93 -0
  231. package/src/handoff/index.js +46 -8
  232. package/src/hooks/index.ts +2 -1
  233. package/src/hooks/use-confirm-dialog.tsx +80 -0
  234. package/src/hooks/use-unsaved-changes-dialog.tsx +140 -0
  235. package/src/image-upload/index.js +10 -15
  236. package/src/image-upload/style.scss +12 -40
  237. package/src/index.js +7 -1
  238. package/src/integration-icons/active-campaign.js +14 -0
  239. package/src/integration-icons/constant-contact.js +22 -0
  240. package/src/integration-icons/fundraise-up.js +18 -0
  241. package/src/integration-icons/index.js +6 -0
  242. package/src/integration-icons/mailchimp.js +10 -0
  243. package/src/integration-icons/salesforce.js +10 -0
  244. package/src/integration-icons/wisepops.js +14 -0
  245. package/src/modal/style.scss +4 -0
  246. package/src/plugin-installer/index.js +30 -19
  247. package/src/section-header/index.js +56 -24
  248. package/src/section-header/style.scss +31 -11
  249. package/src/select-control/index.js +4 -0
  250. package/src/utils/editor-toolbar-back-button.tsx +48 -0
  251. package/src/utils/index.js +6 -0
  252. package/src/utils/style.scss +8 -0
  253. package/src/with-wizard/style.scss +4 -54
  254. package/src/with-wizard-screen/style.scss +41 -3
  255. package/src/wizard/index.js +90 -53
  256. package/src/wizard/store/index.js +2 -2
  257. package/dist/cjs/button-card/index.js +0 -72
  258. package/dist/cjs/button-card/style.scss +0 -180
  259. package/dist/esm/button-card/index.js +0 -64
  260. package/dist/esm/button-card/style.scss +0 -180
  261. package/src/button-card/index.js +0 -52
  262. package/src/button-card/style.scss +0 -180
@@ -6,11 +6,11 @@ import { useState, useEffect, useMemo } from '@wordpress/element';
6
6
  import { store as coreStore } from '@wordpress/core-data';
7
7
  import apiFetch from '@wordpress/api-fetch';
8
8
 
9
- /**
10
- * CoAuthors Plus store name.
11
- */
9
+ // Detection key for the optional Redux store published by CAP <4.0. When present and exposing
10
+ // `getAuthors`, prefer the synchronous path; otherwise fall back to term-ID resolution via REST.
12
11
  const CAP_STORE = 'cap/authors';
13
12
  const COAUTHORS_ENDPOINT = '/coauthors/v1/coauthors';
13
+ const COAUTHORS_BY_TERM_IDS_ENDPOINT = '/coauthors/v1/authors-by-term-ids';
14
14
 
15
15
  // Module-level cache for guest author avatar URLs, keyed by user_nicename.
16
16
  // Prevents duplicate REST requests when components re-mount or when
@@ -23,6 +23,66 @@ const guestAvatarCache = {};
23
23
  // any fetch has completed.
24
24
  const inflightRequests = {};
25
25
 
26
+ // Term-ID to author object cache (new CAP). A cached `false` means the server omitted the ID.
27
+ const coauthorDetailsCache = {};
28
+
29
+ // In-flight term-ID fetches keyed by sorted CSV, so concurrent mounts share one REST call.
30
+ const inflightTermResolves = {};
31
+
32
+ // Stable empty-state reference so setState is a no-op when already empty.
33
+ const EMPTY_AUTHORS = Object.freeze( [] );
34
+
35
+ /**
36
+ * Fetch coauthor details for an array of taxonomy term IDs (new CAP).
37
+ * Deduplicates concurrent requests and caches results by term ID.
38
+ *
39
+ * @param {number[]} termIds Array of coauthor taxonomy term IDs.
40
+ * @return {Promise} Resolves once results are in `coauthorDetailsCache`.
41
+ */
42
+ function fetchCoauthorsByTermIds( termIds ) {
43
+ if ( ! termIds || termIds.length === 0 ) {
44
+ return Promise.resolve();
45
+ }
46
+ const uncachedIds = termIds.filter( id => ! ( id in coauthorDetailsCache ) );
47
+ if ( uncachedIds.length === 0 ) {
48
+ return Promise.resolve();
49
+ }
50
+ const key = [ ...uncachedIds ].sort( ( a, b ) => a - b ).join( ',' );
51
+ if ( inflightTermResolves[ key ] ) {
52
+ return inflightTermResolves[ key ];
53
+ }
54
+ inflightTermResolves[ key ] = apiFetch( {
55
+ path: `${ COAUTHORS_BY_TERM_IDS_ENDPOINT }?ids=${ uncachedIds.join( ',' ) }`,
56
+ } )
57
+ .then( results => {
58
+ if ( Array.isArray( results ) ) {
59
+ results.forEach( author => {
60
+ if ( author?.termId ) {
61
+ coauthorDetailsCache[ author.termId ] = author;
62
+ }
63
+ } );
64
+ }
65
+ // Mark IDs that the server omitted as permanently unresolved (e.g. deleted author).
66
+ // Only done on a successful response — transient errors leave cache untouched to allow retries.
67
+ uncachedIds.forEach( id => {
68
+ if ( ! ( id in coauthorDetailsCache ) ) {
69
+ coauthorDetailsCache[ id ] = false;
70
+ }
71
+ } );
72
+ } )
73
+ .catch( () => {
74
+ // Do not poison the cache on transient errors (network, 5xx) or permission errors (403).
75
+ // Concurrent calls are already deduped via `inflightTermResolves`, and leaving the cache
76
+ // untouched allows a later call to `fetchCoauthorsByTermIds` to retry after this
77
+ // in-flight request clears. Failure is signalled to consumers via `hasCoauthorTermIds`
78
+ // remaining true while `authors` stays empty.
79
+ } )
80
+ .finally( () => {
81
+ delete inflightTermResolves[ key ];
82
+ } );
83
+ return inflightTermResolves[ key ];
84
+ }
85
+
26
86
  /**
27
87
  * Fetch a single coauthor's avatar URLs, deduplicating concurrent requests.
28
88
  *
@@ -59,6 +119,14 @@ export function resetGuestAvatarCacheForTests() {
59
119
  Object.keys( inflightRequests ).forEach( key => delete inflightRequests[ key ] );
60
120
  }
61
121
 
122
+ /**
123
+ * Reset the module-level coauthor details cache. Exposed for testing only.
124
+ */
125
+ export function resetCoauthorDetailsCacheForTests() {
126
+ Object.keys( coauthorDetailsCache ).forEach( key => delete coauthorDetailsCache[ key ] );
127
+ Object.keys( inflightTermResolves ).forEach( key => delete inflightTermResolves[ key ] );
128
+ }
129
+
62
130
  /**
63
131
  * Extract user_nicename from an author archive URL.
64
132
  *
@@ -83,58 +151,144 @@ function extractNicenameFromLink( link ) {
83
151
  /**
84
152
  * Hook to get CoAuthors Plus authors from the CAP store or REST API.
85
153
  *
86
- * For the currently-edited post, it uses CAP's JS store for real-time updates.
87
- * For Query Loop posts, it uses REST API data since CAP's store only works for the
88
- * currently-edited post.
154
+ * For the currently-edited post, uses one of two sources depending on which CAP version is active:
155
+ * - Legacy CAP (Redux store published by CAP <4.0): reads from `cap/authors` for real-time updates.
156
+ * - New CAP (no Redux store, CAP 4.0+): reads taxonomy term IDs from
157
+ * `getEditedPostAttribute('coauthors')` and resolves them to rich author data via the
158
+ * `authors-by-term-ids` REST endpoint.
159
+ * For Query Loop posts, it uses REST data from `newspack_author_info` (unaffected by CAP version).
89
160
  *
90
161
  * @param {number} postId Post ID to get authors for.
91
162
  * @param {string} postType Post type (default: 'post').
92
163
  * @param {boolean} skip Skip fetching (default: false).
93
- * @return {Object} Authors array and availability state.
164
+ * @return {Object} `{ authors, isCapAvailable, isLoading, hasCoauthorTermIds }`.
165
+ * `isLoading` is true during the new-CAP term-ID resolution window (including the
166
+ * pre-flight render before the effect fires); legacy CAP is synchronous.
167
+ * `hasCoauthorTermIds` is true when the new-CAP path is in use AND term IDs are
168
+ * assigned — consumers should treat `authors.length === 0` while this flag is true
169
+ * as "resolution unavailable" (e.g. permission-denied REST) rather than "no coauthors",
170
+ * to avoid silently falling back to default state.
94
171
  */
95
172
  export function useCoAuthors( postId, postType = 'post', skip = false ) {
96
173
  // Return raw store references from useSelect to avoid creating new objects
97
174
  // on every render (which triggers useSelect memoization warnings).
98
175
  // The .map() transformations happen in useMemo below.
99
- const { capAuthors, restAuthors, isCapAvailable } = useSelect(
176
+ const { legacyCapAuthors, coauthorTermIdsKey, restAuthors, isCapAvailable } = useSelect(
100
177
  select => {
101
178
  if ( skip ) {
102
- return { capAuthors: null, restAuthors: null, isCapAvailable: false };
179
+ return { legacyCapAuthors: null, coauthorTermIdsKey: null, restAuthors: null, isCapAvailable: false };
103
180
  }
104
181
 
105
182
  const capStore = select( CAP_STORE );
106
- const isCapStoreAvailable = Boolean( capStore && typeof capStore.getAuthors === 'function' );
183
+ const isLegacyCapStoreAvailable = Boolean( capStore && typeof capStore.getAuthors === 'function' );
107
184
 
108
185
  const editorStore = select( 'core/editor' );
109
186
  const currentPostId = editorStore?.getCurrentPostId?.();
110
187
  const isQueryLoopContext = postId && currentPostId && postId !== currentPostId;
111
188
 
112
- // For the currently-edited post, use CAP's store for real-time updates.
113
- if ( isCapStoreAvailable && ! isQueryLoopContext ) {
114
- const rawCapAuthors = postId ? capStore.getAuthors( postId ) : null;
115
- return { capAuthors: rawCapAuthors, restAuthors: null, isCapAvailable: true };
116
- }
117
-
118
- // For Query Loop context, try to get coauthors from REST API.
119
- // If REST author data exists, treat CAP as available (matching original behavior)
120
- // since the server-side plugin provided the data even if the JS store isn't loaded.
189
+ // Query Loop: read from newspack_author_info on the post entity.
190
+ // Unaffected by CAP version.
121
191
  if ( isQueryLoopContext && postId ) {
122
192
  const { getEntityRecord } = select( coreStore );
123
193
  const post = getEntityRecord( 'postType', postType, postId );
124
194
  const restData = post?.newspack_author_info || null;
125
- return { capAuthors: null, restAuthors: restData, isCapAvailable: restData ? true : isCapStoreAvailable };
195
+ return {
196
+ legacyCapAuthors: null,
197
+ coauthorTermIdsKey: null,
198
+ restAuthors: restData,
199
+ isCapAvailable: restData ? true : isLegacyCapStoreAvailable,
200
+ };
126
201
  }
127
202
 
128
- return { capAuthors: null, restAuthors: null, isCapAvailable: isCapStoreAvailable };
203
+ // Legacy CAP: read from cap/authors store for real-time updates.
204
+ if ( isLegacyCapStoreAvailable ) {
205
+ const rawCapAuthors = postId ? capStore.getAuthors( postId ) : null;
206
+ return { legacyCapAuthors: rawCapAuthors, coauthorTermIdsKey: null, restAuthors: null, isCapAvailable: true };
207
+ }
208
+
209
+ // New CAP: read taxonomy term IDs from the post entity. Serialize to a stable string
210
+ // inside the selector so a fresh array reference from `getEditedPostAttribute` doesn't
211
+ // trigger spurious re-renders. `null` = attribute not loaded; `''` = loaded but empty;
212
+ // CSV (e.g. `'471,488'`) = term IDs assigned.
213
+ const termIds = editorStore?.getEditedPostAttribute?.( 'coauthors' );
214
+ if ( Array.isArray( termIds ) ) {
215
+ const validIds = termIds.map( Number ).filter( Number.isInteger );
216
+ return {
217
+ legacyCapAuthors: null,
218
+ coauthorTermIdsKey: validIds.length === 0 ? '' : validIds.join( ',' ),
219
+ restAuthors: null,
220
+ isCapAvailable: true,
221
+ };
222
+ }
223
+
224
+ // No CAP detected (plugin inactive).
225
+ return { legacyCapAuthors: null, coauthorTermIdsKey: null, restAuthors: null, isCapAvailable: false };
129
226
  },
130
227
  [ postId, postType, skip ]
131
228
  );
132
229
 
230
+ // Resolve coauthor term IDs to author data via REST (new CAP path only).
231
+ // Uses a module-level cache + in-flight dedup so concurrent mounts share a single fetch.
232
+ const [ resolvedTermAuthors, setResolvedTermAuthors ] = useState( EMPTY_AUTHORS );
233
+ const [ asyncIsLoading, setAsyncIsLoading ] = useState( false );
234
+ // Tracks the last `coauthorTermIdsKey` the effect has processed. Used to derive a synchronous
235
+ // pre-flight loading state during the render between key change and effect firing — without
236
+ // it, consumers briefly see "term IDs assigned but isLoading false" and may pick wrong defaults.
237
+ const [ processedKey, setProcessedKey ] = useState( null );
238
+
239
+ const hasCoauthorTermIds = coauthorTermIdsKey !== null && coauthorTermIdsKey !== '';
240
+ const isPreFlight = hasCoauthorTermIds && processedKey !== coauthorTermIdsKey;
241
+ const isLoading = isPreFlight || asyncIsLoading;
242
+
243
+ useEffect( () => {
244
+ if ( skip || coauthorTermIdsKey === null ) {
245
+ // Use the module-level frozen empty array so the identity is stable
246
+ // and setState is a no-op when already empty.
247
+ setResolvedTermAuthors( EMPTY_AUTHORS );
248
+ setAsyncIsLoading( false );
249
+ setProcessedKey( coauthorTermIdsKey );
250
+ return;
251
+ }
252
+ const ids = coauthorTermIdsKey === '' ? [] : coauthorTermIdsKey.split( ',' ).map( Number ).filter( Number.isInteger );
253
+ if ( ids.length === 0 ) {
254
+ setResolvedTermAuthors( EMPTY_AUTHORS );
255
+ setAsyncIsLoading( false );
256
+ setProcessedKey( coauthorTermIdsKey );
257
+ return;
258
+ }
259
+
260
+ // If everything is already cached, resolve synchronously without flipping loading state.
261
+ const allCached = ids.every( id => id in coauthorDetailsCache );
262
+ if ( allCached ) {
263
+ const resolved = ids.map( id => coauthorDetailsCache[ id ] ).filter( author => author && typeof author === 'object' );
264
+ setResolvedTermAuthors( resolved.length === 0 ? EMPTY_AUTHORS : resolved );
265
+ setAsyncIsLoading( false );
266
+ setProcessedKey( coauthorTermIdsKey );
267
+ return;
268
+ }
269
+
270
+ let cancelled = false;
271
+ setAsyncIsLoading( true );
272
+ fetchCoauthorsByTermIds( ids ).then( () => {
273
+ if ( cancelled ) {
274
+ return;
275
+ }
276
+ const resolved = ids.map( id => coauthorDetailsCache[ id ] ).filter( author => author && typeof author === 'object' );
277
+ setResolvedTermAuthors( resolved.length === 0 ? EMPTY_AUTHORS : resolved );
278
+ setAsyncIsLoading( false );
279
+ setProcessedKey( coauthorTermIdsKey );
280
+ } );
281
+
282
+ return () => {
283
+ cancelled = true;
284
+ };
285
+ }, [ coauthorTermIdsKey, skip ] );
286
+
133
287
  // Map raw store data to our normalized author format.
134
288
  const authors = useMemo( () => {
135
- // CAP store authors: { id, label, display, value, userType }
136
- if ( capAuthors && capAuthors.length > 0 ) {
137
- return capAuthors.map( author => ( {
289
+ // Legacy CAP store authors: { id, label, display, value, userType }
290
+ if ( legacyCapAuthors && legacyCapAuthors.length > 0 ) {
291
+ return legacyCapAuthors.map( author => ( {
138
292
  id: author.id,
139
293
  display_name: author.display || author.value || author.label,
140
294
  user_nicename: author.value,
@@ -142,7 +296,21 @@ export function useCoAuthors( postId, postType = 'post', skip = false ) {
142
296
  } ) );
143
297
  }
144
298
 
145
- // REST API authors from newspack_author_info.
299
+ // New CAP authors resolved from term IDs.
300
+ // REST shape: { id (string), termId, displayName, userNicename, userType, ... }
301
+ // Coerce `id` to Number at the source so consumers and the in-file dedupe (`other.id === author.id`)
302
+ // see a consistent shape across legacy CAP (numeric id) and new CAP (string id).
303
+ if ( resolvedTermAuthors && resolvedTermAuthors.length > 0 ) {
304
+ return resolvedTermAuthors.map( author => ( {
305
+ id: Number( author.id ),
306
+ termId: author.termId,
307
+ display_name: author.displayName,
308
+ user_nicename: author.userNicename,
309
+ isGuest: author.userType === 'guest-author',
310
+ } ) );
311
+ }
312
+
313
+ // REST API authors from newspack_author_info (Query Loop).
146
314
  if ( restAuthors && Array.isArray( restAuthors ) && restAuthors.length > 0 ) {
147
315
  return restAuthors.map( author => ( {
148
316
  id: author.id,
@@ -155,7 +323,7 @@ export function useCoAuthors( postId, postType = 'post', skip = false ) {
155
323
  }
156
324
 
157
325
  return [];
158
- }, [ capAuthors, restAuthors ] );
326
+ }, [ legacyCapAuthors, resolvedTermAuthors, restAuthors ] );
159
327
 
160
328
  // Fetch avatar URLs from the CAP REST API for authors that need it.
161
329
  // The CAP store strips avatar data via formatAuthorData(), so we need
@@ -216,5 +384,5 @@ export function useCoAuthors( postId, postType = 'post', skip = false ) {
216
384
  return { ...author, avatar_urls: urls };
217
385
  } );
218
386
 
219
- return { authors: authorsWithAvatars, isCapAvailable };
387
+ return { authors: authorsWithAvatars, isCapAvailable, isLoading, hasCoauthorTermIds };
220
388
  }
@@ -12,7 +12,7 @@ import apiFetch from '@wordpress/api-fetch';
12
12
  /**
13
13
  * Internal dependencies
14
14
  */
15
- import { useCoAuthors, resetGuestAvatarCacheForTests } from './use-coauthors';
15
+ import { useCoAuthors, resetGuestAvatarCacheForTests, resetCoauthorDetailsCacheForTests } from './use-coauthors';
16
16
 
17
17
  jest.mock( '@wordpress/data', () => ( {
18
18
  useSelect: jest.fn(),
@@ -27,13 +27,16 @@ jest.mock( '@wordpress/api-fetch', () => jest.fn() );
27
27
  /**
28
28
  * Helper to create a mock select function for multiple stores.
29
29
  *
30
- * @param {Object} options Mock options.
31
- * @param {Object} options.capStore CAP store mock (null if unavailable).
32
- * @param {number} options.currentPostId Currently-edited post ID.
33
- * @param {Object} options.entityRecords Map of postId -> post entity record.
30
+ * @param {Object} options Mock options.
31
+ * @param {Object} options.capStore Legacy CAP store mock (null if unavailable — i.e., new CAP).
32
+ * @param {number} options.currentPostId Currently-edited post ID.
33
+ * @param {Object} options.entityRecords Map of postId -> post entity record (used for Query Loop).
34
+ * @param {number[]} options.coauthorTermIds `coauthors` post attribute (new CAP).
35
+ * `undefined` means attribute not set (legacy CAP / plugin missing);
36
+ * `[]` means the attribute is present but empty.
34
37
  * @return {Function} Mock select function.
35
38
  */
36
- const createMockSelect = ( { capStore = null, currentPostId = 123, entityRecords = {} } = {} ) => {
39
+ const createMockSelect = ( { capStore = null, currentPostId = 123, entityRecords = {}, coauthorTermIds } = {} ) => {
37
40
  return storeName => {
38
41
  if ( storeName === 'cap/authors' ) {
39
42
  return capStore;
@@ -41,6 +44,7 @@ const createMockSelect = ( { capStore = null, currentPostId = 123, entityRecords
41
44
  if ( storeName === 'core/editor' ) {
42
45
  return {
43
46
  getCurrentPostId: () => currentPostId,
47
+ getEditedPostAttribute: attr => ( attr === 'coauthors' ? coauthorTermIds : undefined ),
44
48
  };
45
49
  }
46
50
  if ( storeName === 'core' ) {
@@ -56,6 +60,7 @@ describe( 'useCoAuthors', () => {
56
60
  beforeEach( () => {
57
61
  jest.clearAllMocks();
58
62
  resetGuestAvatarCacheForTests();
63
+ resetCoauthorDetailsCacheForTests();
59
64
  // Default: apiFetch resolves with empty object (no avatar_urls).
60
65
  apiFetch.mockResolvedValue( {} );
61
66
  } );
@@ -181,6 +186,227 @@ describe( 'useCoAuthors', () => {
181
186
  } );
182
187
  } );
183
188
 
189
+ describe( 'currently-edited post (new CAP, term ID resolution)', () => {
190
+ const NEW_CAP_RESPONSE = [
191
+ { id: '1', termId: 471, displayName: 'Jane Doe', userNicename: 'jane-doe', userType: 'wpuser', login: 'jane', email: 'jane@x.com' },
192
+ { id: '2', termId: 488, displayName: 'John Smith', userNicename: 'john-smith', userType: 'wpuser', login: 'john', email: 'john@x.com' },
193
+ { id: '1591', termId: 483, displayName: 'External', userNicename: 'external', userType: 'guest-author', login: 'external', email: '' },
194
+ ];
195
+
196
+ it( 'should resolve term IDs via REST when legacy CAP store is absent', async () => {
197
+ apiFetch.mockResolvedValueOnce( NEW_CAP_RESPONSE );
198
+
199
+ useSelect.mockImplementation( callback =>
200
+ callback(
201
+ createMockSelect( {
202
+ capStore: null, // legacy CAP not registered
203
+ currentPostId: 123,
204
+ coauthorTermIds: [ 471, 488, 483 ],
205
+ } )
206
+ )
207
+ );
208
+
209
+ const { result } = renderHook( () => useCoAuthors( 123 ) );
210
+
211
+ await waitFor( () => {
212
+ expect( result.current.authors ).toHaveLength( 3 );
213
+ } );
214
+
215
+ expect( apiFetch ).toHaveBeenCalledWith( {
216
+ path: '/coauthors/v1/authors-by-term-ids?ids=471,488,483',
217
+ } );
218
+ expect( result.current.authors[ 0 ] ).toMatchObject( {
219
+ id: 1,
220
+ termId: 471,
221
+ display_name: 'Jane Doe',
222
+ user_nicename: 'jane-doe',
223
+ isGuest: false,
224
+ } );
225
+ expect( result.current.authors[ 2 ].isGuest ).toBe( true );
226
+ expect( result.current.isCapAvailable ).toBe( true );
227
+ expect( result.current.hasCoauthorTermIds ).toBe( true );
228
+ } );
229
+
230
+ it( 'should return empty authors when coauthors attribute is an empty array', async () => {
231
+ useSelect.mockImplementation( callback =>
232
+ callback(
233
+ createMockSelect( {
234
+ capStore: null,
235
+ currentPostId: 123,
236
+ coauthorTermIds: [],
237
+ } )
238
+ )
239
+ );
240
+
241
+ const { result } = renderHook( () => useCoAuthors( 123 ) );
242
+
243
+ await act( () => Promise.resolve() );
244
+
245
+ expect( result.current.authors ).toEqual( [] );
246
+ expect( result.current.isCapAvailable ).toBe( true );
247
+ expect( apiFetch ).not.toHaveBeenCalled();
248
+ } );
249
+
250
+ it( 'should mark isCapAvailable false when neither legacy store nor coauthors attribute are present', () => {
251
+ useSelect.mockImplementation( callback =>
252
+ callback(
253
+ createMockSelect( {
254
+ capStore: null,
255
+ currentPostId: 123,
256
+ // coauthorTermIds undefined
257
+ } )
258
+ )
259
+ );
260
+
261
+ const { result } = renderHook( () => useCoAuthors( 123 ) );
262
+
263
+ expect( result.current.isCapAvailable ).toBe( false );
264
+ expect( result.current.authors ).toEqual( [] );
265
+ } );
266
+
267
+ it( 'should prefer legacy CAP store when both legacy store and coauthors attribute are present', async () => {
268
+ // This simulates an in-flight upgrade scenario where old JS is still loaded.
269
+ // Legacy path should win to keep behavior consistent.
270
+ useSelect.mockImplementation( callback =>
271
+ callback(
272
+ createMockSelect( {
273
+ capStore: {
274
+ getAuthors: () => [ { id: 99, display: 'Legacy', value: 'legacy', userType: 'wpuser' } ],
275
+ },
276
+ currentPostId: 123,
277
+ coauthorTermIds: [ 471 ],
278
+ } )
279
+ )
280
+ );
281
+
282
+ const { result } = renderHook( () => useCoAuthors( 123 ) );
283
+
284
+ await act( () => Promise.resolve() );
285
+
286
+ expect( apiFetch ).not.toHaveBeenCalled();
287
+ expect( result.current.authors ).toEqual( [ { id: 99, display_name: 'Legacy', user_nicename: 'legacy', isGuest: false } ] );
288
+ } );
289
+
290
+ it( 'should cache results so subsequent mounts skip the REST call', async () => {
291
+ apiFetch.mockResolvedValue( NEW_CAP_RESPONSE );
292
+
293
+ useSelect.mockImplementation( callback =>
294
+ callback(
295
+ createMockSelect( {
296
+ capStore: null,
297
+ currentPostId: 123,
298
+ coauthorTermIds: [ 471, 488, 483 ],
299
+ } )
300
+ )
301
+ );
302
+
303
+ const { result: result1 } = renderHook( () => useCoAuthors( 123 ) );
304
+
305
+ await waitFor( () => {
306
+ expect( result1.current.authors ).toHaveLength( 3 );
307
+ } );
308
+
309
+ const callsAfterFirstMount = apiFetch.mock.calls.length;
310
+
311
+ // A later mount re-uses the cache and makes no additional calls.
312
+ const { result: result2 } = renderHook( () => useCoAuthors( 123 ) );
313
+
314
+ await waitFor( () => {
315
+ expect( result2.current.authors ).toHaveLength( 3 );
316
+ } );
317
+
318
+ expect( apiFetch ).toHaveBeenCalledTimes( callsAfterFirstMount );
319
+ } );
320
+
321
+ it( 'should silently drop term IDs that do not resolve', async () => {
322
+ // REST returns only 2 of 3 requested IDs (the third was deleted).
323
+ apiFetch.mockResolvedValueOnce( [ NEW_CAP_RESPONSE[ 0 ], NEW_CAP_RESPONSE[ 1 ] ] );
324
+
325
+ useSelect.mockImplementation( callback =>
326
+ callback(
327
+ createMockSelect( {
328
+ capStore: null,
329
+ currentPostId: 123,
330
+ coauthorTermIds: [ 471, 488, 99999 ],
331
+ } )
332
+ )
333
+ );
334
+
335
+ const { result } = renderHook( () => useCoAuthors( 123 ) );
336
+
337
+ await waitFor( () => {
338
+ expect( result.current.authors ).toHaveLength( 2 );
339
+ } );
340
+
341
+ expect( result.current.authors.map( a => a.termId ) ).toEqual( [ 471, 488 ] );
342
+ } );
343
+
344
+ it( 'should handle REST errors without blocking the component', async () => {
345
+ apiFetch.mockRejectedValueOnce( new Error( 'Server error' ) );
346
+
347
+ useSelect.mockImplementation( callback =>
348
+ callback(
349
+ createMockSelect( {
350
+ capStore: null,
351
+ currentPostId: 123,
352
+ coauthorTermIds: [ 471 ],
353
+ } )
354
+ )
355
+ );
356
+
357
+ const { result } = renderHook( () => useCoAuthors( 123 ) );
358
+
359
+ // Allow the failed promise to settle.
360
+ await act( () => Promise.resolve().then( () => Promise.resolve() ) );
361
+
362
+ expect( result.current.authors ).toEqual( [] );
363
+ expect( result.current.isCapAvailable ).toBe( true );
364
+ // Term IDs are assigned but resolution returned empty — surface this so consumers
365
+ // don't silently fall back to `post_author` and credit the wrong person.
366
+ expect( result.current.hasCoauthorTermIds ).toBe( true );
367
+ } );
368
+
369
+ it( 'should allow retry after a transient REST error (no negative caching on catch)', async () => {
370
+ const RETRY_RESPONSE = [
371
+ { id: '1', termId: 471, displayName: 'Jane', userNicename: 'jane', userType: 'wpuser', login: 'jane', email: '' },
372
+ ];
373
+
374
+ useSelect.mockImplementation( callback =>
375
+ callback(
376
+ createMockSelect( {
377
+ capStore: null,
378
+ currentPostId: 123,
379
+ coauthorTermIds: [ 471 ],
380
+ } )
381
+ )
382
+ );
383
+
384
+ // First attempt fails with a transient error; cache should NOT be poisoned.
385
+ apiFetch.mockRejectedValueOnce( new Error( 'Transient 500' ) );
386
+
387
+ const { result: result1, unmount: unmount1 } = renderHook( () => useCoAuthors( 123 ) );
388
+
389
+ await act( () => Promise.resolve().then( () => Promise.resolve() ) );
390
+
391
+ expect( result1.current.authors ).toEqual( [] );
392
+ expect( apiFetch ).toHaveBeenCalledTimes( 1 );
393
+
394
+ unmount1();
395
+
396
+ // Second attempt succeeds — we should actually re-fetch (not a cached failure).
397
+ apiFetch.mockResolvedValueOnce( RETRY_RESPONSE );
398
+
399
+ const { result: result2 } = renderHook( () => useCoAuthors( 123 ) );
400
+
401
+ await waitFor( () => {
402
+ expect( result2.current.authors ).toHaveLength( 1 );
403
+ } );
404
+
405
+ expect( apiFetch ).toHaveBeenCalledTimes( 2 );
406
+ expect( result2.current.authors[ 0 ].display_name ).toBe( 'Jane' );
407
+ } );
408
+ } );
409
+
184
410
  describe( 'Query Loop context (uses REST API)', () => {
185
411
  it( 'should use REST API when postId differs from currently-edited post', () => {
186
412
  const restAuthors = [
@@ -15,14 +15,21 @@
15
15
  // Used by: author-profile-social, author-social-link blocks
16
16
  // Hex fallbacks from newspack-block-theme theme.json defaults.
17
17
  @mixin socialButton {
18
- background: var(--wp--preset--color--base-2, #f0f0f0);
19
18
  border-radius: 50%; // Circular
20
- color: var(--wp--preset--color--contrast, #1e1e1e);
21
19
  display: grid;
22
20
  place-items: center;
23
21
  text-decoration: none;
24
- transition: background 125ms ease-in-out, color 125ms ease-in-out;
25
22
 
23
+ // Use :not(.wp-element-button) to beat wp-elements parent link colour rules,
24
+ // which use :where() and therefore have lower specificity.
25
+ &:not(.wp-element-button) {
26
+ background: var(--icon-background, var(--wp--preset--color--base-2, #f0f0f0));
27
+ color: var(--icon-color, var(--wp--preset--color--contrast, #1e1e1e));
28
+ }
29
+
30
+ @media not (prefers-reduced-motion) {
31
+ transition: transform 100ms ease;
32
+ }
26
33
  // Sizing - makes circular by using equal width/height
27
34
  // --icon-size controls the circle diameter (e.g., 36px)
28
35
  // Icon inside is 2/3 of circle size (e.g., 24px icon in 36px circle)
@@ -32,9 +39,10 @@
32
39
  padding: 0;
33
40
 
34
41
  &:hover {
35
- background: var(--wp--preset--color--base-3, #ddd);
36
- color: var(--wp--preset--color--contrast, #1e1e1e);
42
+ background: var(--icon-background, var(--wp--preset--color--base-2, #f0f0f0));
43
+ color: var(--icon-color, var(--wp--preset--color--contrast, #1e1e1e));
37
44
  text-decoration: none;
45
+ transform: scale(1.1);
38
46
  }
39
47
 
40
48
  &:focus {
@@ -1,6 +1,3 @@
1
- /* eslint-disable jsx-a11y/click-events-have-key-events */
2
- /* eslint-disable jsx-a11y/no-static-element-interactions */
3
-
4
1
  /**
5
2
  * WordPress dependencies
6
3
  */
@@ -17,11 +14,15 @@ import classNames from 'classnames';
17
14
  */
18
15
  import './style.scss';
19
16
 
20
- const Accordion = ( { children, title } ) => {
21
- const [ isOpen, setIsOpen ] = useState( false );
17
+ const Accordion = ( { children, title, defaultOpen = false } ) => {
18
+ const [ isOpen, setIsOpen ] = useState( defaultOpen );
22
19
  return (
23
- <details className={ classNames( 'newspack-accordion', { 'newspack-accordion--is-open': isOpen } ) }>
24
- <summary onClick={ () => setIsOpen( ! isOpen ) }>
20
+ <details
21
+ className={ classNames( 'newspack-accordion', { 'newspack-accordion--is-open': isOpen } ) }
22
+ open={ isOpen }
23
+ onToggle={ e => setIsOpen( e.currentTarget.open ) }
24
+ >
25
+ <summary>
25
26
  { title }
26
27
  <Icon className="newspack-accordion__icon" icon={ chevronRight } size={ 24 } />
27
28
  </summary>
@@ -39,6 +39,9 @@ const ActionCard = ( {
39
39
  heading = 2,
40
40
  description,
41
41
  handoff,
42
+ handoffUrl,
43
+ bannerText,
44
+ bannerButtonText,
42
45
  editLink,
43
46
  href,
44
47
  notification,
@@ -187,7 +190,18 @@ const ActionCard = ( {
187
190
  { actionContent && actionContent }
188
191
  { actionText &&
189
192
  ( handoff ? (
190
- <Handoff plugin={ handoff } editLink={ editLink } compact isLink>
193
+ <Handoff
194
+ plugin={ handoff }
195
+ editLink={ editLink }
196
+ bannerText={ bannerText }
197
+ bannerButtonText={ bannerButtonText }
198
+ compact
199
+ isLink
200
+ >
201
+ { actionText }
202
+ </Handoff>
203
+ ) : handoffUrl ? (
204
+ <Handoff url={ handoffUrl } bannerText={ bannerText } bannerButtonText={ bannerButtonText } compact isLink>
191
205
  { actionText }
192
206
  </Handoff>
193
207
  ) : onClick || hasInternalLink ? (
@@ -8,9 +8,11 @@ import './style.scss';
8
8
  */
9
9
  import classnames from 'classnames';
10
10
 
11
+ export type BadgeLevel = 'default' | 'info' | 'success' | 'warning' | 'error';
12
+
11
13
  type BadgeProps = {
12
14
  text: string;
13
- level?: 'default' | 'info' | 'success' | 'warning' | 'error';
15
+ level?: BadgeLevel;
14
16
  };
15
17
 
16
18
  /**