mattermost-mobile/app/screens/edit_post/edit_post.test.tsx
Rajat Dabade e39b27be7e
New keyboard library Setup (#9278)
* Initial setup for new keyboard

* fix the offset calculation onMove by adding isKeyboardFullyOpened

* Done with the keyboard handling implementation

* Handled keyboard focus and blured state using context

* Added default height for input container

* Android support

* Tablet state handling

* Fix for refreshing offset in list

* Created a default context for mention post list

* Fix linter errors

* Fix tests

* Minor

* Fix the height issue for tablet view

* Review comments

* Dependency fix

* Reveiw comment

* keyboard animation only enabled with screen on top navigation

* added tests

* Added extra keyboard component for emoji picker (#9328)

* Added extra keyboard component

* handled swipe geature for extra keyboard

* scroll to bottom visible on emoji picker

* Check for stale event for keyboard geature area

* fix keyboard stale event for mid-gesture change swipe direction

* Closing emoji picker when message priority is opened

* changing to thread view closes emoji picker

* Close emoij picker and keyboard when attachment icons are clicked

* handle android emoji picker behaviour

* Remove emoji picker code

* fix tests

* Address reviev comments

* Test fixes

* usePreventDoubleTab for race condition and corrected comment on isInputAccessoryViewMode flag

* File attachments option in bottom sheet quick action (#9331)

* Added extra keyboard component

* handled swipe geature for extra keyboard

* File attachments option in bottom sheet quick action

* Updated tests

* i18n

* fix test

* Added tests

* Review comment

* fix tests

* Integrated Emoji picker to Extra keyboard component.  (#9339)

* Added extra keyboard component

* handled swipe geature for extra keyboard

* scroll to bottom visible on emoji picker

* Fix the post input container height change issue

* Added emoji picker with search functionality

* keyboard height not recorded fallback to default height, set search to false closing emoji picker

* fix search funcitonality in android

* scroll to end bottom dismissed with pressed

* Fixed the scroll issue for android

* fix the flickering post input issue

* Wired up the emoji picker

* Added keyboard and spaceback in emoji picker

* intl extract

* initial cursor position and simple calculation review comment

* separated grapheme to utils file

* Review comments

* nitpick

* Fix the bottom safe area and navigation stack restore fix

* Fix test

* disabled emoji picker in edit post screen

* change the name of the variable to name it clarier

* fix the tab gap issue in andriod

* disabled the emoji skin tone change on andriod

* fix the input container jump issue

* fix the failing test

* UX review

* added white space after the attachment icon

* When @ or / is press open keyboard on andriod also fix the scroll issue when keyboard is open

* back bottom closes emoji picker and opening keyboard jump fix

* reverted code

* fixed the flickering issue of input and scroll position fix

* Fix the gap issue in thread

* fix the warning reaminated issue when opening a channel

* Fix the extra space issue between input and emoij picker

* Minor fix for extra space between input and emoji picker

* Fix thread view bottom safe area and gap between keyboard and searched emojis

* Fix the keyboard issue in tablet

* Fix the warning issue react-native-keyboard-controller

* Fix tests

* Fix the tablet search hight issue

* Replaced the ExtraKeyboardProvider with KeyboardProvider from rnkc for permalink

* Bottom sheet jump issue resolved

* Search do not close after selecting emoji in search list

* Added the bottom inset height in search emoji for android

* fix buid issue

* Fix the cancel state to emoji picker

* use portals for autocomplete

* fix permalink extra space in post list

* Portal only for android

---------

Co-authored-by: Mattermost Build <build@mattermost.com>
Co-authored-by: Rahim Rahman <rahim.rahman@mattermost.com>
Co-authored-by: Harshil Sharma <harshilsharma63@gmail.com>
Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2026-01-13 22:38:35 +05:30

326 lines
12 KiB
TypeScript

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {act, waitFor} from '@testing-library/react-native';
import React from 'react';
import {Alert} from 'react-native';
import DraftEditPostUploadManager from '@managers/draft_upload_manager';
import * as Navigation from '@screens/navigation';
import {fireEvent, renderWithEverything} from '@test/intl-test-helper';
import TestHelper from '@test/test_helper';
import PickerUtil from '@utils/file/file_picker';
import EditPost from './edit_post';
import type {Database} from '@nozbe/watermelondb';
import type PostModel from '@typings/database/models/servers/post';
import type {IntlShape} from 'react-intl';
jest.mock('@utils/file/file_picker');
jest.mock('@managers/draft_upload_manager', () => ({
prepareUpload: jest.fn(),
isUploading: jest.fn(() => false),
registerProgressHandler: jest.fn(() => jest.fn()),
registerErrorHandler: jest.fn(() => jest.fn()),
cancel: jest.fn(),
}));
jest.mock('@screens/navigation', () => ({
openAttachmentOptions: jest.fn(),
buildNavigationButton: jest.fn((id: string, testID: string) => ({id, testID})),
dismissBottomSheet: jest.fn(() => Promise.resolve()),
dismissModal: jest.fn(),
setButtons: jest.fn(),
}));
const TEST_CONFIG = {
serverUrl: 'baseHandler.test.com',
maxPostSize: 1000,
maxFileCount: 10,
maxFileSize: 1000,
} as const;
const TEST_FILES = {
existingFile1: {
id: 'file-1',
name: 'test-1',
extension: 'png',
has_preview_image: true,
height: 100,
mime_type: 'test',
size: 100,
user_id: '1',
width: 100,
},
existingFile2: {
id: 'file-2',
name: 'test-2',
extension: 'pdf',
has_preview_image: false,
height: 100,
mime_type: 'test',
size: 100,
user_id: '1',
width: 100,
},
newFile: {
clientId: 'file-3',
id: 'file-3',
name: 'test-3',
extension: 'txt',
mime_type: 'text/plain',
size: 999,
},
smallFile: {
name: 'test.txt',
mime_type: 'text/plain',
},
largeFile: {
name: 'test.txt',
mime_type: 'text/plain',
size: 1001,
},
} as const satisfies Record<string, Partial<ExtractedFileInfo>>;
const ERROR_MESSAGES = {
uploadsDisabled: 'File uploads from mobile are disabled.',
maxFilesReached: 'Uploads limited to 1 files maximum.',
fileTooLarge: 'Files must be less than 1000 B',
confirmDelete: 'Delete attachment',
confirmDeleteMessage: 'Are you sure you want to remove test-3?',
} as const;
describe('Edit Post', () => {
let database: Database;
const baseProps: Parameters<typeof EditPost>[0] = {
componentId: 'EditPost',
closeButtonId: 'edit-post',
post: {
id: '1',
channelId: '1',
message: 'test',
messageSource: 'test',
userId: '1',
rootId: '1',
metadata: {},
} as PostModel,
maxPostSize: TEST_CONFIG.maxPostSize,
canDelete: true,
files: [TEST_FILES.existingFile1, TEST_FILES.existingFile2],
maxFileCount: TEST_CONFIG.maxFileCount,
maxFileSize: TEST_CONFIG.maxFileSize,
canUploadFiles: true,
};
const setupPickerMock = (file: Partial<ExtractedFileInfo>) => {
jest.mocked(PickerUtil).mockImplementation((intl, onUploadFiles) => ({
attachFileFromFiles: jest.fn(() => {
onUploadFiles?.([file as ExtractedFileInfo]);
return Promise.resolve({error: undefined});
}),
attachFileFromPhotoGallery: jest.fn(() => {
onUploadFiles?.([file as ExtractedFileInfo]);
return Promise.resolve({error: undefined});
}),
attachFileFromCamera: jest.fn(() => {
onUploadFiles?.([file as ExtractedFileInfo]);
return Promise.resolve({error: undefined});
}),
}) as unknown as PickerUtil);
};
const renderEditPost = (props = baseProps) => {
return renderWithEverything(<EditPost {...props}/>, {
database,
serverUrl: TEST_CONFIG.serverUrl,
});
};
const triggerFileUpload = async (screen: ReturnType<typeof renderEditPost>) => {
let onUploadFilesCallback: ((files: ExtractedFileInfo[]) => void) | undefined;
jest.mocked(Navigation.openAttachmentOptions).mockImplementation((intl, theme, props) => {
onUploadFilesCallback = props?.onUploadFiles;
return undefined;
});
await act(async () => {
fireEvent.press(screen.getByTestId('edit_post.quick_actions.attachment_action'));
});
if (onUploadFilesCallback) {
await act(async () => {
const mockIntl = {formatMessage: jest.fn()} as unknown as IntlShape;
const mockPicker = new PickerUtil(mockIntl, onUploadFilesCallback as (files: ExtractedFileInfo[]) => void);
await mockPicker.attachFileFromFiles(undefined, true);
});
}
};
const triggerFileRemoval = async (screen: ReturnType<typeof renderEditPost>, fileId: string) => {
await act(async () => {
fireEvent.press(screen.getByTestId(`remove-button-${fileId}`));
});
};
beforeAll(async () => {
const server = await TestHelper.setupServerDatabase(TEST_CONFIG.serverUrl);
database = server.database;
const operator = server.operator;
operator.handleConfigs({
configs: [
{id: 'Version', value: '10.5.0'},
{id: 'EnableFileAttachments', value: 'true'},
{id: 'EnableMobileFileUpload', value: 'true'},
],
configsToDelete: [],
prepareRecordsOnly: false,
});
});
beforeEach(() => {
jest.clearAllMocks();
});
describe('Rendering', () => {
it('should render existing attachments in edit mode', () => {
const screen = renderEditPost();
expect(screen.getByTestId('uploads')).toBeVisible();
expect(screen.getByTestId('file-1')).toBeVisible();
expect(screen.getByTestId('file-2')).toBeVisible();
});
});
describe('File Upload Validation', () => {
it('should show error when file uploads are disabled', async () => {
setupPickerMock(TEST_FILES.smallFile);
const props = {...baseProps, canUploadFiles: false};
const screen = renderEditPost(props);
await triggerFileUpload(screen);
await waitFor(() => {
expect(screen.getByText(ERROR_MESSAGES.uploadsDisabled)).toBeVisible();
});
});
it('should show error when maximum file count is reached', async () => {
setupPickerMock(TEST_FILES.smallFile);
const props = {...baseProps, maxFileCount: 1};
const screen = renderEditPost(props);
await triggerFileUpload(screen);
await waitFor(() => {
expect(screen.getByText(ERROR_MESSAGES.maxFilesReached)).toBeVisible();
});
});
it('should show error when file size exceeds limit', async () => {
setupPickerMock(TEST_FILES.largeFile);
const props = {...baseProps, maxFileSize: 1000};
const screen = renderEditPost(props);
await triggerFileUpload(screen);
await waitFor(() => {
expect(screen.getByText(ERROR_MESSAGES.fileTooLarge)).toBeVisible();
});
});
});
describe('File Upload Integration', () => {
it('should integrate with DraftEditPostUploadManager for successful uploads', async () => {
setupPickerMock(TEST_FILES.newFile);
const screen = renderEditPost();
await triggerFileUpload(screen);
await waitFor(() => {
expect(DraftEditPostUploadManager.prepareUpload).toHaveBeenCalledWith(
TEST_CONFIG.serverUrl,
TEST_FILES.newFile,
baseProps.post.channelId,
baseProps.post.rootId,
0,
true,
expect.any(Function),
);
expect(DraftEditPostUploadManager.registerErrorHandler).toHaveBeenCalledWith(
TEST_FILES.newFile.clientId,
expect.any(Function),
);
});
});
});
describe('File Removal', () => {
beforeEach(() => {
jest.mocked(DraftEditPostUploadManager.isUploading).mockReturnValue(true);
setupPickerMock(TEST_FILES.newFile);
});
it('should remove newly uploaded files without confirmation', async () => {
const screen = renderEditPost();
await triggerFileUpload(screen);
await triggerFileRemoval(screen, TEST_FILES.newFile.id);
expect(Alert.alert).not.toHaveBeenCalled();
await waitFor(() => {
expect(DraftEditPostUploadManager.cancel).toHaveBeenCalledWith(TEST_FILES.newFile.clientId);
});
});
it('should show confirmation dialog for existing files', async () => {
const screen = renderEditPost();
await triggerFileRemoval(screen, TEST_FILES.existingFile1.id);
await waitFor(() => {
expect(Alert.alert).toHaveBeenCalledWith(
ERROR_MESSAGES.confirmDelete,
'Are you sure you want to remove test-1?',
expect.any(Array),
);
});
});
});
describe('Error Display', () => {
it('should display upload error in PostError component when file upload fails', async () => {
setupPickerMock(TEST_FILES.largeFile);
const props = {...baseProps, maxFileSize: 1000};
const screen = renderEditPost(props);
// Trigger file upload that will cause size error
await triggerFileUpload(screen);
// Verify that the PostError component is displayed with the error
await waitFor(() => {
expect(screen.getByTestId('edit_post.message.input.error')).toBeVisible();
expect(screen.getByText(ERROR_MESSAGES.fileTooLarge)).toBeVisible();
});
});
it('should display upload error with divider when error is present', async () => {
setupPickerMock(TEST_FILES.smallFile);
const props = {...baseProps, canUploadFiles: false};
const screen = renderEditPost(props);
// Trigger file upload that will cause upload disabled error
await triggerFileUpload(screen);
// Verify that the error is displayed
await waitFor(() => {
expect(screen.getByText(ERROR_MESSAGES.uploadsDisabled)).toBeVisible();
});
// Verify that the PostError component has the hasError styling applied
const editPostInput = screen.getByTestId('edit_post.message.input');
expect(editPostInput).toBeVisible();
});
it('should not display PostError component when no upload error exists', () => {
const screen = renderEditPost();
// Verify that no error message is displayed
expect(screen.queryByTestId('edit_post.message.input.error')).toBeNull();
});
});
});