@elementor/editor-site-navigation 0.15.0 → 0.17.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,67 @@
1
+ import { fireEvent, render, screen } from '@testing-library/react';
2
+ import SetHome from '../set-home';
3
+ import * as React from 'react';
4
+ import { Post } from '../../../../../types';
5
+
6
+ const mockMutateAsync = jest.fn();
7
+ jest.mock( '../../../../../hooks/use-homepage-actions', () => ( {
8
+ __esModule: true,
9
+ useHomepageActions: jest.fn( () => ( {
10
+ updateSettingsMutation: {
11
+ mutateAsync: mockMutateAsync,
12
+ isLoading: false,
13
+ },
14
+ } ) ),
15
+ } ) );
16
+
17
+ describe( '@elementor/editor-site-navigation - SetHome', () => {
18
+ afterAll( () => {
19
+ jest.clearAllMocks();
20
+ } );
21
+
22
+ it( 'should render Set as homepage', () => {
23
+ // Arrange.
24
+ const post: Post = {
25
+ id: 1,
26
+ title: {
27
+ rendered: 'Test Page',
28
+ },
29
+ status: 'publish',
30
+ type: 'page',
31
+ link: 'https://example.local/test-page',
32
+ isHome: false,
33
+ };
34
+
35
+ // Act.
36
+ render( <SetHome post={ post } /> );
37
+
38
+ // Assert.
39
+ const button = screen.getByRole( 'button' );
40
+ expect( button ).not.toHaveAttribute( 'aria-disabled' );
41
+
42
+ fireEvent.click( button );
43
+
44
+ expect( mockMutateAsync ).toHaveBeenCalledTimes( 1 );
45
+ } );
46
+
47
+ it( 'should render Set as homepage disabled when the page status is draft', () => {
48
+ // Arrange.
49
+ const post: Post = {
50
+ id: 1,
51
+ title: {
52
+ rendered: 'Test Page',
53
+ },
54
+ status: 'draft',
55
+ type: 'page',
56
+ link: 'https://example.local/test-page',
57
+ isHome: false,
58
+ };
59
+
60
+ // Act.
61
+ render( <SetHome post={ post } /> );
62
+
63
+ // Assert.
64
+ const button = screen.getByRole( 'button' );
65
+ expect( button ).toHaveAttribute( 'aria-disabled', 'true' );
66
+ } );
67
+ } );
@@ -1,21 +1,94 @@
1
1
  import * as React from 'react';
2
2
  import { TrashIcon } from '@elementor/icons';
3
3
  import { Post } from '../../../../types';
4
- import { useActiveDocument } from '@elementor/editor-documents';
5
- import { __ } from '@wordpress/i18n';
4
+ import { __, sprintf } from '@wordpress/i18n';
6
5
  import ActionMenuItem from '../action-menu-item';
6
+ import { usePostActions } from '../../../../hooks/use-posts-actions';
7
+ import {
8
+ Button,
9
+ CircularProgress,
10
+ Dialog,
11
+ DialogActions,
12
+ DialogContent,
13
+ DialogContentText,
14
+ DialogTitle,
15
+ Divider,
16
+ } from '@elementor/ui';
17
+ import { usePostListContext } from '../../../../contexts/post-list-context';
18
+ import { useState } from 'react';
19
+ import { useActiveDocument } from '@elementor/editor-documents';
7
20
 
8
21
  export default function Delete( { post }: { post: Post } ) {
22
+ const [ isDialogOpen, setIsDialogOpen ] = useState( false );
9
23
  const activeDocument = useActiveDocument();
10
24
 
11
- const isActive = activeDocument?.id === post.id;
25
+ const isPostActive = activeDocument?.id === post.id;
26
+
27
+ return (
28
+ <>
29
+ <ActionMenuItem
30
+ title={ __( 'Delete', 'elementor' ) }
31
+ icon={ TrashIcon }
32
+ ListItemButtonProps={
33
+ {
34
+ disabled: post.isHome || isPostActive,
35
+ onClick: () => setIsDialogOpen( true ),
36
+ sx: { '&:hover': { color: 'error.main' } },
37
+ }
38
+ }
39
+ />
40
+
41
+ {
42
+ isDialogOpen && (
43
+ <DeleteDialog post={ post } setIsDialogOpen={ setIsDialogOpen } />
44
+ )
45
+ }
46
+ </>
47
+ );
48
+ }
49
+
50
+ function DeleteDialog( { post, setIsDialogOpen }: { post: Post, setIsDialogOpen: React.Dispatch<React.SetStateAction<boolean>> } ) {
51
+ const { type } = usePostListContext();
52
+ const { deletePost } = usePostActions( type );
53
+
54
+ /* translators: %s: Post title. */
55
+ const dialogTitle = sprintf( __( 'Delete “%s”?', 'elementor' ), post.title.rendered );
56
+
57
+ const deletePage = async () => {
58
+ await deletePost.mutateAsync( post.id );
59
+ };
60
+
61
+ const handleCancel = () => {
62
+ if ( deletePost.isLoading ) {
63
+ return;
64
+ }
65
+
66
+ setIsDialogOpen( false );
67
+ };
12
68
 
13
69
  return (
14
- <ActionMenuItem
15
- title={ __( 'Delete', 'elementor' ) }
16
- icon={ TrashIcon }
17
- disabled={ post.isHome || isActive }
18
- onClick={ () => null }
19
- />
70
+ <Dialog
71
+ open={ true }
72
+ onClose={ handleCancel }
73
+ aria-labelledby="delete-dialog"
74
+ >
75
+ <DialogTitle>
76
+ { dialogTitle }
77
+ </DialogTitle>
78
+ <Divider />
79
+ <DialogContent>
80
+ <DialogContentText>
81
+ { __( 'The page and its content will be deleted forever and we won’t be able to recover them.', 'elementor' ) }
82
+ </DialogContentText>
83
+ </DialogContent>
84
+ <DialogActions>
85
+ <Button variant="contained" color="secondary" onClick={ handleCancel } disabled={ deletePost.isLoading }>
86
+ { __( 'Cancel', 'elementor' ) }
87
+ </Button>
88
+ <Button variant="contained" color="error" onClick={ deletePage } disabled={ deletePost.isLoading } >
89
+ { ! deletePost.isLoading ? __( 'Delete', 'elementor' ) : <CircularProgress /> }
90
+ </Button>
91
+ </DialogActions>
92
+ </Dialog>
20
93
  );
21
94
  }
@@ -9,21 +9,27 @@ import { Post } from '../../../../types';
9
9
  export default function Duplicate( { post, popupState }: { post: Post, popupState: PopupState } ) {
10
10
  const { setEditMode } = usePostListContext();
11
11
 
12
+ const onClick = () => {
13
+ popupState.close();
14
+
15
+ setEditMode( {
16
+ mode: 'duplicate',
17
+ details: {
18
+ postId: post.id,
19
+ title: post.title.rendered,
20
+ },
21
+ } );
22
+ };
23
+
12
24
  return (
13
25
  <ActionMenuItem
14
26
  title={ __( 'Duplicate', 'elementor' ) }
15
27
  icon={ CopyIcon }
16
- onClick={ () => {
17
- popupState.close();
18
-
19
- setEditMode( {
20
- mode: 'duplicate',
21
- details: {
22
- postId: post.id,
23
- title: post.title.rendered,
24
- },
25
- } );
26
- } }
28
+ ListItemButtonProps={
29
+ {
30
+ onClick,
31
+ }
32
+ }
27
33
  />
28
34
  );
29
35
  }
@@ -12,14 +12,18 @@ export default function Rename( { post }: { post: Post } ) {
12
12
  <ActionMenuItem
13
13
  title={ __( 'Rename', 'elementor' ) }
14
14
  icon={ EraseIcon }
15
- onClick={ () => {
16
- setEditMode( {
17
- mode: 'rename',
18
- details: {
19
- postId: post.id,
15
+ ListItemButtonProps={
16
+ {
17
+ onClick: () => {
18
+ setEditMode( {
19
+ mode: 'rename',
20
+ details: {
21
+ postId: post.id,
22
+ },
23
+ } );
20
24
  },
21
- } );
22
- } }
25
+ }
26
+ }
23
27
  />
24
28
  );
25
29
  }
@@ -3,14 +3,26 @@ import { Post } from '../../../../types';
3
3
  import { HomeIcon } from '@elementor/icons';
4
4
  import { __ } from '@wordpress/i18n';
5
5
  import ActionMenuItem from '../action-menu-item';
6
+ import { useHomepageActions } from '../../../../hooks/use-homepage-actions';
7
+ import { CircularProgress } from '@elementor/ui';
6
8
 
7
9
  export default function SetHome( { post }: { post: Post } ) {
10
+ const { updateSettingsMutation } = useHomepageActions();
11
+
12
+ const handleClick = () => {
13
+ updateSettingsMutation.mutateAsync( { show_on_front: 'page', page_on_front: post.id } );
14
+ };
15
+
8
16
  return (
9
17
  <ActionMenuItem
10
18
  title={ __( 'Set as homepage', 'elementor' ) }
11
- icon={ HomeIcon }
12
- disabled={ !! post.isHome }
13
- onClick={ () => null }
19
+ icon={ ! updateSettingsMutation.isLoading ? HomeIcon : CircularProgress }
20
+ ListItemButtonProps={
21
+ {
22
+ disabled: !! post.isHome || post.status !== 'publish' || updateSettingsMutation.isLoading,
23
+ onClick: handleClick,
24
+ }
25
+ }
14
26
  />
15
27
  );
16
28
  }
@@ -16,9 +16,11 @@ export default function View( { post }: { post: Post } ) {
16
16
  <ActionMenuItem
17
17
  title={ title }
18
18
  icon={ EyeIcon }
19
- onClick={ () => {
20
- window.open( post.link, '_blank' );
21
- } }
19
+ ListItemButtonProps={
20
+ {
21
+ onClick: () => window.open( post.link, '_blank' ),
22
+ }
23
+ }
22
24
  />
23
25
  );
24
26
  }
@@ -8,13 +8,23 @@ jest.mock( '../../../../hooks/use-posts', () => ( {
8
8
  usePosts: jest.fn( () => ( {
9
9
  isLoading: false,
10
10
  data: [
11
- { id: 1, type: 'page', title: { rendered: 'Home' }, status: 'draft', link: 'www.test.demo' },
12
- { id: 2, type: 'page', title: { rendered: 'About' }, status: 'publish', link: 'www.test.demo' },
13
- { id: 3, type: 'page', title: { rendered: 'Services' }, status: 'publish', link: 'www.test.demo', isHome: true },
11
+ { id: 1, type: 'page', title: { rendered: 'Home' }, status: 'publish', link: 'www.test.demo' },
12
+ { id: 2, type: 'page', title: { rendered: 'About' }, status: 'draft', link: 'www.test.demo' },
14
13
  ],
15
14
  } ) ),
16
15
  } ) );
17
16
 
17
+ jest.mock( '../../../../hooks/use-homepage', () => ( {
18
+ __esModule: true,
19
+ useHomepage: jest.fn( () => ( {
20
+ isLoading: false,
21
+ data: {
22
+ show_on_front: 'page',
23
+ page_on_front: 1,
24
+ },
25
+ } ) ),
26
+ } ) );
27
+
18
28
  jest.mock( '@elementor/editor-documents', () => ( {
19
29
  __esModule: true,
20
30
  useActiveDocument: jest.fn( () => ( {
@@ -33,22 +43,27 @@ describe( '@elementor/editor-site-navigation - PostsCollapsibleList', () => {
33
43
  renderWithQuery( <PostsCollapsibleList isOpenByDefault={ false } /> );
34
44
 
35
45
  // Assert.
36
- const label = screen.getByText( `Pages (3)` );
46
+ const label = screen.getByText( `Pages (2)` );
37
47
  expect( label ).toBeInTheDocument();
38
48
 
39
- const postInList = screen.queryByText( 'Services' );
49
+ const postInList = screen.queryByText( 'Home' );
40
50
  expect( postInList ).not.toBeInTheDocument();
41
51
  } );
42
52
 
43
- it( 'should render open list', () => {
53
+ it( 'should render open list with home icon and page status', () => {
44
54
  // Act.
45
55
  renderWithQuery( <PostsCollapsibleList isOpenByDefault={ true } /> );
46
56
 
47
57
  // Assert.
48
- const label = screen.getByText( `Pages (3)` );
49
- expect( label ).toBeInTheDocument();
58
+ const items = screen.getAllByRole( 'listitem' );
59
+
60
+ expect( items.length ).toBe( 3 );
50
61
 
51
- const postInList = screen.getByText( 'Services' );
52
- expect( postInList ).toBeInTheDocument();
62
+ // First item is the list title.
63
+ expect( items[ 0 ] ).toHaveTextContent( `Pages (2)` );
64
+ expect( items[ 1 ] ).toHaveTextContent( 'Home' );
65
+ expect( items[ 1 ] ).toHaveTextContent( 'Homepage' ); // Home icon.
66
+ expect( items[ 2 ] ).toHaveTextContent( 'About' );
67
+ expect( items[ 2 ] ).toHaveTextContent( '(draft)' );
53
68
  } );
54
69
  } );
@@ -20,6 +20,7 @@ import Delete from '../../actions-menu/actions/delete';
20
20
  import View from '../../actions-menu/actions/view';
21
21
  import SetHome from '../../actions-menu/actions/set-home';
22
22
  import { Post } from '../../../../types';
23
+ import { __ } from '@wordpress/i18n';
23
24
 
24
25
  export default function ListItemView( { post }: { post: Post } ) {
25
26
  const activeDocument = useActiveDocument();
@@ -65,7 +66,7 @@ export default function ListItemView( { post }: { post: Post } ) {
65
66
  </ListItemText>
66
67
  { post.isHome &&
67
68
  <ListItemIcon>
68
- <HomeIcon color="disabled" />
69
+ <HomeIcon titleAccess={ __( 'Homepage', 'elementor' ) } color="disabled" />
69
70
  </ListItemIcon>
70
71
  }
71
72
  </ListItemButton>
@@ -6,6 +6,7 @@ import { usePostListContext } from '../../../contexts/post-list-context';
6
6
  import { postTypesMap } from '../../../api/post';
7
7
  import CollapsibleList from './collapsible-list';
8
8
  import PostListItem from './post-list-item';
9
+ import { useHomepage } from '../../../hooks/use-homepage';
9
10
 
10
11
  type Props = {
11
12
  isOpenByDefault?: boolean,
@@ -14,6 +15,7 @@ type Props = {
14
15
  export default function PostsCollapsibleList( { isOpenByDefault = false }: Props ) {
15
16
  const { type, editMode } = usePostListContext();
16
17
  const { data: posts, isLoading: postsLoading } = usePosts( type );
18
+ const { data: homepageSettings } = useHomepage();
17
19
 
18
20
  if ( ! posts || postsLoading ) {
19
21
  return (
@@ -26,6 +28,9 @@ export default function PostsCollapsibleList( { isOpenByDefault = false }: Props
26
28
 
27
29
  const label = `${ postTypesMap[ type ].labels.plural_name } (${ posts.length.toString() })`;
28
30
 
31
+ const isHomepageSet = homepageSettings?.show_on_front === 'page' && !! homepageSettings?.page_on_front;
32
+ const homepageId = isHomepageSet ? homepageSettings.page_on_front : null;
33
+
29
34
  return (
30
35
  <List dense>
31
36
  <CollapsibleList
@@ -33,7 +38,10 @@ export default function PostsCollapsibleList( { isOpenByDefault = false }: Props
33
38
  Icon={ PageTypeIcon }
34
39
  isOpenByDefault={ isOpenByDefault || false }
35
40
  >
36
- { posts.map( ( post ) => <PostListItem key={ post.id } post={ post } /> ) }
41
+ { posts.map( ( post ) => {
42
+ post = { ...post, isHome: post.id === homepageId };
43
+ return <PostListItem key={ post.id } post={ post } />;
44
+ } ) }
37
45
  {
38
46
  [ 'duplicate', 'create' ].includes( editMode.mode ) &&
39
47
  <PostListItem />
@@ -0,0 +1,43 @@
1
+ import apiFetch from '@wordpress/api-fetch';
2
+ import { renderHookWithQuery } from 'test-utils';
3
+ import { useHomepageActions } from '../use-homepage-actions';
4
+ import { settingsQueryKey } from '../use-homepage';
5
+
6
+ jest.mock( '@wordpress/api-fetch' );
7
+
8
+ describe( '@elementor/site-settings/use-homepage-actions', () => {
9
+ beforeEach( () => {
10
+ jest.mocked( apiFetch ).mockImplementation( () => Promise.resolve( {} ) );
11
+ } );
12
+
13
+ afterEach( () => {
14
+ jest.clearAllMocks();
15
+ } );
16
+
17
+ it( 'should run updateSettings from useHomepageActions hook', async () => {
18
+ // Arrange.
19
+ const { component, queryClient } = renderHookWithQuery( () => useHomepageActions() );
20
+ const { updateSettingsMutation } = component.result.current;
21
+
22
+ const queryKey = settingsQueryKey();
23
+ await queryClient.setQueryData( queryKey, {
24
+ show_on_front: '',
25
+ page_on_front: 0,
26
+ } );
27
+
28
+ // Act.
29
+ await updateSettingsMutation.mutateAsync( { show_on_front: 'page', page_on_front: 1 } );
30
+
31
+ expect( apiFetch ).toHaveBeenCalledTimes( 1 );
32
+ expect( apiFetch ).toHaveBeenCalledWith( {
33
+ path: '/wp/v2/settings',
34
+ method: 'POST',
35
+ data: {
36
+ show_on_front: 'page',
37
+ page_on_front: 1,
38
+ },
39
+ } );
40
+
41
+ expect( queryClient.getQueryState( queryKey )?.isInvalidated ).toBe( true );
42
+ } );
43
+ } );
@@ -0,0 +1,42 @@
1
+ import { waitFor } from '@testing-library/react';
2
+ import apiFetch from '@wordpress/api-fetch';
3
+ import { useHomepage } from '../use-homepage';
4
+ import { renderHookWithQuery } from 'test-utils';
5
+
6
+ jest.mock( '@wordpress/api-fetch' );
7
+
8
+ describe( '@elementor/site-settings/use-homepage', () => {
9
+ beforeEach( () => {
10
+ jest.mocked( apiFetch ).mockImplementation( () => Promise.resolve( [] ) );
11
+ } );
12
+
13
+ afterEach( () => {
14
+ jest.clearAllMocks();
15
+ } );
16
+
17
+ it( 'useHomepage hook should return homepage settings', async () => {
18
+ // Arrange.
19
+ const settings = {
20
+ show_on_front: 'page',
21
+ page_on_front: 1,
22
+ };
23
+ jest.mocked( apiFetch ).mockImplementation( () => Promise.resolve( settings ) );
24
+
25
+ // Act.
26
+ const { component } = renderHookWithQuery( () => useHomepage() );
27
+
28
+ // Assert.
29
+ const expectedPath = `/wp/v2/settings?_fields=${ encodeURIComponent( 'show_on_front,page_on_front' ) }`;
30
+
31
+ expect( apiFetch ).toHaveBeenCalledWith( {
32
+ path: expectedPath,
33
+ } );
34
+ expect( apiFetch ).toHaveBeenCalledTimes( 1 );
35
+
36
+ await waitFor( () => {
37
+ return component.result.current.isSuccess;
38
+ } );
39
+
40
+ expect( component.result.current.data ).toBe( settings );
41
+ } );
42
+ } );
@@ -0,0 +1,26 @@
1
+ import { useMutation, useQueryClient } from '@elementor/query';
2
+ import { Settings, updateSettings } from '../api/settings';
3
+ import { settingsQueryKey } from './use-homepage';
4
+
5
+ export function useHomepageActions() {
6
+ const invalidateSettings = useInvalidateSettings();
7
+
8
+ const onSuccess = async () => invalidateSettings( { exact: true } );
9
+
10
+ const updateSettingsMutation = useMutation(
11
+ ( settings: Settings ) => updateSettings( settings ),
12
+ { onSuccess }
13
+ );
14
+
15
+ return { updateSettingsMutation };
16
+ }
17
+
18
+ function useInvalidateSettings() {
19
+ const queryClient = useQueryClient();
20
+
21
+ return ( options = {} ) => {
22
+ const queryKey = settingsQueryKey();
23
+
24
+ return queryClient.invalidateQueries( queryKey, options );
25
+ };
26
+ }
@@ -0,0 +1,11 @@
1
+ import { useQuery } from '@elementor/query';
2
+ import { getSettings } from '../api/settings';
3
+
4
+ export const settingsQueryKey = () => [ 'site-navigation', 'homepage' ];
5
+
6
+ export function useHomepage( ) {
7
+ return useQuery( {
8
+ queryKey: settingsQueryKey(),
9
+ queryFn: () => getSettings(),
10
+ } );
11
+ }
package/src/types.ts CHANGED
@@ -8,13 +8,3 @@ export type Post = {
8
8
  rendered: string;
9
9
  }
10
10
  };
11
-
12
- export type PostType = {
13
- name: string;
14
- slug: string;
15
- labels: Record<string, string>;
16
- rest_base: string;
17
- rest_namespace: string;
18
- }
19
-
20
- export type NonNullableQueryResponse<T> = T extends { data: infer U } ? T & { data: NonNullable<U> } : T;
@@ -1,27 +0,0 @@
1
- import * as React from 'react';
2
- import { ComponentType } from 'react';
3
- import { ListItemButton, ListItemIcon, ListItemText } from '@elementor/ui';
4
- import { Post } from '../../../types';
5
-
6
- export type Props = {
7
- title: string;
8
- icon: ComponentType;
9
- disabled?: boolean;
10
- onClick: ( post: Post ) => void;
11
- }
12
-
13
- export default function ActionListItem( { title, icon: Icon, disabled, onClick }: Props ) {
14
- return (
15
- <ListItemButton
16
- disabled={ disabled }
17
- onClick={ onClick }
18
- >
19
- <ListItemIcon>
20
- <Icon />
21
- </ListItemIcon>
22
- <ListItemText>
23
- { title }
24
- </ListItemText>
25
- </ListItemButton>
26
- );
27
- }