diff --git a/packages/block-editor/src/components/index.native.js b/packages/block-editor/src/components/index.native.js
index e88b3d6c047f33..608d230503b679 100644
--- a/packages/block-editor/src/components/index.native.js
+++ b/packages/block-editor/src/components/index.native.js
@@ -44,7 +44,13 @@ export {
MEDIA_TYPE_AUDIO,
MEDIA_TYPE_ANY,
} from './media-upload';
-export { default as MediaUploadProgress } from './media-upload-progress';
+export {
+ default as MediaUploadProgress,
+ MEDIA_UPLOAD_STATE_UPLOADING,
+ MEDIA_UPLOAD_STATE_SUCCEEDED,
+ MEDIA_UPLOAD_STATE_FAILED,
+ MEDIA_UPLOAD_STATE_RESET,
+} from './media-upload-progress';
export { default as BlockMediaUpdateProgress } from './block-media-update-progress';
export { default as URLInput } from './url-input';
export { default as BlockInvalidWarning } from './block-list/block-invalid-warning';
diff --git a/packages/block-editor/src/components/media-placeholder/index.native.js b/packages/block-editor/src/components/media-placeholder/index.native.js
index b7ac7f00e5755e..4b83c1e314fdef 100644
--- a/packages/block-editor/src/components/media-placeholder/index.native.js
+++ b/packages/block-editor/src/components/media-placeholder/index.native.js
@@ -130,12 +130,14 @@ function MediaPlaceholder( props ) {
);
} else if ( isAppender && ! disableMediaButtons ) {
return (
-
+
+
+
);
}
};
diff --git a/packages/block-editor/src/components/media-upload/index.native.js b/packages/block-editor/src/components/media-upload/index.native.js
index 397a70e006ac52..05aabd9a9a3cd1 100644
--- a/packages/block-editor/src/components/media-upload/index.native.js
+++ b/packages/block-editor/src/components/media-upload/index.native.js
@@ -294,6 +294,7 @@ export class MediaUpload extends Component {
ref={ ( instance ) => ( this.picker = instance ) }
options={ this.getMediaOptionsItems() }
onChange={ this.onPickerSelect }
+ testID="media-options-picker"
/>
);
diff --git a/packages/block-library/src/gallery/test/__snapshots__/index.native.js.snap b/packages/block-library/src/gallery/test/__snapshots__/index.native.js.snap
index eadf547a190f01..1fd4dc765870d0 100644
--- a/packages/block-library/src/gallery/test/__snapshots__/index.native.js.snap
+++ b/packages/block-library/src/gallery/test/__snapshots__/index.native.js.snap
@@ -1,7 +1,171 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
+exports[`Gallery block Columns setting decrements columns 1`] = `
+"
+
+
+
+
+
+
+
+
+
+
+
+"
+`;
+
+exports[`Gallery block Columns setting does not increment due to maximum value 1`] = `
+"
+
+
+
+
+
+
+
+
+
+
+
+"
+`;
+
+exports[`Gallery block cancels uploads 1`] = `
+"
+
+
+
+
+
+
+
+"
+`;
+
+exports[`Gallery block disables crop images setting 1`] = `
+"
+
+
+
+"
+`;
+
+exports[`Gallery block finishes pending uploads upon opening the editor 1`] = `
+"
+
+
+
+
+
+
+
+"
+`;
+
+exports[`Gallery block handles failed uploads 1`] = `
+"
+
+
+
+
+
+
+
+"
+`;
+
exports[`Gallery block inserts block 1`] = `
"
"
`;
+
+exports[`Gallery block overrides "Link to" setting of gallery items 1`] = `
+"
+
+
+
+
+
+
+
+"
+`;
+
+exports[`Gallery block rearranges gallery items 1`] = `
+"
+
+
+
+
+
+
+
+
+
+
+
+"
+`;
+
+exports[`Gallery block sets caption to gallery 1`] = `
+"
+
+
+Bold italic strikethrough gallery caption
+"
+`;
+
+exports[`Gallery block sets caption to gallery items 1`] = `
+"
+
+
Bold italic strikethrough image caption
+
+"
+`;
+
+exports[`Gallery block successfully uploads items 1`] = `
+"
+
+
+
+
+
+
+
+"
+`;
+
+exports[`Gallery block takes a photo 1`] = `
+"
+
+
+
+"
+`;
+
+exports[`Gallery block uploads from free photo library 1`] = `
+"
+
+
+
+
+
+
+
+"
+`;
+
+exports[`Gallery block uploads from other apps 1`] = `
+"
+
+
+
+
+
+
+
+"
+`;
diff --git a/packages/block-library/src/gallery/test/helpers.native.js b/packages/block-library/src/gallery/test/helpers.native.js
new file mode 100644
index 00000000000000..da99a166384480
--- /dev/null
+++ b/packages/block-library/src/gallery/test/helpers.native.js
@@ -0,0 +1,293 @@
+/**
+ * External dependencies
+ */
+import {
+ act,
+ initializeEditor,
+ fireEvent,
+ waitFor,
+ within,
+ waitForStoreResolvers,
+} from 'test/helpers';
+
+/**
+ * WordPress dependencies
+ */
+import {
+ requestMediaPicker,
+ subscribeMediaUpload,
+} from '@wordpress/react-native-bridge';
+import {
+ MEDIA_UPLOAD_STATE_UPLOADING,
+ MEDIA_UPLOAD_STATE_SUCCEEDED,
+ MEDIA_UPLOAD_STATE_FAILED,
+ MEDIA_UPLOAD_STATE_RESET,
+} from '@wordpress/block-editor';
+
+/**
+ * Adds a Gallery block via the block picker.
+ *
+ * @return {import('@testing-library/react-native').RenderAPI} A Testing Library screen.
+ */
+export const addGalleryBlock = async () => {
+ const screen = await initializeEditor();
+ const { getByA11yLabel, getByTestId, getByText } = screen;
+
+ fireEvent.press( getByA11yLabel( 'Add block' ) );
+
+ const blockList = getByTestId( 'InserterUI-Blocks' );
+ // onScroll event used to force the FlatList to render all items
+ fireEvent.scroll( blockList, {
+ nativeEvent: {
+ contentOffset: { y: 0, x: 0 },
+ contentSize: { width: 100, height: 100 },
+ layoutMeasurement: { width: 100, height: 100 },
+ },
+ } );
+
+ fireEvent.press( await waitFor( () => getByText( 'Gallery' ) ) );
+
+ return screen;
+};
+
+/**
+ * The gallery items are rendered via the FlatList of the inner block list.
+ * In order to render the items of a FlatList, it's required to trigger the
+ * "onLayout" event. Additionally, the call is wrapped over "waitForStoreResolvers"
+ * because the gallery items request the media data associated with the image to
+ * be rendered via the "getMedia" selector.
+ *
+ * @param {import('react-test-renderer').ReactTestInstance} galleryBlock Gallery block instance to trigger layout event.
+ * @param {Object} [options] Configuration options for the event.
+ * @param {number} [options.width] Width value to be passed to the event.
+ */
+export const triggerGalleryLayout = async (
+ galleryBlock,
+ { width = 320 } = {}
+) =>
+ waitForStoreResolvers( () =>
+ fireEvent(
+ within( galleryBlock ).getByTestId( 'block-list-wrapper' ),
+ 'layout',
+ {
+ nativeEvent: {
+ layout: {
+ width,
+ },
+ },
+ }
+ )
+ );
+
+/**
+ * Initialize the editor with HTML generated of Gallery block.
+ *
+ * @param {Object} [options] Configuration options for the initialization.
+ * @param {string} [options.html] String of block editor HTML to parse and render.
+ * @param {number} [options.numberOfItems] Number of gallery items to generate or already included in the provided block editor HTML.
+ * @param {Object} [options.media] Contains media data to be used in the generation.
+ * @param {number} [options.width] Width to be passed when triggering the "onLayout" event on the Gallery block.
+ * @param {boolean} [options.selected] Specifies if the Gallery block included in the initial HTML should be automatically selected.
+ * @param {boolean} [options.useLocalUrl] Specifies if the items should use the local URL instead of the server URL.
+ *
+ * @return {import('@testing-library/react-native').RenderAPI} The Testing Library screen plus the Gallery block React Test instance.
+ */
+export const initializeWithGalleryBlock = async ( {
+ html,
+ numberOfItems = 0,
+ media = [],
+ width = 320,
+ selected = true,
+ useLocalUrl = false,
+} = {} ) => {
+ const initialHtml =
+ html ||
+ generateGalleryBlock( numberOfItems, media, {
+ useLocalUrl,
+ } );
+ const screen = await initializeEditor( { initialHtml } );
+ const { getByA11yLabel } = screen;
+
+ const galleryBlock = getByA11yLabel( /Gallery Block\. Row 1/ );
+
+ if ( numberOfItems > 0 ) {
+ await triggerGalleryLayout( galleryBlock, { width } );
+ }
+
+ if ( selected ) {
+ fireEvent.press( galleryBlock );
+ }
+
+ return { ...screen, galleryBlock };
+};
+
+/**
+ * Gets a gallery item within a Gallery block.
+ *
+ * @param {import('react-test-renderer').ReactTestInstance} galleryBlock Gallery block instance.
+ * @param {number} rowPosition Row position within the Gallery block.
+ * @return {import('react-test-renderer').ReactTestInstance} Gallery item.
+ */
+export const getGalleryItem = ( galleryBlock, rowPosition ) => {
+ return within( galleryBlock ).getByA11yLabel(
+ new RegExp( `Image Block\\. Row ${ rowPosition }` )
+ );
+};
+
+/**
+ * Sets up the media upload mock functions for testing.
+ *
+ * @typedef {Object} MediaUploadMockFunctions
+ * @property {Function} notifyUploadingState Notify uploading state for a media item.
+ * @property {Function} notifySucceedState Notify succeed state for a media item.
+ * @property {Function} notifyFailedState Notify failed state for a media item.
+ * @property {Function} notifyResetState Notify reset state for a media item.
+ *
+ * @return {MediaUploadMockFunctions} Notify state functions.
+ */
+export const setupMediaUpload = () => {
+ const mediaUploadListeners = [];
+ subscribeMediaUpload.mockImplementation( ( callback ) => {
+ mediaUploadListeners.push( callback );
+ return { remove: jest.fn() };
+ } );
+ const notifyMediaUpload = ( payload ) =>
+ mediaUploadListeners.forEach( ( listener ) => listener( payload ) );
+
+ return {
+ notifyUploadingState: async ( mediaItem ) =>
+ act( async () => {
+ notifyMediaUpload( {
+ state: MEDIA_UPLOAD_STATE_UPLOADING,
+ mediaId: mediaItem.localId,
+ progress: 0.25,
+ } );
+ } ),
+ notifySucceedState: async ( mediaItem ) =>
+ act( async () => {
+ notifyMediaUpload( {
+ state: MEDIA_UPLOAD_STATE_SUCCEEDED,
+ mediaId: mediaItem.localId,
+ mediaUrl: mediaItem.serverUrl,
+ mediaServerId: mediaItem.serverId,
+ } );
+ } ),
+ notifyFailedState: async ( mediaItem ) =>
+ act( async () => {
+ notifyMediaUpload( {
+ state: MEDIA_UPLOAD_STATE_FAILED,
+ mediaId: mediaItem.localId,
+ progress: 0.5,
+ } );
+ } ),
+ notifyResetState: async ( mediaItem ) =>
+ act( async () => {
+ notifyMediaUpload( {
+ state: MEDIA_UPLOAD_STATE_RESET,
+ mediaId: mediaItem.localId,
+ progress: 0,
+ } );
+ } ),
+ };
+};
+
+/**
+ *
+ * Sets up Media Picker mock functions.
+ *
+ * @typedef {Object} MediaPickerMockFunctions
+ * @property {Function} expectMediaPickerCall Checks if the request media picker function has been called with specific arguments.
+ * @property {Function} mediaPickerCallback Callback function to notify the media items picked from the media picker.
+ *
+ * @return {MediaPickerMockFunctions} Media picker mock functions.
+ */
+export const setupMediaPicker = () => {
+ let mediaPickerCallback;
+ requestMediaPicker.mockImplementation(
+ ( source, filter, multiple, callback ) => {
+ mediaPickerCallback = callback;
+ }
+ );
+ return {
+ expectMediaPickerCall: ( source, filter, multiple ) =>
+ expect( requestMediaPicker ).toHaveBeenCalledWith(
+ source,
+ filter,
+ multiple,
+ mediaPickerCallback
+ ),
+ mediaPickerCallback: async ( ...mediaItems ) =>
+ act( async () =>
+ mediaPickerCallback(
+ mediaItems.map( ( { localId, localUrl } ) => ( {
+ type: 'image',
+ url: localUrl,
+ id: localId,
+ } ) )
+ )
+ ),
+ };
+};
+
+/**
+ * Generates the HTML of a Gallery block.
+ *
+ * @param {number} numberOfItems Number of gallery items to generate.
+ * @param {Object} media Contains media data to be used in the generation.
+ * @param {Object} [options] Configuration options for the generation.
+ * @param {boolean} [options.useLocalUrl] Specifies if the items should use the local URL instead of the server URL.
+ * @return {string} Gallery block HTML.
+ */
+export const generateGalleryBlock = (
+ numberOfItems,
+ media,
+ { useLocalUrl = false } = {}
+) => {
+ const galleryItems = [ ...Array( numberOfItems ) ]
+ .map( ( _, index ) => {
+ const id = useLocalUrl
+ ? media[ index ].localId
+ : media[ index ].serverId;
+ const url = useLocalUrl
+ ? media[ index ].localUrl
+ : media[ index ].serverUrl;
+ return `
+
+ `;
+ } )
+ .join( '\n\n' );
+
+ return `
+ ${ galleryItems }
+ `;
+};
+
+/**
+ * Sets the text of a caption.
+ *
+ * @param {import('react-test-renderer').ReactTestInstance} element Caption test instance.
+ * @param {string} text Text to be set.
+ */
+export const setCaption = ( element, text ) => {
+ fireEvent( element, 'focus' );
+ fireEvent( element, 'onChange', {
+ nativeEvent: {
+ eventCount: 1,
+ target: undefined,
+ text,
+ },
+ } );
+};
+
+/**
+ * Opens the block settings of the current selected block.
+ *
+ * @param {import('@testing-library/react-native').RenderAPI} screen The Testing Library screen.
+ */
+export const openBlockSettings = async ( screen ) => {
+ const { getByA11yLabel, getByTestId } = screen;
+ fireEvent.press( getByA11yLabel( 'Open Settings' ) );
+ await waitFor(
+ () => getByTestId( 'block-settings-modal' ).props.isVisible
+ );
+};
diff --git a/packages/block-library/src/gallery/test/index.native.js b/packages/block-library/src/gallery/test/index.native.js
index d5f8edfe81c9c8..295859fb039903 100644
--- a/packages/block-library/src/gallery/test/index.native.js
+++ b/packages/block-library/src/gallery/test/index.native.js
@@ -2,10 +2,10 @@
* External dependencies
*/
import {
+ act,
getEditorHtml,
initializeEditor,
fireEvent,
- waitFor,
within,
} from 'test/helpers';
@@ -14,6 +14,48 @@ import {
*/
import { getBlockTypes, unregisterBlockType } from '@wordpress/blocks';
import { registerCoreBlocks } from '@wordpress/block-library';
+import { Platform } from '@wordpress/element';
+import {
+ getOtherMediaOptions,
+ requestImageFailedRetryDialog,
+ requestImageUploadCancelDialog,
+} from '@wordpress/react-native-bridge';
+
+/**
+ * Internal dependencies
+ */
+import {
+ addGalleryBlock,
+ initializeWithGalleryBlock,
+ getGalleryItem,
+ setupMediaUpload,
+ generateGalleryBlock,
+ setCaption,
+ setupMediaPicker,
+ triggerGalleryLayout,
+ openBlockSettings,
+} from './helpers';
+
+const media = [
+ {
+ localId: 1,
+ localUrl: 'file:///local-image-1.jpeg',
+ serverId: 2000,
+ serverUrl: 'https://test-site.files.wordpress.com/local-image-1.jpeg',
+ },
+ {
+ localId: 2,
+ localUrl: 'file:///local-image-2.jpeg',
+ serverId: 2001,
+ serverUrl: 'https://test-site.files.wordpress.com/local-image-2.jpeg',
+ },
+ {
+ localId: 3,
+ localUrl: 'file:///local-image-3.jpeg',
+ serverId: 2002,
+ serverUrl: 'https://test-site.files.wordpress.com/local-image-3.jpeg',
+ },
+];
beforeAll( () => {
// Register all core blocks
@@ -27,102 +69,578 @@ afterAll( () => {
} );
} );
-const GALLERY_WITH_ONE_IMAGE = `
-
-
-
-`;
+describe( 'Gallery block', () => {
+ it( 'inserts block', async () => {
+ const { getByA11yLabel } = await addGalleryBlock();
-const addGalleryBlock = async () => {
- const screen = await initializeEditor();
- const { getByA11yLabel, getByTestId, getByText } = screen;
+ expect( getByA11yLabel( /Gallery Block\. Row 1/ ) ).toBeVisible();
+ expect( getEditorHtml() ).toMatchSnapshot();
+ } );
- fireEvent.press( getByA11yLabel( 'Add block' ) );
+ it( 'selects a gallery item', async () => {
+ const { galleryBlock } = await initializeWithGalleryBlock( {
+ numberOfItems: 1,
+ media,
+ selected: false,
+ } );
+
+ const galleryItem = getGalleryItem( galleryBlock, 1 );
+ fireEvent.press( galleryItem );
- const blockList = getByTestId( 'InserterUI-Blocks' );
- // onScroll event used to force the FlatList to render all items
- fireEvent.scroll( blockList, {
- nativeEvent: {
- contentOffset: { y: 0, x: 0 },
- contentSize: { width: 100, height: 100 },
- layoutMeasurement: { width: 100, height: 100 },
- },
+ expect( galleryItem ).toBeVisible();
} );
- fireEvent.press( await waitFor( () => getByText( 'Gallery' ) ) );
+ it( 'shows appender button when gallery has images', async () => {
+ const { galleryBlock, getByText } = await initializeWithGalleryBlock( {
+ numberOfItems: 1,
+ media,
+ } );
- return screen;
-};
+ const appenderButton = within( galleryBlock ).getByTestId(
+ 'media-placeholder-appender-icon'
+ );
+ fireEvent.press( appenderButton );
-describe( 'Gallery block', () => {
- it( 'inserts block', async () => {
- const { getByA11yLabel } = await addGalleryBlock();
+ expect( getByText( 'Choose from device' ) ).toBeVisible();
+ expect( getByText( 'Take a Photo' ) ).toBeVisible();
+ expect( getByText( 'WordPress Media Library' ) ).toBeVisible();
+ } );
+
+ // This case is disabled until the issue (https://github.com/WordPress/gutenberg/issues/38444)
+ // is addressed.
+ it.skip( 'displays media options picker when selecting the block', async () => {
+ // Initialize with an empty gallery
+ const {
+ getByA11yLabel,
+ getByText,
+ getByTestId,
+ } = await initializeEditor( {
+ initialHtml: generateGalleryBlock( 0 ),
+ } );
+
+ // Tap on Gallery block
+ fireEvent.press( getByText( 'ADD MEDIA' ) );
+
+ // Observe that media options picker is displayed
+ expect( getByText( 'Choose images' ) ).toBeVisible();
+ expect( getByText( 'WordPress Media Library' ) ).toBeVisible();
+
+ // Dimiss the picker
+ if ( Platform.isIOS ) {
+ fireEvent.press( getByText( 'Cancel' ) );
+ } else {
+ fireEvent( getByTestId( 'media-options-picker' ), 'backdropPress' );
+ }
- const galleryBlock = await waitFor( () =>
- getByA11yLabel( /Gallery Block\. Row 1/ )
+ // Observe that the block is selected, this is done by checking if the block settings
+ // button is visible
+ const blockActionsButton = getByA11yLabel( /Open Block Actions Menu/ );
+ expect( blockActionsButton ).toBeVisible();
+ } );
+
+ // Test case related to TC001 - Close/Re-open post with an ongoing image upload
+ // Reference: https://github.com/wordpress-mobile/test-cases/blob/trunk/test-cases/gutenberg/gallery.md#tc001
+ it( 'finishes pending uploads upon opening the editor', async () => {
+ const { notifyUploadingState, notifySucceedState } = setupMediaUpload();
+
+ // Initialize with a gallery that contains two items that are being uploaded
+ const { galleryBlock } = await initializeWithGalleryBlock( {
+ numberOfItems: 2,
+ media,
+ useLocalUrl: true,
+ } );
+
+ // Notify that the media items are uploading
+ await notifyUploadingState( media[ 0 ] );
+ await notifyUploadingState( media[ 1 ] );
+
+ // Check that images are showing a loading state
+ expect(
+ within( getGalleryItem( galleryBlock, 1 ) ).getByTestId( 'spinner' )
+ ).toBeVisible();
+ expect(
+ within( getGalleryItem( galleryBlock, 2 ) ).getByTestId( 'spinner' )
+ ).toBeVisible();
+
+ // Notify that the media items upload succeeded
+ await notifySucceedState( media[ 0 ] );
+ await notifySucceedState( media[ 1 ] );
+
+ expect( getEditorHtml() ).toMatchSnapshot();
+ } );
+
+ // Test case related to TC003 - Add caption to gallery
+ // Reference: https://github.com/wordpress-mobile/test-cases/blob/trunk/test-cases/gutenberg/gallery.md#tc003
+ it( 'sets caption to gallery', async () => {
+ // Initialize with a gallery that contains one item
+ const { getByA11yLabel } = await initializeWithGalleryBlock( {
+ numberOfItems: 1,
+ media,
+ } );
+
+ // Check gallery item caption is not visible
+ const galleryItemCaption = getByA11yLabel( /Image caption. Empty/ );
+ expect( galleryItemCaption ).not.toBeVisible();
+
+ // Set gallery caption
+ const captionField = within(
+ getByA11yLabel( /Gallery caption. Empty/ )
+ ).getByPlaceholderText( 'Add caption' );
+ setCaption(
+ captionField,
+ 'Bold italic strikethrough gallery caption'
);
- expect( galleryBlock ).toHaveProperty( 'type', 'View' );
expect( getEditorHtml() ).toMatchSnapshot();
} );
- it( 'selects a gallery item', async () => {
- const { getByA11yLabel } = await initializeEditor( {
- initialHtml: GALLERY_WITH_ONE_IMAGE,
+ // Test case related to TC004 - Add caption to gallery images
+ // Reference: https://github.com/wordpress-mobile/test-cases/blob/trunk/test-cases/gutenberg/gallery.md#tc004
+ it( 'sets caption to gallery items', async () => {
+ // Initialize with a gallery that contains one item
+ const { galleryBlock } = await initializeWithGalleryBlock( {
+ numberOfItems: 1,
+ media,
} );
- const galleryBlock = await waitFor( () =>
- getByA11yLabel( /Gallery Block\. Row 1/ )
+ // Select gallery item
+ const galleryItem = getGalleryItem( galleryBlock, 1 );
+ fireEvent.press( galleryItem );
+
+ // Set gallery item caption
+ const captionField = within( galleryItem ).getByPlaceholderText(
+ 'Add caption'
+ );
+ setCaption(
+ captionField,
+ 'Bold italic strikethrough image caption'
);
- fireEvent.press( galleryBlock );
- const innerBlockListWrapper = await waitFor( () =>
- within( galleryBlock ).getByTestId( 'block-list-wrapper' )
+ expect( getEditorHtml() ).toMatchSnapshot();
+ } );
+
+ // Test case related to TC005 - Choose from device (stay in editor) - Successful upload
+ // Reference: https://github.com/wordpress-mobile/test-cases/blob/trunk/test-cases/gutenberg/gallery.md#tc005
+ it( 'successfully uploads items', async () => {
+ const { notifyUploadingState, notifySucceedState } = setupMediaUpload();
+ const {
+ expectMediaPickerCall,
+ mediaPickerCallback,
+ } = setupMediaPicker();
+
+ // Initialize with an empty gallery
+ const { galleryBlock, getByText } = await initializeWithGalleryBlock();
+
+ // Upload images from device
+ fireEvent.press( getByText( 'ADD MEDIA' ) );
+ fireEvent.press( getByText( 'Choose from device' ) );
+ expectMediaPickerCall( 'DEVICE_MEDIA_LIBRARY', [ 'image' ], true );
+
+ // Return media items picked
+ await mediaPickerCallback( media[ 0 ], media[ 1 ] );
+
+ // Check that gallery items are visible
+ await triggerGalleryLayout( galleryBlock );
+ const galleryItem1 = getGalleryItem( galleryBlock, 1 );
+ const galleryItem2 = getGalleryItem( galleryBlock, 2 );
+ expect( galleryItem1 ).toBeVisible();
+ expect( galleryItem2 ).toBeVisible();
+
+ // Check that images are showing a loading state
+ await notifyUploadingState( media[ 0 ] );
+ await notifyUploadingState( media[ 1 ] );
+ expect( within( galleryItem1 ).getByTestId( 'spinner' ) ).toBeVisible();
+ expect( within( galleryItem2 ).getByTestId( 'spinner' ) ).toBeVisible();
+
+ // Notify that the media items upload succeeded
+ await notifySucceedState( media[ 0 ] );
+ await notifySucceedState( media[ 1 ] );
+
+ expect( getEditorHtml() ).toMatchSnapshot();
+ } );
+
+ // Test case related to TC006 - Choose from device (stay in editor) - Failed upload
+ // Reference: https://github.com/wordpress-mobile/test-cases/blob/trunk/test-cases/gutenberg/gallery.md#tc006
+ it( 'handles failed uploads', async () => {
+ const { notifyUploadingState, notifyFailedState } = setupMediaUpload();
+ const {
+ expectMediaPickerCall,
+ mediaPickerCallback,
+ } = setupMediaPicker();
+
+ // Initialize with an empty gallery
+ const { galleryBlock, getByText } = await initializeWithGalleryBlock();
+
+ // Upload images from device
+ fireEvent.press( getByText( 'ADD MEDIA' ) );
+ fireEvent.press( getByText( 'Choose from device' ) );
+ expectMediaPickerCall( 'DEVICE_MEDIA_LIBRARY', [ 'image' ], true );
+
+ // Return media items picked
+ await mediaPickerCallback( media[ 0 ], media[ 1 ] );
+
+ // Check that gallery items are visible
+ await triggerGalleryLayout( galleryBlock );
+ const galleryItem1 = getGalleryItem( galleryBlock, 1 );
+ const galleryItem2 = getGalleryItem( galleryBlock, 2 );
+ expect( galleryItem1 ).toBeVisible();
+ expect( galleryItem2 ).toBeVisible();
+
+ // Check that images are showing a loading state
+ await notifyUploadingState( media[ 0 ] );
+ await notifyUploadingState( media[ 1 ] );
+ expect( within( galleryItem1 ).getByTestId( 'spinner' ) ).toBeVisible();
+ expect( within( galleryItem2 ).getByTestId( 'spinner' ) ).toBeVisible();
+
+ // Notify that the media items uploads failed
+ await notifyFailedState( media[ 0 ] );
+ await notifyFailedState( media[ 1 ] );
+
+ // Check that failed images provide the option to retry the upload
+ fireEvent.press( galleryItem1 );
+ fireEvent.press(
+ within( galleryItem1 ).getByText( /Failed to insert media/ )
);
- fireEvent( innerBlockListWrapper, 'layout', {
- nativeEvent: {
- layout: {
- width: 100,
- },
- },
+ expect( requestImageFailedRetryDialog ).toHaveBeenCalledWith(
+ media[ 0 ].localId
+ );
+ fireEvent.press( galleryItem2 );
+ fireEvent.press(
+ within( galleryItem2 ).getByText( /Failed to insert media/ )
+ );
+ expect( requestImageFailedRetryDialog ).toHaveBeenCalledWith(
+ media[ 1 ].localId
+ );
+
+ expect( getEditorHtml() ).toMatchSnapshot();
+ } );
+
+ // Test case related to TC007 - Take a photo
+ // Reference: https://github.com/wordpress-mobile/test-cases/blob/trunk/test-cases/gutenberg/gallery.md#tc007
+ it( 'takes a photo', async () => {
+ const { notifyUploadingState, notifySucceedState } = setupMediaUpload();
+ const {
+ expectMediaPickerCall,
+ mediaPickerCallback,
+ } = setupMediaPicker();
+
+ // Initialize with an empty gallery
+ const { galleryBlock, getByText } = await initializeWithGalleryBlock();
+
+ // Take a photo
+ fireEvent.press( getByText( 'ADD MEDIA' ) );
+ fireEvent.press( getByText( 'Take a Photo' ) );
+ expectMediaPickerCall( 'DEVICE_CAMERA', [ 'image' ], true );
+
+ // Return media item from photo taken
+ await mediaPickerCallback( media[ 0 ] );
+
+ // Check gallery item is visible
+ await triggerGalleryLayout( galleryBlock );
+ const galleryItem = getGalleryItem( galleryBlock, 1 );
+ expect( galleryItem ).toBeVisible();
+
+ // Check image is showing a loading state
+ await notifyUploadingState( media[ 0 ] );
+ expect( within( galleryItem ).getByTestId( 'spinner' ) ).toBeVisible();
+
+ // Notify that the media item upload succeeded
+ await notifySucceedState( media[ 0 ] );
+
+ expect( getEditorHtml() ).toMatchSnapshot();
+ } );
+
+ // Test case related to TC008 - Choose from the free photo library
+ // Reference: https://github.com/wordpress-mobile/test-cases/blob/trunk/test-cases/gutenberg/gallery.md#tc008
+ it( 'uploads from free photo library', async () => {
+ const freePhotoMedia = [ ...media ].map( ( item, index ) => ( {
+ ...item,
+ localUrl: `https://images.pexels.com/photos/110854/pexels-photo-${
+ index + 1
+ }.jpeg`,
+ } ) );
+ const { notifyUploadingState, notifySucceedState } = setupMediaUpload();
+ const {
+ expectMediaPickerCall,
+ mediaPickerCallback,
+ } = setupMediaPicker();
+
+ let otherMediaOptionsCallback;
+ getOtherMediaOptions.mockImplementation( ( filter, callback ) => {
+ otherMediaOptionsCallback = callback;
} );
- const galleryItem = await waitFor( () =>
- getByA11yLabel( /Image Block\. Row 1/ )
+ // Initialize with an empty gallery
+ const { galleryBlock, getByText } = await initializeWithGalleryBlock();
+
+ // Notify other media options
+ act( () =>
+ otherMediaOptionsCallback( [
+ {
+ label: 'Free Photo Library',
+ value: 'stock-photo-library',
+ },
+ ] )
+ );
+
+ // Upload images from free photo library
+ fireEvent.press( getByText( 'ADD MEDIA' ) );
+ fireEvent.press( getByText( 'Free Photo Library' ) );
+ expectMediaPickerCall( 'stock-photo-library', [ 'image' ], true );
+
+ // Return media items picked
+ await act( async () =>
+ mediaPickerCallback( freePhotoMedia[ 0 ], freePhotoMedia[ 1 ] )
);
- fireEvent.press( galleryItem );
- expect( galleryItem ).toHaveProperty( 'type', 'View' );
+ // Check that gallery items are visible
+ await triggerGalleryLayout( galleryBlock );
+ const galleryItem1 = getGalleryItem( galleryBlock, 1 );
+ const galleryItem2 = getGalleryItem( galleryBlock, 2 );
+ expect( galleryItem1 ).toBeVisible();
+ expect( galleryItem2 ).toBeVisible();
+
+ // Check that images are showing a loading state
+ await notifyUploadingState( freePhotoMedia[ 0 ] );
+ await notifyUploadingState( freePhotoMedia[ 1 ] );
+ expect( within( galleryItem1 ).getByTestId( 'spinner' ) ).toBeVisible();
+ expect( within( galleryItem2 ).getByTestId( 'spinner' ) ).toBeVisible();
+
+ // Notify that the media items upload succeeded
+ await notifySucceedState( freePhotoMedia[ 0 ] );
+ await notifySucceedState( freePhotoMedia[ 1 ] );
+
+ expect( getEditorHtml() ).toMatchSnapshot();
} );
- it( 'shows appender button when gallery has images', async () => {
- const { getByA11yLabel, getByText } = await initializeEditor( {
- initialHtml: GALLERY_WITH_ONE_IMAGE,
+ // Test case related to TC009 - Choose from device (stay in editor) - Cancel upload
+ // Reference: https://github.com/wordpress-mobile/test-cases/blob/trunk/test-cases/gutenberg/gallery.md#tc009
+ it( 'cancels uploads', async () => {
+ const { notifyUploadingState, notifyResetState } = setupMediaUpload();
+ const {
+ expectMediaPickerCall,
+ mediaPickerCallback,
+ } = setupMediaPicker();
+
+ // Initialize with an empty gallery
+ const { galleryBlock, getByText } = await initializeWithGalleryBlock();
+
+ // Upload images from device
+ fireEvent.press( getByText( 'ADD MEDIA' ) );
+ fireEvent.press( getByText( 'Choose from device' ) );
+ expectMediaPickerCall( 'DEVICE_MEDIA_LIBRARY', [ 'image' ], true );
+
+ // Return media items picked
+ await mediaPickerCallback( media[ 0 ], media[ 1 ] );
+
+ // Check that gallery items are visible
+ await triggerGalleryLayout( galleryBlock );
+ const galleryItem1 = getGalleryItem( galleryBlock, 1 );
+ const galleryItem2 = getGalleryItem( galleryBlock, 2 );
+ expect( galleryItem1 ).toBeVisible();
+ expect( galleryItem2 ).toBeVisible();
+
+ // Check that images are showing a loading state
+ await notifyUploadingState( media[ 0 ] );
+ await notifyUploadingState( media[ 1 ] );
+ expect( within( galleryItem1 ).getByTestId( 'spinner' ) ).toBeVisible();
+ expect( within( galleryItem2 ).getByTestId( 'spinner' ) ).toBeVisible();
+
+ // Cancel uploads
+ fireEvent.press( galleryItem1 );
+ fireEvent.press( within( galleryItem1 ).getByTestId( 'spinner' ) );
+ expect( requestImageUploadCancelDialog ).toHaveBeenCalledWith(
+ media[ 0 ].localId
+ );
+ await notifyResetState( media[ 0 ] );
+
+ fireEvent.press( galleryItem2 );
+ fireEvent.press( within( galleryItem2 ).getByTestId( 'spinner' ) );
+ expect( requestImageUploadCancelDialog ).toHaveBeenCalledWith(
+ media[ 1 ].localId
+ );
+ await notifyResetState( media[ 1 ] );
+
+ expect( getEditorHtml() ).toMatchSnapshot();
+ } );
+
+ // Test case related to TC010 - Rearrange images in Gallery
+ // Reference: https://github.com/wordpress-mobile/test-cases/blob/trunk/test-cases/gutenberg/gallery.md#tc010
+ it( 'rearranges gallery items', async () => {
+ // Initialize with a gallery that contains three items
+ const { galleryBlock } = await initializeWithGalleryBlock( {
+ numberOfItems: 3,
+ media,
} );
- const galleryBlock = await waitFor( () =>
- getByA11yLabel( /Gallery Block\. Row 1/ )
+ // Rearrange items (final disposition will be: Image 3 - Image 1 - Image 2)
+ const galleryItem1 = getGalleryItem( galleryBlock, 1 );
+ const galleryItem3 = getGalleryItem( galleryBlock, 3 );
+
+ fireEvent.press( galleryItem3 );
+ await act( () =>
+ fireEvent.press(
+ within( galleryItem3 ).getByA11yLabel(
+ /Move block left from position 3 to position 2/
+ )
+ )
);
- fireEvent.press( galleryBlock );
- const innerBlockListWrapper = await waitFor( () =>
- within( galleryBlock ).getByTestId( 'block-list-wrapper' )
+ fireEvent.press( galleryItem1 );
+ await act( () =>
+ fireEvent.press(
+ within( galleryItem1 ).getByA11yLabel(
+ /Move block right from position 1 to position 2/
+ )
+ )
);
- fireEvent( innerBlockListWrapper, 'layout', {
- nativeEvent: {
- layout: {
- width: 100,
- },
- },
+
+ expect( getEditorHtml() ).toMatchSnapshot();
+ } );
+
+ // Test case related to TC011 - Choose from Other Apps (iOS Files App)
+ // Reference: https://github.com/wordpress-mobile/test-cases/blob/trunk/test-cases/gutenberg/gallery.md#tc011
+ it( 'uploads from other apps', async () => {
+ const otherAppsMedia = [ ...media ].map( ( item, index ) => ( {
+ ...item,
+ localUrl: `file:///IMG_${ index + 1 }.JPG`,
+ } ) );
+ const { notifyUploadingState, notifySucceedState } = setupMediaUpload();
+ const {
+ expectMediaPickerCall,
+ mediaPickerCallback,
+ } = setupMediaPicker();
+
+ let otherMediaOptionsCallback;
+ getOtherMediaOptions.mockImplementation( ( filter, callback ) => {
+ otherMediaOptionsCallback = callback;
} );
- const appenderButton = await waitFor( () =>
- within( galleryBlock ).getByA11yLabel( /Gallery block\. Empty/ )
+ // Initialize with an empty gallery
+ const { galleryBlock, getByText } = await initializeWithGalleryBlock();
+
+ // Notify other media options
+ act( () =>
+ otherMediaOptionsCallback( [
+ { label: 'Other Apps', value: 'other-files' },
+ ] )
);
- fireEvent.press( appenderButton );
- expect( getByText( 'Choose from device' ) ).toBeDefined();
- expect( getByText( 'Take a Photo' ) ).toBeDefined();
- expect( getByText( 'WordPress Media Library' ) ).toBeDefined();
+ // Upload images from other apps
+ fireEvent.press( getByText( 'ADD MEDIA' ) );
+ fireEvent.press( getByText( 'Other Apps' ) );
+ expectMediaPickerCall( 'other-files', [ 'image' ], true );
+
+ // Return media items picked
+ await mediaPickerCallback( otherAppsMedia[ 0 ], otherAppsMedia[ 1 ] );
+
+ // Check that gallery items are visible
+ await triggerGalleryLayout( galleryBlock );
+ const galleryItem1 = getGalleryItem( galleryBlock, 1 );
+ const galleryItem2 = getGalleryItem( galleryBlock, 2 );
+ expect( galleryItem1 ).toBeVisible();
+ expect( galleryItem2 ).toBeVisible();
+
+ // Check that images are showing a loading state
+ await notifyUploadingState( otherAppsMedia[ 0 ] );
+ await notifyUploadingState( otherAppsMedia[ 1 ] );
+ expect( within( galleryItem1 ).getByTestId( 'spinner' ) ).toBeVisible();
+ expect( within( galleryItem2 ).getByTestId( 'spinner' ) ).toBeVisible();
+
+ // Notify that the media items upload succeeded
+ await notifySucceedState( otherAppsMedia[ 0 ] );
+ await notifySucceedState( otherAppsMedia[ 1 ] );
+
+ expect( getEditorHtml() ).toMatchSnapshot();
+ } );
+
+ // Test case related to TC012 - Settings - Link to
+ // Reference: https://github.com/wordpress-mobile/test-cases/blob/trunk/test-cases/gutenberg/gallery.md#tc012
+ it( 'overrides "Link to" setting of gallery items', async () => {
+ // Initialize with a gallery that contains two items, the latter includes "linkDestination" attribute
+ const screen = await initializeWithGalleryBlock( {
+ html: `
+
+
+
+
+
+
+
+ `,
+ numberOfItems: 2,
+ } );
+ const { getByText } = screen;
+
+ // Set "Link to" setting via Gallery block settings
+ await openBlockSettings( screen );
+ fireEvent.press( getByText( 'Link to' ) );
+ fireEvent.press( getByText( 'Media File' ) );
+
+ expect( getEditorHtml() ).toMatchSnapshot();
+ } );
+
+ // Test cases related to TC013 - Settings - Columns
+ // Reference: https://github.com/wordpress-mobile/test-cases/blob/trunk/test-cases/gutenberg/gallery.md#tc013
+ describe( 'Columns setting', () => {
+ it( 'does not increment due to maximum value', async () => {
+ // Initialize with a gallery that contains three items
+ const screen = await initializeWithGalleryBlock( {
+ numberOfItems: 3,
+ media,
+ } );
+ const { getByA11yLabel } = screen;
+
+ await openBlockSettings( screen );
+
+ // Can't increment due to maximum value
+ // NOTE: Default columns value is 3
+ fireEvent(
+ getByA11yLabel( /Columns\. Value is 3/ ),
+ 'accessibilityAction',
+ {
+ nativeEvent: { actionName: 'increment' },
+ }
+ );
+ expect( getEditorHtml() ).toMatchSnapshot();
+ } );
+
+ it( 'decrements columns', async () => {
+ // Initialize with a gallery that contains three items
+ const screen = await initializeWithGalleryBlock( {
+ numberOfItems: 3,
+ media,
+ } );
+ const { getByA11yLabel } = screen;
+
+ await openBlockSettings( screen );
+
+ // Decrement columns
+ fireEvent(
+ getByA11yLabel( /Columns\. Value is 3/ ),
+ 'accessibilityAction',
+ {
+ nativeEvent: { actionName: 'decrement' },
+ }
+ );
+ expect( getEditorHtml() ).toMatchSnapshot();
+ } );
+ } );
+
+ // Test case related to TC014 - Settings - Crop images
+ // Reference: https://github.com/wordpress-mobile/test-cases/blob/trunk/test-cases/gutenberg/gallery.md#tc014
+ it( 'disables crop images setting', async () => {
+ // Initialize with a gallery that contains one item
+ const screen = await initializeWithGalleryBlock( {
+ numberOfItems: 1,
+ media,
+ } );
+ const { getByText } = screen;
+
+ await openBlockSettings( screen );
+
+ // Disable crop images setting
+ fireEvent.press( getByText( 'Crop images' ) );
+ expect( getEditorHtml() ).toMatchSnapshot();
} );
} );
diff --git a/test/native/helpers.js b/test/native/helpers.js
index 3a2386d46bf376..a2918aa6354f1d 100644
--- a/test/native/helpers.js
+++ b/test/native/helpers.js
@@ -35,48 +35,26 @@ provideToNativeHtml.mockImplementation( ( html ) => {
} );
/**
- * Initialize an editor for test assertions.
+ * Executes a function that triggers store resolvers and waits for them to be finished.
*
- * @param {Object} props Properties passed to the editor component.
- * @param {string} props.initialHtml String of block editor HTML to parse and render.
- * @param {Object} [options] Configuration options for the editor.
- * @param {import('react').ReactNode} [options.component] A specific editor component to render.
- * @return {import('@testing-library/react-native').RenderAPI} A Testing Library screen.
+ * Asynchronous store resolvers leverage `setTimeout` to run at the end of
+ * the current JavaScript block execution. In order to prevent "act" warnings
+ * triggered by updates to the React tree, we manually tick fake timers and
+ * await the resolution of the current block execution before proceeding.
+ *
+ * @param {Function} fn Function that triggers store resolvers.
+ * @return {*} The result of the function call.
*/
-export async function initializeEditor( props, { component = Editor } = {} ) {
+export async function waitForStoreResolvers( fn ) {
// Portions of the React Native Animation API rely upon these APIs. However,
// Jest's 'legacy' fake timers mutate these globals, which breaks the Animated
// API. We preserve the original implementations to restore them later.
const originalRAF = global.requestAnimationFrame;
const originalCAF = global.cancelAnimationFrame;
- // During editor initialization, asynchronous store resolvers leverage
- // `setTimeout` to run at the end of the current JavaScript block execution.
- // In order to prevent "act" warnings triggered by updates to the React tree,
- // we manually tick fake timers and await the resolution of the current block
- // execution before proceeding.
jest.useFakeTimers( 'legacy' );
- // Arrange.
- const EditorComponent = component;
- const screen = render(
-
- );
-
- // A layout event must be explicitly dispatched in BlockList component,
- // otherwise the inner blocks are not rendered.
- fireEvent( screen.getByTestId( 'block-list-wrapper' ), 'layout', {
- nativeEvent: {
- layout: {
- width: 100,
- },
- },
- } );
+ const result = fn();
// Advance all timers allowing store resolvers to resolve.
act( () => jest.runAllTimers() );
@@ -97,7 +75,42 @@ export async function initializeEditor( props, { component = Editor } = {} ) {
global.requestAnimationFrame = originalRAF;
global.cancelAnimationFrame = originalCAF;
- return screen;
+ return result;
+}
+
+/**
+ * Initialize an editor for test assertions.
+ *
+ * @param {Object} props Properties passed to the editor component.
+ * @param {string} props.initialHtml String of block editor HTML to parse and render.
+ * @param {Object} [options] Configuration options for the editor.
+ * @param {import('react').ReactNode} [options.component] A specific editor component to render.
+ * @return {import('@testing-library/react-native').RenderAPI} A Testing Library screen.
+ */
+export async function initializeEditor( props, { component = Editor } = {} ) {
+ return waitForStoreResolvers( () => {
+ const EditorComponent = component;
+ const screen = render(
+
+ );
+
+ // A layout event must be explicitly dispatched in BlockList component,
+ // otherwise the inner blocks are not rendered.
+ fireEvent( screen.getByTestId( 'block-list-wrapper' ), 'layout', {
+ nativeEvent: {
+ layout: {
+ width: 100,
+ },
+ },
+ } );
+
+ return screen;
+ } );
}
export * from '@testing-library/react-native';
diff --git a/test/native/jest.config.js b/test/native/jest.config.js
index f1bbb921925b6d..b46e9d6651db62 100644
--- a/test/native/jest.config.js
+++ b/test/native/jest.config.js
@@ -28,7 +28,7 @@ module.exports = {
setupFiles: [ '/' + configPath + '/setup.js' ],
setupFilesAfterEnv: [ '/' + configPath + '/setup-after-env.js' ],
testMatch: [
- '**/test/*.native.[jt]s?(x)',
+ '**/test/!(helper)*.native.[jt]s?(x)',
'/packages/react-native-*/**/?(*.)+(spec|test).[jt]s?(x)',
],
testPathIgnorePatterns: [
diff --git a/test/native/matchers/to-be-visible.js b/test/native/matchers/to-be-visible.js
index 709e2a0aa707dd..b2b19d239a8561 100644
--- a/test/native/matchers/to-be-visible.js
+++ b/test/native/matchers/to-be-visible.js
@@ -17,7 +17,7 @@ function isStyleVisible( element ) {
}
function isAttributeVisible( element ) {
- return element.type !== 'Modal' || ! element.props.visible === false;
+ return element.type !== 'Modal' || element.props.visible !== false;
}
function isElementVisible( element ) {
diff --git a/test/native/setup.js b/test/native/setup.js
index 4d8a52127ec405..4d65686a620b65 100644
--- a/test/native/setup.js
+++ b/test/native/setup.js
@@ -81,6 +81,8 @@ jest.mock( '@wordpress/react-native-bridge', () => {
subscribeMediaSave: jest.fn(),
getOtherMediaOptions: jest.fn(),
provideToNative_Html: jest.fn(),
+ requestImageFailedRetryDialog: jest.fn(),
+ requestImageUploadCancelDialog: jest.fn(),
requestMediaEditor: jest.fn(),
requestMediaPicker: jest.fn(),
requestUnsupportedBlockFallback: jest.fn(),