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.
- package/dist/cjs/action-card/index.js +21 -12
- package/dist/cjs/action-card/style.scss +25 -2
- package/dist/cjs/autocomplete-tokenfield/index.js +2 -2
- package/dist/cjs/autocomplete-with-latest-posts/index.js +289 -0
- package/dist/cjs/autocomplete-with-latest-posts/style.scss +150 -0
- package/dist/cjs/badge/index.js +32 -0
- package/dist/cjs/badge/style.scss +36 -0
- package/dist/cjs/button/index.js +2 -0
- package/dist/cjs/card/core-card.js +141 -0
- package/dist/cjs/card/index.js +33 -0
- package/dist/cjs/card/style-core.scss +227 -0
- package/dist/cjs/card/style.scss +2 -2
- package/dist/cjs/card-settings-group/index.js +49 -0
- package/dist/cjs/card-settings-group/style.scss +33 -0
- package/dist/cjs/card-sortable-list/index.js +390 -0
- package/dist/cjs/card-sortable-list/style.scss +48 -0
- package/dist/cjs/confirm-dialog/index.js +59 -0
- package/dist/cjs/divider/index.js +42 -0
- package/dist/cjs/divider/style.scss +51 -0
- package/dist/cjs/form-token-field/style.scss +0 -5
- package/dist/cjs/grid/index.js +2 -1
- package/dist/cjs/grid/style.scss +63 -9
- package/dist/cjs/index.js +42 -0
- package/dist/cjs/modal/index.js +11 -4
- package/dist/cjs/modal/style.scss +43 -13
- package/dist/cjs/newspack-icon/style.scss +5 -3
- package/dist/cjs/section-header/index.js +76 -17
- package/dist/cjs/section-header/style.scss +109 -4
- package/dist/cjs/style.scss +2 -0
- package/dist/cjs/waiting/index.js +3 -1
- package/dist/cjs/waiting/style.scss +4 -0
- package/dist/cjs/web-preview/index.js +98 -39
- package/dist/cjs/web-preview/style.scss +20 -53
- package/dist/cjs/with-wizard/index.js +1 -1
- package/dist/cjs/with-wizard/style.scss +24 -6
- package/dist/cjs/with-wizard-screen/style.scss +39 -10
- package/dist/cjs/wizard/components/WizardSnackbar.js +70 -0
- package/dist/cjs/wizard/components/style.scss +48 -0
- package/dist/cjs/wizard/index.js +97 -16
- package/dist/cjs/wizard/store/index.js +46 -0
- package/dist/esm/action-card/index.js +21 -12
- package/dist/esm/action-card/style.scss +25 -2
- package/dist/esm/autocomplete-tokenfield/index.js +2 -2
- package/dist/esm/autocomplete-with-latest-posts/index.js +281 -0
- package/dist/esm/autocomplete-with-latest-posts/style.scss +150 -0
- package/dist/esm/badge/index.js +24 -0
- package/dist/esm/badge/style.scss +36 -0
- package/dist/esm/button/index.js +2 -0
- package/dist/esm/card/core-card.js +133 -0
- package/dist/esm/card/index.js +33 -0
- package/dist/esm/card/style-core.scss +227 -0
- package/dist/esm/card/style.scss +2 -2
- package/dist/esm/card-settings-group/index.js +42 -0
- package/dist/esm/card-settings-group/style.scss +33 -0
- package/dist/esm/card-sortable-list/index.js +381 -0
- package/dist/esm/card-sortable-list/style.scss +48 -0
- package/dist/esm/confirm-dialog/index.js +49 -0
- package/dist/esm/divider/index.js +34 -0
- package/dist/esm/divider/style.scss +51 -0
- package/dist/esm/form-token-field/style.scss +0 -5
- package/dist/esm/grid/index.js +2 -1
- package/dist/esm/grid/style.scss +63 -9
- package/dist/esm/index.js +6 -0
- package/dist/esm/modal/index.js +11 -4
- package/dist/esm/modal/style.scss +43 -13
- package/dist/esm/newspack-icon/style.scss +5 -3
- package/dist/esm/section-header/index.js +75 -18
- package/dist/esm/section-header/style.scss +109 -4
- package/dist/esm/style.scss +2 -0
- package/dist/esm/waiting/index.js +3 -1
- package/dist/esm/waiting/style.scss +4 -0
- package/dist/esm/web-preview/index.js +101 -42
- package/dist/esm/web-preview/style.scss +20 -53
- package/dist/esm/with-wizard/index.js +1 -1
- package/dist/esm/with-wizard/style.scss +24 -6
- package/dist/esm/with-wizard-screen/style.scss +39 -10
- package/dist/esm/wizard/components/WizardSnackbar.js +64 -0
- package/dist/esm/wizard/components/style.scss +48 -0
- package/dist/esm/wizard/index.js +101 -20
- package/dist/esm/wizard/store/index.js +46 -0
- package/package.json +5 -6
- package/shared/author-context.js +26 -0
- package/shared/hooks/use-coauthors.js +220 -0
- package/shared/hooks/use-coauthors.test.js +624 -0
- package/shared/hooks/use-custom-byline.js +47 -0
- package/shared/hooks/use-custom-byline.test.js +133 -0
- package/shared/scss/_mixins.scss +46 -0
- package/src/action-card/action-card.d.ts +7 -3
- package/src/action-card/index.js +24 -11
- package/src/action-card/style.scss +25 -2
- package/src/autocomplete-tokenfield/index.js +2 -2
- package/src/autocomplete-with-latest-posts/index.js +290 -0
- package/src/autocomplete-with-latest-posts/style.scss +150 -0
- package/src/badge/index.tsx +24 -0
- package/src/badge/style.scss +36 -0
- package/src/button/index.tsx +3 -2
- package/src/card/core-card.js +147 -0
- package/src/card/index.js +39 -1
- package/src/card/style-core.scss +227 -0
- package/src/card/style.scss +2 -2
- package/src/card-settings-group/README.md +43 -0
- package/src/card-settings-group/index.tsx +53 -0
- package/src/card-settings-group/style.scss +33 -0
- package/src/card-sortable-list/README.md +51 -0
- package/src/card-sortable-list/index.tsx +412 -0
- package/src/card-sortable-list/style.scss +48 -0
- package/src/confirm-dialog/index.tsx +64 -0
- package/src/divider/README.md +25 -0
- package/src/divider/index.js +31 -0
- package/src/divider/style.scss +51 -0
- package/src/form-token-field/style.scss +0 -5
- package/src/grid/index.tsx +2 -1
- package/src/grid/style.scss +63 -9
- package/src/index.js +6 -0
- package/src/modal/index.js +12 -3
- package/src/modal/style.scss +43 -13
- package/src/newspack-icon/style.scss +5 -3
- package/src/section-header/index.js +72 -13
- package/src/section-header/style.scss +109 -4
- package/src/style.scss +2 -0
- package/src/waiting/index.js +2 -1
- package/src/waiting/style.scss +4 -0
- package/src/web-preview/index.js +90 -35
- package/src/web-preview/style.scss +20 -53
- package/src/with-wizard/index.js +1 -1
- package/src/with-wizard/style.scss +24 -6
- package/src/with-wizard-screen/style.scss +39 -10
- package/src/wizard/components/WizardSnackbar.js +58 -0
- package/src/wizard/components/style.scss +48 -0
- package/src/wizard/index.js +108 -14
- 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
|
+
} );
|
package/shared/scss/_mixins.scss
CHANGED
|
@@ -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?: (
|
|
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?: (
|
|
39
|
-
dragWrapperRef?: React.MutableRefObject<
|
|
42
|
+
onDragCallback?: (index: number) => void;
|
|
43
|
+
dragWrapperRef?: React.MutableRefObject<HTMLDivElement | null>;
|
|
40
44
|
}
|
package/src/action-card/index.js
CHANGED
|
@@ -29,13 +29,14 @@ import classnames from 'classnames';
|
|
|
29
29
|
*/
|
|
30
30
|
const ActionCard = ( {
|
|
31
31
|
badge,
|
|
32
|
-
badgeLevel
|
|
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(
|
|
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
|
|
127
|
+
const cardContent = (
|
|
117
128
|
<>
|
|
118
129
|
<div className="newspack-action-card__region newspack-action-card__region-top">
|
|
119
|
-
{ toggleOnChange &&
|
|
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
|
-
<
|
|
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
|
-
</
|
|
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 }
|
|
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
|
-
|
|
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
|
-
|
|
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-
|
|
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 =
|
|
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 => (
|
|
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;
|