@elementor/editor-site-navigation 0.6.2 → 0.8.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.
@@ -0,0 +1,48 @@
1
+ import * as React from 'react';
2
+ import { render } from '@testing-library/react';
3
+ import { Page } from '../../../../types';
4
+ import PagesCollapsibleList from '../pages-collapsible-list';
5
+
6
+ jest.mock( '@elementor/editor-documents' );
7
+
8
+ describe( '@elementor/editor-site-navigation - PagesCollapsibleList', () => {
9
+ const mockPosts: Page[] = [
10
+ { id: 1, type: 'page', title: 'Home', status: 'draft' },
11
+ { id: 2, type: 'page', title: 'About', status: 'publish' },
12
+ { id: 3, type: 'page', title: 'Services', status: 'publish', isHome: true },
13
+ { id: 4, type: 'page', title: 'Contact', status: 'draft' },
14
+ { id: 5, type: 'page', title: 'FAQ', status: 'publish' },
15
+ ];
16
+
17
+ afterAll( () => {
18
+ jest.clearAllMocks();
19
+ } );
20
+
21
+ it( 'should render closed list', () => {
22
+ // Act.
23
+ const { getByText, queryByText } = render(
24
+ <PagesCollapsibleList pages={ mockPosts } isOpenByDefault={ false } />,
25
+ );
26
+
27
+ // Assert.
28
+ const label = getByText( `Pages (${ mockPosts.length })` );
29
+ expect( label ).toBeInTheDocument();
30
+
31
+ const postInList = queryByText( 'Services' );
32
+ expect( postInList ).toBeNull();
33
+ } );
34
+
35
+ it( 'should render open list', () => {
36
+ // Act.
37
+ const { getByText } = render(
38
+ <PagesCollapsibleList pages={ mockPosts } isOpenByDefault={ true } />,
39
+ );
40
+
41
+ // Assert.
42
+ const label = getByText( `Pages (${ mockPosts.length })` );
43
+ expect( label ).toBeInTheDocument();
44
+
45
+ const postInList = getByText( 'Services' );
46
+ expect( postInList ).toBeInTheDocument();
47
+ } );
48
+ } );
@@ -0,0 +1,63 @@
1
+ import * as React from 'react';
2
+ import { ComponentType, PropsWithChildren, useState } from 'react';
3
+ import { Collapse, IconButton, List, ListItem, ListItemIcon, ListItemText, styled, SvgIconProps } from '@elementor/ui';
4
+ import { ChevronDownIcon } from '@elementor/icons';
5
+
6
+ type Props = {
7
+ label: string
8
+ Icon: ComponentType;
9
+ isOpenByDefault?: boolean;
10
+ }
11
+
12
+ interface RotateIconProps extends SvgIconProps {
13
+ isOpen: boolean;
14
+ }
15
+
16
+ // TODO 21/06/2023 : Should replace this with future Rotate component that will be implemented in elementor-ui
17
+ const RotateIcon = styled( ChevronDownIcon, {
18
+ shouldForwardProp: ( prop ) => prop !== 'isOpen',
19
+ } )<RotateIconProps>( ( { theme, isOpen } ) => ( {
20
+ transform: isOpen ? 'rotate(0deg)' : 'rotate(-90deg)',
21
+ transition: theme.transitions.create( 'transform', {
22
+ duration: theme.transitions.duration.standard,
23
+ } ),
24
+ } ) );
25
+
26
+ export default function CollapsibleList(
27
+ {
28
+ label,
29
+ Icon,
30
+ isOpenByDefault = false,
31
+ children,
32
+ }: PropsWithChildren<Props>
33
+ ) {
34
+ const [ isOpen, setIsOpen ] = useState( isOpenByDefault );
35
+
36
+ return (
37
+ <>
38
+ <ListItem disableGutters>
39
+ <ListItemIcon>
40
+ <IconButton
41
+ onClick={ () => setIsOpen( ( prev ) => ! prev ) }
42
+ sx={ { color: 'inherit' } }
43
+ size="small"
44
+ >
45
+ <RotateIcon fontSize="small" isOpen={ isOpen } />
46
+ </IconButton>
47
+ </ListItemIcon>
48
+ <ListItemIcon>
49
+ <Icon />
50
+ </ListItemIcon>
51
+ <ListItemText>{ label }</ListItemText>
52
+ </ListItem>
53
+ <Collapse
54
+ in={ isOpen }
55
+ timeout="auto"
56
+ unmountOnExit>
57
+ <List dense>
58
+ { children }
59
+ </List>
60
+ </Collapse>
61
+ </>
62
+ );
63
+ }
@@ -0,0 +1,66 @@
1
+ import * as React from 'react';
2
+ import {
3
+ bindMenu,
4
+ bindTrigger,
5
+ ListItem,
6
+ ListItemButton,
7
+ ListItemIcon,
8
+ ListItemText,
9
+ ToggleButton,
10
+ usePopupState,
11
+ } from '@elementor/ui';
12
+ import { DotsVerticalIcon, HomeIcon } from '@elementor/icons';
13
+ import { Page } from '../../../types';
14
+ import { useActiveDocument, useNavigateToDocument } from '@elementor/editor-documents';
15
+ import PageTitleAndStatus from '../../shared/page-title-and-status';
16
+ import PageActionsMenu from '../actions-menu/page-actions-menu';
17
+
18
+ export default function PageListItem( { page }: { page: Page } ) {
19
+ const popupState = usePopupState( {
20
+ variant: 'popover',
21
+ popupId: 'page-actions',
22
+ } );
23
+
24
+ const activeDocument = useActiveDocument();
25
+ const navigateToDocument = useNavigateToDocument();
26
+
27
+ const isActive = activeDocument?.id === page.id;
28
+
29
+ return (
30
+ <>
31
+ <ListItem
32
+ disablePadding
33
+ secondaryAction={
34
+ <ToggleButton
35
+ value
36
+ color="secondary"
37
+ size="small"
38
+ selected={ popupState.isOpen }
39
+ { ...bindTrigger( popupState ) }
40
+ >
41
+ <DotsVerticalIcon fontSize="small" />
42
+ </ToggleButton>
43
+ }
44
+ >
45
+ <ListItemButton
46
+ selected={ isActive }
47
+ onClick={ () => navigateToDocument( page.id ) }
48
+ dense
49
+ >
50
+ <ListItemIcon />
51
+ <ListItemText
52
+ disableTypography={ true }
53
+ >
54
+ <PageTitleAndStatus page={ page } />
55
+ </ListItemText>
56
+ { page.isHome &&
57
+ <ListItemIcon>
58
+ <HomeIcon color="disabled" />
59
+ </ListItemIcon>
60
+ }
61
+ </ListItemButton>
62
+ </ListItem>
63
+ <PageActionsMenu page={ page } { ...bindMenu( popupState ) } />
64
+ </>
65
+ );
66
+ }
@@ -0,0 +1,24 @@
1
+ import * as React from 'react';
2
+ import CollapsibleList from './collapsible-list';
3
+ import { Page } from '../../../types';
4
+ import { PageTypeIcon } from '@elementor/icons';
5
+ import PageListItem from './page-list-item';
6
+
7
+ type Props = {
8
+ pages: Page[];
9
+ isOpenByDefault?: boolean;
10
+ }
11
+
12
+ export default function PagesCollapsibleList( { pages, isOpenByDefault = false }: Props ) {
13
+ const label = `Pages (${ pages.length })`; // TODO 21/06/2023 : This label should come from the backend
14
+
15
+ return (
16
+ <CollapsibleList
17
+ label={ label }
18
+ Icon={ PageTypeIcon }
19
+ isOpenByDefault={ isOpenByDefault }
20
+ >
21
+ { pages.map( ( page ) => <PageListItem key={ page.id } page={ page } /> ) }
22
+ </CollapsibleList>
23
+ );
24
+ }
@@ -0,0 +1,58 @@
1
+ import * as React from 'react';
2
+ import { Box, Button, Divider, Grid, List, Paper, ThemeProvider, Typography } from '@elementor/ui';
3
+ import { PlusIcon } from '@elementor/icons';
4
+ import { Page } from '../../types';
5
+ import PagesCollapsibleList from './pages-list/pages-collapsible-list';
6
+
7
+ // TODO: Remove once connected to real data fetching.
8
+ const mockPages: Page[] = [
9
+ {
10
+ id: 1,
11
+ type: 'page',
12
+ title: 'This is a very long title that somebody wrote, a very very long line',
13
+ status: 'pending approval',
14
+ },
15
+ { id: 2, type: 'page', title: 'About', status: 'publish' },
16
+ { id: 3, type: 'page', title: 'Services', status: 'publish', isHome: true },
17
+ { id: 4, type: 'page', title: 'Contact', status: 'draft' },
18
+ { id: 5, type: 'page', title: 'FAQ', status: 'publish' },
19
+ ];
20
+
21
+ const Shell = () => {
22
+ return (
23
+ <ThemeProvider colorScheme="light">
24
+ <Box sx={ { width: '100%', maxWidth: 360 } }>
25
+ <Paper>
26
+ <Grid
27
+ container
28
+ justifyContent="center"
29
+ alignItems="center"
30
+ sx={ { height: 51 } }>
31
+ <Typography variant="h6">Pages</Typography>
32
+ </Grid>
33
+ <Divider />
34
+ <Box
35
+ display="flex"
36
+ justifyContent="flex-end"
37
+ alignItems="center"
38
+ >
39
+ <Button
40
+ sx={ { mt: 4, mb: 4, mr: 5 } }
41
+ startIcon={ <PlusIcon /> }
42
+ >
43
+ Add New
44
+ </Button>
45
+ </Box>
46
+ <Box sx={ { width: '100%', maxWidth: 360 } }>
47
+ <List dense>
48
+ <PagesCollapsibleList pages={ mockPages } isOpenByDefault={ true } />
49
+ </List>
50
+ </Box>
51
+ <Divider />
52
+ </Paper>
53
+ </Box>
54
+ </ThemeProvider>
55
+ );
56
+ };
57
+
58
+ export default Shell;
@@ -0,0 +1,50 @@
1
+ import * as React from 'react';
2
+ import { Box, Typography } from '@elementor/ui';
3
+ import { Page } from '../../types';
4
+ import { useReverseHtmlEntities } from '../../hooks/use-reverse-html-entities';
5
+
6
+ const PageStatus = ( { status }: { status: string } ) => {
7
+ if ( 'publish' === status ) {
8
+ return null;
9
+ }
10
+
11
+ return (
12
+ <Typography
13
+ component="span"
14
+ variant="body1"
15
+ sx={ {
16
+ textTransform: 'capitalize',
17
+ fontStyle: 'italic',
18
+ whiteSpace: 'nowrap',
19
+ flexBasis: 'content',
20
+ } }
21
+ >
22
+ ({ status })
23
+ </Typography>
24
+ );
25
+ };
26
+
27
+ const PageTitle = ( { title }: { title: string } ) => {
28
+ const modifiedTitle = useReverseHtmlEntities( title );
29
+
30
+ return (
31
+ <Typography
32
+ component="span"
33
+ variant="body1"
34
+ noWrap
35
+ sx={ {
36
+ flexBasis: 'auto',
37
+ } }
38
+ >
39
+ { modifiedTitle }
40
+ </Typography>
41
+ );
42
+ };
43
+
44
+ export default function PageTitleAndStatus( { page }: { page: Page } ) {
45
+ return (
46
+ <Box display="flex">
47
+ <PageTitle title={ page.title } />&nbsp;<PageStatus status={ page.status } />
48
+ </Box>
49
+ );
50
+ }
@@ -0,0 +1,13 @@
1
+ export interface Post {
2
+ id: number;
3
+ title: string;
4
+ content: string;
5
+ }
6
+
7
+ export interface PostType {
8
+ name: string;
9
+ slug: string;
10
+ labels: Record<string, string>;
11
+ rest_base: string;
12
+ rest_namespace: string;
13
+ }
@@ -0,0 +1,64 @@
1
+ import { useState, useEffect, useMemo } from 'react';
2
+ import { PostType } from './types/interfaces';
3
+ import apiFetch from '@wordpress/api-fetch';
4
+
5
+ type PostTypesResponse = Record<string, PostType>;
6
+
7
+ // allowedPostTypes is only used to filter irrelevant types when fetching all post types.
8
+ // You can still use a specific post type name to fetch a post type not present in allowedPostTypes.
9
+ const allowedPostTypes = [ 'page' ];
10
+
11
+ const useFetchPostTypes = (
12
+ postTypeName?: string
13
+ ): ( PostType | null )[] => {
14
+ const [ postTypes, setPostTypes ] = useState<( PostType | null )[]>( [] );
15
+
16
+ useEffect( () => {
17
+ const fetchPostTypes = async () => {
18
+ try {
19
+ const response: PostTypesResponse = await apiFetch( {
20
+ path: '/wp/v2/types',
21
+ } );
22
+
23
+ if ( postTypeName ) {
24
+ const specificPostType = response[ postTypeName ];
25
+
26
+ setPostTypes( [
27
+ specificPostType
28
+ ? { ...specificPostType }
29
+ : null,
30
+ ] );
31
+ } else {
32
+ const filteredResponse: PostTypesResponse = {};
33
+
34
+ allowedPostTypes.forEach( ( type ) => {
35
+ if ( response[ type ] ) {
36
+ filteredResponse[ type ] = response[ type ];
37
+ }
38
+ } );
39
+
40
+ const filteredPostTypes: ( PostType | null )[] = Object.keys( filteredResponse ).map(
41
+ ( key ) => ( { ...filteredResponse[ key ] } )
42
+ );
43
+
44
+ setPostTypes( filteredPostTypes );
45
+ }
46
+ } catch ( error ) {
47
+ // eslint-disable-next-line no-console
48
+ console.error( 'Error fetching post types:', error );
49
+ }
50
+ };
51
+
52
+ fetchPostTypes();
53
+ }, [ postTypeName ] );
54
+
55
+ return postTypes;
56
+ };
57
+
58
+ export const usePostTypes = (
59
+ postTypeName?: string
60
+ ): ( PostType | null )[] => {
61
+ const postTypes = useFetchPostTypes( postTypeName );
62
+
63
+ return useMemo( () => postTypes, [ postTypes ] );
64
+ };
@@ -0,0 +1,123 @@
1
+ import { useEffect, useMemo, useState, useCallback } from 'react';
2
+ import { usePostTypes } from './use-post-types';
3
+ import { Post, PostType } from './types/interfaces';
4
+ import apiFetch from '@wordpress/api-fetch';
5
+
6
+ const defaultRestNamespace = 'wp/v2';
7
+
8
+ const useFetchPosts = ( postType: PostType | null | undefined ): [ Post[], () => void, boolean ] => {
9
+ const [ posts, setPosts ] = useState<Post[]>( [] );
10
+ const [ isLoading, setIsLoading ] = useState( false );
11
+
12
+ const fetchPosts = useCallback( () => {
13
+ setIsLoading( true );
14
+
15
+ const path = postType
16
+ ? `/${ postType.rest_namespace }/${ postType.rest_base }`
17
+ : `/${ defaultRestNamespace }/posts`;
18
+
19
+ apiFetch( { path } )
20
+ .then( ( response ) => {
21
+ if ( Array.isArray( response ) ) {
22
+ setPosts( response );
23
+ }
24
+ } )
25
+ .catch( ( error ) => {
26
+ // eslint-disable-next-line no-console
27
+ console.error( 'Error fetching posts:', error );
28
+ } )
29
+ .finally( () => {
30
+ setIsLoading( false );
31
+ } );
32
+ }, [ postType ] );
33
+
34
+ useEffect( () => {
35
+ fetchPosts();
36
+ }, [ fetchPosts ] );
37
+
38
+ return [ posts, fetchPosts, isLoading ];
39
+ };
40
+
41
+ const useUpdatePost = ( postType: PostType | null | undefined ): ( ( postId: number, updatedPost: Partial<Post> ) => void ) => {
42
+ return ( postId: number, updatedPost: Partial<Post> ) => {
43
+ const path = postType
44
+ ? `/${ postType.rest_namespace }/${ postType.rest_base }`
45
+ : `/${ defaultRestNamespace }/posts`;
46
+
47
+ apiFetch( {
48
+ path: `/${ path }/${ postId }`,
49
+ method: 'POST',
50
+ data: updatedPost,
51
+ } )
52
+ .catch( ( error ) => {
53
+ // eslint-disable-next-line no-console
54
+ console.error( 'Error updating post:', error );
55
+ } );
56
+ };
57
+ };
58
+
59
+ const useCreatePost = ( postType: PostType | null | undefined ): ( ( newPost: Partial<Post> ) => void ) => {
60
+ return ( newPost: Partial<Post> ) => {
61
+ const path = postType
62
+ ? `/${ postType.rest_namespace }/${ postType.rest_base }`
63
+ : `/${ defaultRestNamespace }/posts`;
64
+
65
+ apiFetch( {
66
+ path,
67
+ method: 'POST',
68
+ data: newPost,
69
+ } )
70
+ .catch( ( error ) => {
71
+ // eslint-disable-next-line no-console
72
+ console.error( 'Error creating post:', error );
73
+ } );
74
+ };
75
+ };
76
+
77
+ const useDeletePost = ( postType: PostType | null | undefined ): ( ( postId: number ) => void ) => {
78
+ return ( postId: number ) => {
79
+ const path = postType
80
+ ? `/${ postType.rest_namespace }/${ postType.rest_base }`
81
+ : `/${ defaultRestNamespace }/posts`;
82
+
83
+ apiFetch( {
84
+ path: `/${ path }/${ postId }`,
85
+ method: 'DELETE',
86
+ } )
87
+ .catch( ( error ) => {
88
+ // eslint-disable-next-line no-console
89
+ console.error( 'Error deleting post:', error );
90
+ } );
91
+ };
92
+ };
93
+
94
+ export const usePosts = ( postType?: string | null ): {
95
+ posts: Post[];
96
+ getPosts: () => void;
97
+ updatePost: ( postId: number, updatedPost: Partial<Post> ) => void;
98
+ createPost: ( newPost: Partial<Post> ) => void;
99
+ deletePost: ( postId: number ) => void;
100
+ isLoading: boolean;
101
+ } => {
102
+ const postTypes = usePostTypes();
103
+ const postTypeData = postTypes.find( ( type ) => type?.name === postType );
104
+
105
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
106
+ const [ posts, fetchPosts, isLoading ] = useFetchPosts( postTypeData );
107
+ const updatePost = useUpdatePost( postTypeData );
108
+ const createPost = useCreatePost( postTypeData );
109
+ const deletePost = useDeletePost( postTypeData );
110
+
111
+ const getPosts = useMemo( () => {
112
+ return () => posts;
113
+ }, [ posts ] );
114
+
115
+ return {
116
+ posts,
117
+ getPosts,
118
+ updatePost,
119
+ createPost,
120
+ deletePost,
121
+ isLoading,
122
+ };
123
+ };
package/src/init.ts CHANGED
@@ -1,8 +1,12 @@
1
1
  import RecentlyEdited from './components/top-bar/recently-edited';
2
2
  import { injectIntoPageIndication } from '@elementor/editor-app-bar';
3
+ import { injectIntoTop } from '@elementor/editor';
4
+ import Shell from './components/panel/shell';
3
5
 
4
6
  export default function init() {
5
7
  registerTopBarMenuItems();
8
+ // TODO 06/06/2023 : uncomment registerPanel() when we are ready to release
9
+ registerPanel();
6
10
  }
7
11
 
8
12
  function registerTopBarMenuItems() {
@@ -11,3 +15,11 @@ function registerTopBarMenuItems() {
11
15
  filler: RecentlyEdited,
12
16
  } );
13
17
  }
18
+
19
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
20
+ function registerPanel() {
21
+ injectIntoTop( {
22
+ id: 'navigation-panel',
23
+ filler: Shell,
24
+ } );
25
+ }
package/src/types.ts ADDED
@@ -0,0 +1,7 @@
1
+ export type Page = {
2
+ isHome?: boolean;
3
+ id: number;
4
+ title: string;
5
+ status: string;
6
+ type: string;
7
+ };