MM-63649 - fix preview embeded images (#30685)
* MM-63649 - fix preview embeded images * enhance utils function and simplify validations in file preview modal component * add new test for proxied images without extension * simplify logic; apply DRY unifying it via helper function * if extension is available in file info, use it first * add extra validation for the extension lenght if fileInfo.extension is present --------- Co-authored-by: Mattermost Build <build@mattermost.com>
Этот коммит содержится в:
@@ -0,0 +1,94 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
// ***************************************************************
|
||||
// - [#] indicates a test step (e.g. # Go to a page)
|
||||
// - [*] indicates an assertion (e.g. * Check the title)
|
||||
// - Use element ID when selecting an element. Create one if none.
|
||||
// ***************************************************************
|
||||
|
||||
// Stage: @prod
|
||||
// Group: @channels @messaging
|
||||
|
||||
describe('Image URL Preview', () => {
|
||||
before(() => {
|
||||
// # Login as test user and visit the newly created test channel
|
||||
cy.apiInitSetup({loginAfter: true}).then(({team, channel}) => {
|
||||
// # Visit a test channel
|
||||
cy.visit(`/${team.name}/channels/${channel.name}`);
|
||||
});
|
||||
});
|
||||
|
||||
it('MM-T1 - Image URL in markdown format opens correctly in preview modal', () => {
|
||||
// # Post a message with text and an image URL in markdown format
|
||||
const imageUrl = 'https://raw.githubusercontent.com/mattermost/mattermost/master/e2e-tests/cypress/tests/fixtures/image-small-height.png';
|
||||
cy.postMessage('This is a test message with an image. This text should appear after the image.');
|
||||
|
||||
// * Confirm the image is rendered in the post
|
||||
cy.uiWaitUntilMessagePostedIncludes('This is a test message with an image');
|
||||
cy.get('.markdown-inline-img').should('be.visible');
|
||||
cy.getLastPostId().then((postId) => {
|
||||
cy.get(`#postMessageText_${postId}`).should('contain', 'This text should appear after the image');
|
||||
});
|
||||
|
||||
// # Click on the image in the last post to open the preview modal
|
||||
cy.getLastPostId().then((postId) => {
|
||||
cy.get(`#post_${postId}`).find('.file-preview__button').click();
|
||||
});
|
||||
|
||||
// * Verify the preview modal opens and the image is displayed correctly
|
||||
cy.findByTestId('imagePreview').should('be.visible').and('have.class', 'image_preview__image').and('have.attr', 'alt', 'preview url image').and('have.attr', 'src').and('include', 'image-small-height.png');
|
||||
|
||||
// # Close the modal
|
||||
cy.uiCloseFilePreviewModal();
|
||||
});
|
||||
|
||||
it('MM-T2 - Image URL without file extension in markdown format opens correctly in preview modal', () => {
|
||||
// # Post a message with text and an image URL without file extension in markdown format
|
||||
const imageUrl = 'https://hub.mattermost.com/files/sdrkars9kfdrxmyj4gcz6xk9de/public?h=dM-SC6JuRu0DarFyFPEMG_-io9gi7VY2qNV4Z59TmsM';
|
||||
cy.postMessage('This is a test message with an image without extension. This text should appear after the image.');
|
||||
|
||||
// * Confirm the image is rendered in the post
|
||||
cy.uiWaitUntilMessagePostedIncludes('This is a test message with an image without extension');
|
||||
cy.get('.markdown-inline-img').should('be.visible');
|
||||
cy.getLastPostId().then((postId) => {
|
||||
cy.get(`#postMessageText_${postId}`).should('contain', 'This text should appear after the image');
|
||||
});
|
||||
|
||||
// # Click on the image in the last post to open the preview modal
|
||||
cy.getLastPostId().then((postId) => {
|
||||
cy.get(`#post_${postId}`).find('.file-preview__button').click();
|
||||
});
|
||||
|
||||
// * Verify the preview modal opens and the image is displayed correctly
|
||||
cy.findByTestId('imagePreview').should('be.visible').and('have.class', 'image_preview__image').and('have.attr', 'alt', 'preview url image').and('have.attr', 'src').and('include', 'public');
|
||||
|
||||
// # Close the modal
|
||||
cy.uiCloseFilePreviewModal();
|
||||
});
|
||||
|
||||
it('MM-T3 - Proxied image URL in markdown format opens correctly in preview modal', () => {
|
||||
// # Post a message with text and a proxied image URL in markdown format
|
||||
const originalImageUrl = 'https://raw.githubusercontent.com/mattermost/mattermost/master/e2e-tests/cypress/tests/fixtures/image-small-height.png';
|
||||
const proxiedImageUrl = Cypress.config('baseUrl') + '/api/v4/image?url=' + encodeURIComponent(originalImageUrl);
|
||||
cy.postMessage('This is a test message with a proxied image URL. This text should appear after the image.');
|
||||
|
||||
// * Confirm the image is rendered in the post
|
||||
cy.uiWaitUntilMessagePostedIncludes('This is a test message with a proxied image URL');
|
||||
cy.get('.markdown-inline-img').should('be.visible');
|
||||
cy.getLastPostId().then((postId) => {
|
||||
cy.get(`#postMessageText_${postId}`).should('contain', 'This text should appear after the image');
|
||||
});
|
||||
|
||||
// # Click on the image in the last post to open the preview modal
|
||||
cy.getLastPostId().then((postId) => {
|
||||
cy.get(`#post_${postId}`).find('.file-preview__button').click();
|
||||
});
|
||||
|
||||
// * Verify the preview modal opens and the image is displayed correctly
|
||||
cy.findByTestId('imagePreview').should('be.visible').and('have.class', 'image_preview__image').and('have.attr', 'alt', 'preview url image').and('have.attr', 'src').and('include', proxiedImageUrl);
|
||||
|
||||
// # Close the modal
|
||||
cy.uiCloseFilePreviewModal();
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import FilePreviewModal from 'components/file_preview_modal/file_preview_modal';
|
||||
|
||||
import Constants from 'utils/constants';
|
||||
import {TestHelper} from 'utils/test_helper';
|
||||
import * as Utils from 'utils/utils';
|
||||
import {generateId} from 'utils/utils';
|
||||
|
||||
describe('components/FilePreviewModal', () => {
|
||||
@@ -152,6 +153,66 @@ describe('components/FilePreviewModal', () => {
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('should correctly identify image URLs with isImageUrl method', () => {
|
||||
const wrapper = shallow<FilePreviewModal>(<FilePreviewModal {...baseProps}/>);
|
||||
|
||||
// Test proxied image URLs
|
||||
expect(wrapper.instance().isImageUrl('http://localhost:8065/api/v4/image?url=https%3A%2F%2Fexample.com%2Fimage.jpg')).toBe(true);
|
||||
|
||||
// Test URLs with image extensions
|
||||
expect(wrapper.instance().isImageUrl('https://example.com/image.jpg')).toBe(true);
|
||||
expect(wrapper.instance().isImageUrl('https://example.com/image.png')).toBe(true);
|
||||
expect(wrapper.instance().isImageUrl('https://example.com/image.gif')).toBe(true);
|
||||
|
||||
// Test non-image URLs
|
||||
expect(wrapper.instance().isImageUrl('https://example.com/document.pdf')).toBe(false);
|
||||
expect(wrapper.instance().isImageUrl('https://example.com/file.txt')).toBe(false);
|
||||
});
|
||||
|
||||
test('should handle external image URLs correctly', () => {
|
||||
// Create a mock for Utils.loadImage
|
||||
const loadImageSpy = jest.spyOn(Utils, 'loadImage').mockImplementation((url, onLoad) => {
|
||||
// Create a mock ProgressEvent
|
||||
const mockProgressEvent = new ProgressEvent('progress');
|
||||
|
||||
// Call onLoad with the mock event if it exists
|
||||
if (onLoad) {
|
||||
onLoad.call({} as XMLHttpRequest, mockProgressEvent);
|
||||
}
|
||||
});
|
||||
|
||||
// Create a LinkInfo object for an external image URL
|
||||
const externalImageUrl = 'http://localhost:8065/api/v4/image?url=https%3A%2F%2Fexample.com%2Fimage.jpg';
|
||||
const fileInfos = [{
|
||||
has_preview_image: false,
|
||||
link: externalImageUrl,
|
||||
extension: '',
|
||||
name: 'External Image',
|
||||
}];
|
||||
|
||||
const props = {...baseProps, fileInfos};
|
||||
const wrapper = shallow<FilePreviewModal>(<FilePreviewModal {...props}/>);
|
||||
|
||||
// Spy on handleImageLoaded
|
||||
const handleImageLoadedSpy = jest.spyOn(wrapper.instance(), 'handleImageLoaded');
|
||||
|
||||
// Call loadImage with the external image URL
|
||||
wrapper.instance().loadImage(0);
|
||||
|
||||
// Verify that Utils.loadImage was called with the correct URL
|
||||
expect(loadImageSpy).toHaveBeenCalledWith(
|
||||
externalImageUrl,
|
||||
expect.any(Function),
|
||||
expect.any(Function),
|
||||
);
|
||||
|
||||
// Verify that handleImageLoaded was called
|
||||
expect(handleImageLoadedSpy).toHaveBeenCalled();
|
||||
|
||||
// Restore the original loadImage function
|
||||
loadImageSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('should have called loadImage', () => {
|
||||
const fileInfos = [
|
||||
TestHelper.getFileInfoMock({id: 'file_id_1', extension: 'gif'}),
|
||||
|
||||
@@ -28,7 +28,7 @@ import FilePreviewModalFooter from './file_preview_modal_footer/file_preview_mod
|
||||
import FilePreviewModalHeader from './file_preview_modal_header/file_preview_modal_header';
|
||||
import ImagePreview from './image_preview';
|
||||
import PopoverBar from './popover_bar';
|
||||
import {isFileInfo} from './types';
|
||||
import {isFileInfo, isLinkInfo} from './types';
|
||||
import type {LinkInfo} from './types';
|
||||
|
||||
import './file_preview_modal.scss';
|
||||
@@ -164,21 +164,51 @@ export default class FilePreviewModal extends React.PureComponent<Props, State>
|
||||
}
|
||||
};
|
||||
|
||||
isImageUrl = (url: string): boolean => {
|
||||
const fileType = Utils.getFileType(url);
|
||||
return fileType === FileTypes.IMAGE || fileType === FileTypes.SVG;
|
||||
};
|
||||
|
||||
private getFileTypeFromFileInfo = (fileInfo: FileInfo | LinkInfo): typeof FileTypes[keyof typeof FileTypes] => {
|
||||
if (isFileInfo(fileInfo)) {
|
||||
return Utils.getFileType(fileInfo.extension);
|
||||
}
|
||||
|
||||
if (isLinkInfo(fileInfo)) {
|
||||
// if extension is not available or is longer than 5 characters, use the link to determine the file type
|
||||
const maxLenghtExtension = 11; // applescript is the longest extension
|
||||
const extensionOrLink = fileInfo.extension && fileInfo.extension.length <= maxLenghtExtension ? fileInfo.extension : fileInfo.link;
|
||||
return Utils.getFileType(extensionOrLink);
|
||||
}
|
||||
|
||||
return FileTypes.OTHER;
|
||||
};
|
||||
|
||||
loadImage = (index: number) => {
|
||||
const fileInfo = this.props.fileInfos[index];
|
||||
if (isFileInfo(fileInfo) && fileInfo.archived) {
|
||||
this.handleImageLoaded(index);
|
||||
return;
|
||||
}
|
||||
const fileType = Utils.getFileType(fileInfo.extension);
|
||||
|
||||
if (fileType === FileTypes.IMAGE && isFileInfo(fileInfo)) {
|
||||
let previewUrl;
|
||||
if (fileInfo.has_preview_image) {
|
||||
previewUrl = getFilePreviewUrl(fileInfo.id);
|
||||
} else {
|
||||
// some images (eg animated gifs) just show the file itself and not a preview
|
||||
previewUrl = getFileUrl(fileInfo.id);
|
||||
// Determine file type using helper method
|
||||
const fileType = this.getFileTypeFromFileInfo(fileInfo);
|
||||
|
||||
// Check if this is an image
|
||||
const isImage = fileType === FileTypes.IMAGE;
|
||||
|
||||
if (isImage) {
|
||||
let previewUrl = '';
|
||||
if (isFileInfo(fileInfo)) {
|
||||
if (fileInfo.has_preview_image) {
|
||||
previewUrl = getFilePreviewUrl(fileInfo.id);
|
||||
} else {
|
||||
// some images (eg animated gifs) just show the file itself and not a preview
|
||||
previewUrl = getFileUrl(fileInfo.id);
|
||||
}
|
||||
} else if (isLinkInfo(fileInfo)) {
|
||||
// For LinkInfo, use the link directly
|
||||
previewUrl = fileInfo.link;
|
||||
}
|
||||
|
||||
Utils.loadImage(
|
||||
@@ -269,7 +299,9 @@ export default class FilePreviewModal extends React.PureComponent<Props, State>
|
||||
}
|
||||
|
||||
const fileInfo = this.props.fileInfos[this.state.imageIndex];
|
||||
const fileType = Utils.getFileType(fileInfo.extension);
|
||||
|
||||
// Determine file type using helper method
|
||||
const fileType = this.getFileTypeFromFileInfo(fileInfo);
|
||||
|
||||
let showPublicLink;
|
||||
let fileName;
|
||||
|
||||
@@ -13,3 +13,7 @@ export type LinkInfo = {
|
||||
export function isFileInfo(info: FileInfo | LinkInfo): info is FileInfo {
|
||||
return Boolean((info as FileInfo).id);
|
||||
}
|
||||
|
||||
export function isLinkInfo(info: FileInfo | LinkInfo): info is LinkInfo {
|
||||
return Boolean((info as LinkInfo).link) && !isFileInfo(info);
|
||||
}
|
||||
|
||||
@@ -1,574 +1,58 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import type {UserProfile} from '@mattermost/types/users';
|
||||
import {FileTypes} from './constants';
|
||||
import {getFileType} from './utils';
|
||||
|
||||
import {GeneralTypes} from 'mattermost-redux/action_types';
|
||||
|
||||
import store from 'stores/redux_store';
|
||||
|
||||
import * as lineBreakHelpers from 'tests/helpers/line_break_helpers';
|
||||
import * as ua from 'tests/helpers/user_agent_mocks';
|
||||
import Constants, {ValidationErrors, AdvancedTextEditorTextboxIds} from 'utils/constants';
|
||||
import * as Utils from 'utils/utils';
|
||||
|
||||
describe('Utils.getDisplayNameByUser', () => {
|
||||
afterEach(() => {
|
||||
store.dispatch({
|
||||
type: GeneralTypes.CLIENT_CONFIG_RESET,
|
||||
data: {},
|
||||
});
|
||||
describe('Utils.getFileType', () => {
|
||||
test('should identify image files by extension', () => {
|
||||
expect(getFileType('jpg')).toBe(FileTypes.IMAGE);
|
||||
expect(getFileType('png')).toBe(FileTypes.IMAGE);
|
||||
expect(getFileType('gif')).toBe(FileTypes.IMAGE);
|
||||
expect(getFileType('bmp')).toBe(FileTypes.IMAGE);
|
||||
expect(getFileType('tiff')).toBe(FileTypes.IMAGE);
|
||||
});
|
||||
|
||||
const userA = {username: 'a_user', nickname: 'a_nickname', first_name: 'a_first_name', last_name: ''};
|
||||
const userB = {username: 'b_user', nickname: 'b_nickname', first_name: '', last_name: 'b_last_name'};
|
||||
const userC = {username: 'c_user', nickname: '', first_name: 'c_first_name', last_name: 'c_last_name'};
|
||||
const userD = {username: 'd_user', nickname: 'd_nickname', first_name: 'd_first_name', last_name: 'd_last_name'};
|
||||
const userE = {username: 'e_user', nickname: '', first_name: 'e_first_name', last_name: 'e_last_name'};
|
||||
const userF = {username: 'f_user', nickname: 'f_nickname', first_name: 'f_first_name', last_name: 'f_last_name'};
|
||||
const userG = {username: 'g_user', nickname: '', first_name: 'g_first_name', last_name: 'g_last_name'};
|
||||
const userH = {username: 'h_user', nickname: 'h_nickname', first_name: '', last_name: 'h_last_name'};
|
||||
const userI = {username: 'i_user', nickname: 'i_nickname', first_name: 'i_first_name', last_name: ''};
|
||||
const userJ = {username: 'j_user', nickname: '', first_name: 'j_first_name', last_name: ''};
|
||||
|
||||
test('Show display name of user with TeammateNameDisplay set to username', () => {
|
||||
store.dispatch({
|
||||
type: GeneralTypes.CLIENT_CONFIG_RECEIVED,
|
||||
data: {
|
||||
TeammateNameDisplay: 'username',
|
||||
},
|
||||
});
|
||||
|
||||
[userA, userB, userC, userD, userE, userF, userG, userH, userI, userJ].forEach((user) => {
|
||||
expect(Utils.getDisplayNameByUser(store.getState(), user as UserProfile)).toEqual(user.username);
|
||||
});
|
||||
test('should identify image files from URLs with extensions', () => {
|
||||
expect(getFileType('https://example.com/image.jpg')).toBe(FileTypes.IMAGE);
|
||||
expect(getFileType('https://example.com/path/to/image.png')).toBe(FileTypes.IMAGE);
|
||||
expect(getFileType('https://example.com/image.gif?query=param')).toBe(FileTypes.IMAGE);
|
||||
expect(getFileType('http://example.com/image.bmp#fragment')).toBe(FileTypes.IMAGE);
|
||||
});
|
||||
|
||||
test('Show display name of user with TeammateNameDisplay set to nickname_full_name', () => {
|
||||
store.dispatch({
|
||||
type: GeneralTypes.CLIENT_CONFIG_RECEIVED,
|
||||
data: {
|
||||
TeammateNameDisplay: 'nickname_full_name',
|
||||
},
|
||||
});
|
||||
test('should identify image files from URLs without extensions', () => {
|
||||
// Test URLs with /api/v4/image and ?url= parameter
|
||||
expect(getFileType('/api/v4/image?url=https://example.com/image-without-extension')).toBe(FileTypes.IMAGE);
|
||||
expect(getFileType('https://mattermost.com/api/v4/image?url=https://example.com/another-image')).toBe(FileTypes.IMAGE);
|
||||
|
||||
for (const data of [
|
||||
{user: userA, result: userA.nickname},
|
||||
{user: userB, result: userB.nickname},
|
||||
{user: userC, result: `${userC.first_name} ${userC.last_name}`},
|
||||
{user: userD, result: userD.nickname},
|
||||
{user: userE, result: `${userE.first_name} ${userE.last_name}`},
|
||||
{user: userF, result: userF.nickname},
|
||||
{user: userG, result: `${userG.first_name} ${userG.last_name}`},
|
||||
{user: userH, result: userH.nickname},
|
||||
{user: userI, result: userI.nickname},
|
||||
{user: userJ, result: userJ.first_name},
|
||||
]) {
|
||||
expect(Utils.getDisplayNameByUser(store.getState(), data.user as UserProfile)).toEqual(data.result);
|
||||
}
|
||||
// Test URLs with /api/v4/image and &url= parameter (in case it's not the first parameter)
|
||||
expect(getFileType('/api/v4/image?param=value&url=https://example.com/image')).toBe(FileTypes.IMAGE);
|
||||
expect(getFileType('https://mattermost.com/api/v4/image?param=value&url=https://example.com/image')).toBe(FileTypes.IMAGE);
|
||||
});
|
||||
|
||||
test('Show display name of user with TeammateNameDisplay set to username', () => {
|
||||
store.dispatch({
|
||||
type: GeneralTypes.CLIENT_CONFIG_RECEIVED,
|
||||
data: {
|
||||
TeammateNameDisplay: 'full_name',
|
||||
},
|
||||
});
|
||||
test('should identify image files from proxied URLs', () => {
|
||||
expect(getFileType('/api/v4/image?url=https://example.com/image.jpg')).toBe(FileTypes.IMAGE);
|
||||
expect(getFileType('https://mattermost.com/api/v4/image?url=https://example.com/image.png')).toBe(FileTypes.IMAGE);
|
||||
});
|
||||
|
||||
for (const data of [
|
||||
{user: userA, result: userA.first_name},
|
||||
{user: userB, result: userB.last_name},
|
||||
{user: userC, result: `${userC.first_name} ${userC.last_name}`},
|
||||
{user: userD, result: `${userD.first_name} ${userD.last_name}`},
|
||||
{user: userE, result: `${userE.first_name} ${userE.last_name}`},
|
||||
{user: userF, result: `${userF.first_name} ${userF.last_name}`},
|
||||
{user: userG, result: `${userG.first_name} ${userG.last_name}`},
|
||||
{user: userH, result: userH.last_name},
|
||||
{user: userI, result: userI.first_name},
|
||||
{user: userJ, result: userJ.first_name},
|
||||
]) {
|
||||
expect(Utils.getDisplayNameByUser(store.getState(), data.user as UserProfile)).toEqual(data.result);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Utils.isValidUsername', () => {
|
||||
const tests = [
|
||||
{
|
||||
testUserName: 'sonic.the.hedgehog',
|
||||
expectedError: undefined,
|
||||
}, {
|
||||
testUserName: 'sanic.the.speedy.errored.hedgehog@10_10-10',
|
||||
expectedError: ValidationErrors.INVALID_LENGTH,
|
||||
}, {
|
||||
testUserName: 'sanic⭑',
|
||||
expectedError: ValidationErrors.INVALID_CHARACTERS,
|
||||
}, {
|
||||
testUserName: '.sanic',
|
||||
expectedError: ValidationErrors.INVALID_FIRST_CHARACTER,
|
||||
}, {
|
||||
testUserName: 'valet',
|
||||
expectedError: ValidationErrors.RESERVED_NAME,
|
||||
},
|
||||
];
|
||||
test('Validate username', () => {
|
||||
for (const test of tests) {
|
||||
const testError = Utils.isValidUsername(test.testUserName);
|
||||
if (testError) {
|
||||
expect(testError.id).toEqual(test.expectedError);
|
||||
} else {
|
||||
expect(testError).toBe(undefined);
|
||||
}
|
||||
}
|
||||
});
|
||||
test('Validate bot username', () => {
|
||||
tests.push({
|
||||
testUserName: 'sanic.the.hedgehog.',
|
||||
expectedError: ValidationErrors.INVALID_LAST_CHARACTER,
|
||||
});
|
||||
for (const test of tests) {
|
||||
const testError = Utils.isValidUsername(test.testUserName);
|
||||
if (testError) {
|
||||
expect(testError.id).toEqual(test.expectedError);
|
||||
} else {
|
||||
expect(testError).toBe(undefined);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Utils.localizeMessage', () => {
|
||||
const originalGetState = store.getState;
|
||||
|
||||
afterAll(() => {
|
||||
store.getState = originalGetState;
|
||||
});
|
||||
|
||||
const entities = {
|
||||
general: {
|
||||
config: {},
|
||||
},
|
||||
users: {
|
||||
currentUserId: 'abcd',
|
||||
profiles: {
|
||||
abcd: {
|
||||
locale: 'fr',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
describe('with translations', () => {
|
||||
beforeAll(() => {
|
||||
store.getState = () => ({
|
||||
entities,
|
||||
views: {
|
||||
i18n: {
|
||||
translations: {
|
||||
fr: {
|
||||
'test.hello_world': 'Bonjour tout le monde!',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
});
|
||||
|
||||
test('with translations', () => {
|
||||
const messageId = 'test.hello_world';
|
||||
expect(Utils.localizeMessage({id: messageId, defaultMessage: 'Hello, World!'})).toEqual('Bonjour tout le monde!');
|
||||
});
|
||||
|
||||
test('with missing string in translations', () => {
|
||||
const messageId = 'test.hello_world2';
|
||||
expect(Utils.localizeMessage({id: messageId, defaultMessage: 'Hello, World 2!'})).toEqual('Hello, World 2!');
|
||||
});
|
||||
|
||||
test('with missing string in translations and no default', () => {
|
||||
const messageId = 'test.hello_world2';
|
||||
expect(Utils.localizeMessage({id: messageId})).toEqual('test.hello_world2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('without translations', () => {
|
||||
beforeAll(() => {
|
||||
store.getState = () => ({
|
||||
entities,
|
||||
views: {
|
||||
i18n: {
|
||||
translations: {},
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
});
|
||||
|
||||
test('without translations', () => {
|
||||
const messageId = 'test.hello_world';
|
||||
expect(Utils.localizeMessage({id: messageId, defaultMessage: 'Hello, World!'})).toEqual('Hello, World!');
|
||||
});
|
||||
|
||||
test('without translations and no default', () => {
|
||||
const messageId = 'test.hello_world';
|
||||
expect(Utils.localizeMessage({id: messageId})).toEqual('test.hello_world');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Utils.imageURLForUser', () => {
|
||||
test('should return url when user id and last_picture_update is given', () => {
|
||||
const imageUrl = Utils.imageURLForUser('foobar-123', 123456);
|
||||
expect(imageUrl).toEqual('/api/v4/users/foobar-123/image?_=123456');
|
||||
});
|
||||
|
||||
test('should return url when user id is given without last_picture_update', () => {
|
||||
const imageUrl = Utils.imageURLForUser('foobar-123');
|
||||
expect(imageUrl).toEqual('/api/v4/users/foobar-123/image?_=0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Utils.isUnhandledLineBreakKeyCombo', () => {
|
||||
test('isUnhandledLineBreakKeyCombo returns true for alt + enter for Chrome UA', () => {
|
||||
ua.mockChrome();
|
||||
expect(Utils.isUnhandledLineBreakKeyCombo(lineBreakHelpers.getAltKeyEvent())).toBe(true);
|
||||
});
|
||||
|
||||
test('isUnhandledLineBreakKeyCombo returns false for alt + enter for Safari UA', () => {
|
||||
ua.mockSafari();
|
||||
expect(Utils.isUnhandledLineBreakKeyCombo(lineBreakHelpers.getAltKeyEvent())).toBe(false);
|
||||
});
|
||||
|
||||
test('isUnhandledLineBreakKeyCombo returns false for shift + enter', () => {
|
||||
expect(Utils.isUnhandledLineBreakKeyCombo(lineBreakHelpers.getShiftKeyEvent())).toBe(false);
|
||||
});
|
||||
|
||||
test('isUnhandledLineBreakKeyCombo returns false for ctrl/command + enter', () => {
|
||||
expect(Utils.isUnhandledLineBreakKeyCombo(lineBreakHelpers.getCtrlKeyEvent())).toBe(false);
|
||||
expect(Utils.isUnhandledLineBreakKeyCombo(lineBreakHelpers.getMetaKeyEvent())).toBe(false);
|
||||
});
|
||||
|
||||
test('isUnhandledLineBreakKeyCombo returns false for just enter', () => {
|
||||
expect(Utils.isUnhandledLineBreakKeyCombo(lineBreakHelpers.BASE_EVENT)).toBe(false);
|
||||
});
|
||||
|
||||
test('isUnhandledLineBreakKeyCombo returns false for f (random key)', () => {
|
||||
const e = {
|
||||
...lineBreakHelpers.BASE_EVENT,
|
||||
key: Constants.KeyCodes.F[0],
|
||||
keyCode: Constants.KeyCodes.F[1],
|
||||
};
|
||||
expect(Utils.isUnhandledLineBreakKeyCombo(e)).toBe(false);
|
||||
});
|
||||
|
||||
// restore initial user agent
|
||||
afterEach(ua.reset);
|
||||
});
|
||||
|
||||
describe('Utils.insertLineBreakFromKeyEvent', () => {
|
||||
test('insertLineBreakFromKeyEvent returns with line break appending (no selection range)', () => {
|
||||
expect(Utils.insertLineBreakFromKeyEvent(lineBreakHelpers.getAppendEvent())).toBe(lineBreakHelpers.OUTPUT_APPEND);
|
||||
});
|
||||
test('insertLineBreakFromKeyEvent returns with line break replacing (with selection range)', () => {
|
||||
expect(Utils.insertLineBreakFromKeyEvent(lineBreakHelpers.getReplaceEvent())).toBe(lineBreakHelpers.OUTPUT_REPLACE);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Utils.copyTextAreaToDiv', () => {
|
||||
const textArea = document.createElement('textarea');
|
||||
|
||||
test('copyTextAreaToDiv actually creates a div element', () => {
|
||||
const copy = Utils.copyTextAreaToDiv(textArea);
|
||||
|
||||
expect(copy!.nodeName).toEqual('DIV');
|
||||
});
|
||||
|
||||
test('copyTextAreaToDiv copies the content into the div element', () => {
|
||||
textArea.value = 'the content';
|
||||
|
||||
const copy = Utils.copyTextAreaToDiv(textArea);
|
||||
|
||||
expect(copy!.innerHTML).toEqual('the content');
|
||||
});
|
||||
|
||||
test('copyTextAreaToDiv correctly copies the styles of the textArea element', () => {
|
||||
textArea.style.fontFamily = 'Sans-serif';
|
||||
|
||||
const copy = Utils.copyTextAreaToDiv(textArea);
|
||||
|
||||
expect(copy!.style.fontFamily).toEqual('Sans-serif');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Utils.getCaretXYCoordinate', () => {
|
||||
const tmpCreateRange = document.createRange;
|
||||
const cleanUp = () => {
|
||||
document.createRange = tmpCreateRange;
|
||||
};
|
||||
|
||||
afterAll(cleanUp);
|
||||
|
||||
const textArea = document.createElement('textarea');
|
||||
document.createRange = () => {
|
||||
const range = new Range();
|
||||
|
||||
range.getClientRects = () => {
|
||||
return [{
|
||||
top: 10,
|
||||
left: 15,
|
||||
}] as unknown as DOMRectList;
|
||||
};
|
||||
|
||||
return range;
|
||||
};
|
||||
textArea.value = 'm'.repeat(10);
|
||||
|
||||
test('getCaretXYCoordinate returns the coordinates of the caret', () => {
|
||||
const coordinates = Utils.getCaretXYCoordinate(textArea);
|
||||
|
||||
expect(coordinates.x).toEqual(15);
|
||||
expect(coordinates.y).toEqual(10);
|
||||
});
|
||||
|
||||
test('getCaretXYCoordinate returns the coordinates of the caret with a left scroll', () => {
|
||||
textArea.scrollLeft = 5;
|
||||
|
||||
const coordinates = Utils.getCaretXYCoordinate(textArea);
|
||||
|
||||
expect(coordinates.x).toEqual(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Utils.getViewportSize', () => {
|
||||
test('getViewportSize returns the right viewport using default jsDom window', () => {
|
||||
// the default values of the jsDom window are w: 1024, h: 768
|
||||
const viewportDimensions = Utils.getViewportSize();
|
||||
|
||||
expect(viewportDimensions.w).toEqual(1024);
|
||||
expect(viewportDimensions.h).toEqual(768);
|
||||
});
|
||||
|
||||
test('getViewportSize returns the right viewport width with custom parameter', () => {
|
||||
const mockWindow = {document: {body: {}, compatMode: undefined}};
|
||||
(mockWindow.document.body as any).clientWidth = 1025;
|
||||
(mockWindow.document.body as any).clientHeight = 860;
|
||||
|
||||
const viewportDimensions = Utils.getViewportSize(mockWindow as unknown as Window);
|
||||
|
||||
expect(viewportDimensions.w).toEqual(1025);
|
||||
expect(viewportDimensions.h).toEqual(860);
|
||||
});
|
||||
|
||||
test('getViewportSize returns the right viewport width with custom parameter - innerWidth', () => {
|
||||
const mockWindow = {innerWidth: 1027, innerHeight: 767};
|
||||
|
||||
const viewportDimensions = Utils.getViewportSize(mockWindow as unknown as Window);
|
||||
|
||||
expect(viewportDimensions.w).toEqual(1027);
|
||||
expect(viewportDimensions.h).toEqual(767);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Utils.offsetTopLeft', () => {
|
||||
test('offsetTopLeft returns the right offset values', () => {
|
||||
const textArea = document.createElement('textArea');
|
||||
|
||||
textArea.getBoundingClientRect = jest.fn(() => ({
|
||||
top: 967,
|
||||
left: 851,
|
||||
} as DOMRect));
|
||||
|
||||
const offsetTopLeft = Utils.offsetTopLeft(textArea);
|
||||
expect(offsetTopLeft.top).toEqual(967);
|
||||
expect(offsetTopLeft.left).toEqual(851);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Utils.getSuggestionBoxAlgn', () => {
|
||||
const tmpCreateRange = document.createRange;
|
||||
const cleanUp = () => {
|
||||
document.createRange = tmpCreateRange;
|
||||
};
|
||||
|
||||
afterAll(cleanUp);
|
||||
|
||||
const textArea: HTMLTextAreaElement = document.createElement('textArea') as HTMLTextAreaElement;
|
||||
|
||||
textArea.value = 'a'.repeat(30);
|
||||
|
||||
jest.spyOn(textArea, 'offsetWidth', 'get').
|
||||
mockImplementation(() => 950);
|
||||
|
||||
textArea.getBoundingClientRect = jest.fn(() => ({
|
||||
left: 50,
|
||||
} as DOMRect));
|
||||
|
||||
const createRange = (size: number) => {
|
||||
document.createRange = () => {
|
||||
const range = new Range();
|
||||
range.getClientRects = () => {
|
||||
return [{
|
||||
top: 100,
|
||||
left: size,
|
||||
}] as unknown as DOMRectList;
|
||||
};
|
||||
return range;
|
||||
};
|
||||
};
|
||||
|
||||
const fixedToTheRight = textArea.offsetWidth - Constants.SUGGESTION_LIST_MODAL_WIDTH;
|
||||
|
||||
test('getSuggestionBoxAlgn returns 0 (box stuck to left) when the length of the text is small', () => {
|
||||
const smallSizeText = 15;
|
||||
createRange(smallSizeText);
|
||||
const suggestionBoxAlgn = Utils.getSuggestionBoxAlgn(textArea, Utils.getPxToSubstract());
|
||||
expect(suggestionBoxAlgn.pixelsToMoveX).toEqual(0);
|
||||
});
|
||||
|
||||
test('getSuggestionBoxAlgn returns pixels to move when text is medium size', () => {
|
||||
const mediumSizeText = 155;
|
||||
createRange(mediumSizeText);
|
||||
const suggestionBoxAlgn = Utils.getSuggestionBoxAlgn(textArea, Utils.getPxToSubstract());
|
||||
expect(suggestionBoxAlgn.pixelsToMoveX).toBeGreaterThan(0);
|
||||
expect(suggestionBoxAlgn.pixelsToMoveX).not.toBe(fixedToTheRight);
|
||||
});
|
||||
|
||||
test('getSuggestionBoxAlgn align box to the righ when text is large size', () => {
|
||||
const largeSizeText = 700;
|
||||
createRange(largeSizeText);
|
||||
const suggestionBoxAlgn = Utils.getSuggestionBoxAlgn(textArea, Utils.getPxToSubstract());
|
||||
expect(fixedToTheRight).toEqual(suggestionBoxAlgn.pixelsToMoveX);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Utils.numberToFixedDynamic', () => {
|
||||
const tests = [
|
||||
{
|
||||
label: 'Removes period when no decimals needed',
|
||||
num: 123.001,
|
||||
places: 2,
|
||||
expected: '123',
|
||||
},
|
||||
{
|
||||
label: 'Extra places are ignored',
|
||||
num: 123.45,
|
||||
places: 3,
|
||||
expected: '123.45',
|
||||
},
|
||||
{
|
||||
label: 'rounds positives',
|
||||
num: 123.45,
|
||||
places: 1,
|
||||
expected: '123.5',
|
||||
},
|
||||
{
|
||||
label: 'rounds negatives',
|
||||
num: -123.45,
|
||||
places: 1,
|
||||
expected: '-123.5',
|
||||
},
|
||||
{
|
||||
label: 'negative places interpreted as 0 places',
|
||||
num: 123,
|
||||
places: -1,
|
||||
expected: '123',
|
||||
},
|
||||
{
|
||||
label: 'handles integers',
|
||||
num: 123,
|
||||
places: 4,
|
||||
expected: '123',
|
||||
},
|
||||
{
|
||||
label: 'handles integers with 0 places',
|
||||
num: 123,
|
||||
places: 4,
|
||||
expected: '123',
|
||||
},
|
||||
{
|
||||
label: 'correctly excludes decimal when rounding exlcudes number',
|
||||
num: 0.004,
|
||||
places: 2,
|
||||
expected: '0',
|
||||
},
|
||||
];
|
||||
tests.forEach((testCase) => {
|
||||
test(testCase.label, () => {
|
||||
const actual = Utils.numberToFixedDynamic(testCase.num, testCase.places);
|
||||
expect(actual).toBe(testCase.expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('isTextSelectedInPostOrReply', () => {
|
||||
function createKeyboardEvent(target: Partial<HTMLTextAreaElement>) {
|
||||
return {
|
||||
target: {
|
||||
selectionStart: 0,
|
||||
selectionEnd: 0,
|
||||
id: AdvancedTextEditorTextboxIds.Default,
|
||||
...target,
|
||||
},
|
||||
} as unknown as KeyboardEvent;
|
||||
}
|
||||
|
||||
test('returns false when not typing in a textbox', () => {
|
||||
const event = createKeyboardEvent({
|
||||
id: 'not_a_textbox',
|
||||
});
|
||||
expect(Utils.isTextSelectedInPostOrReply(event)).toBe(false);
|
||||
});
|
||||
|
||||
test('returns false when no text is selected in center textbox', () => {
|
||||
const event = createKeyboardEvent({
|
||||
id: AdvancedTextEditorTextboxIds.InCenter,
|
||||
selectionStart: 5,
|
||||
selectionEnd: 5,
|
||||
});
|
||||
expect(Utils.isTextSelectedInPostOrReply(event)).toBe(false);
|
||||
});
|
||||
|
||||
test('returns true when text is selected in center textbox', () => {
|
||||
const event = createKeyboardEvent({
|
||||
id: AdvancedTextEditorTextboxIds.InCenter,
|
||||
selectionStart: 0,
|
||||
selectionEnd: 5,
|
||||
});
|
||||
expect(Utils.isTextSelectedInPostOrReply(event)).toBe(true);
|
||||
});
|
||||
|
||||
test('returns false when no text is selected in RHS comment textbox', () => {
|
||||
const event = createKeyboardEvent({
|
||||
id: AdvancedTextEditorTextboxIds.InRHSComment,
|
||||
selectionStart: 3,
|
||||
selectionEnd: 3,
|
||||
});
|
||||
expect(Utils.isTextSelectedInPostOrReply(event)).toBe(false);
|
||||
});
|
||||
|
||||
test('returns true when text is selected in RHS comment textbox', () => {
|
||||
const event = createKeyboardEvent({
|
||||
id: AdvancedTextEditorTextboxIds.InRHSComment,
|
||||
selectionStart: 0,
|
||||
selectionEnd: 3,
|
||||
});
|
||||
expect(Utils.isTextSelectedInPostOrReply(event)).toBe(true);
|
||||
});
|
||||
|
||||
test('returns false when no text is selected in edit mode textbox', () => {
|
||||
const event = createKeyboardEvent({
|
||||
id: AdvancedTextEditorTextboxIds.InEditMode,
|
||||
selectionStart: 7,
|
||||
selectionEnd: 7,
|
||||
});
|
||||
expect(Utils.isTextSelectedInPostOrReply(event)).toBe(false);
|
||||
});
|
||||
|
||||
test('returns true when text is selected in edit mode textbox', () => {
|
||||
const event = createKeyboardEvent({
|
||||
id: AdvancedTextEditorTextboxIds.InEditMode,
|
||||
selectionStart: 0,
|
||||
selectionEnd: 7,
|
||||
});
|
||||
expect(Utils.isTextSelectedInPostOrReply(event)).toBe(true);
|
||||
test('should handle invalid image URLs gracefully', () => {
|
||||
// These are not valid URLs but should still be processed correctly
|
||||
expect(getFileType('path/to/image.jpg')).toBe(FileTypes.IMAGE);
|
||||
expect(getFileType('image.png')).toBe(FileTypes.IMAGE);
|
||||
});
|
||||
|
||||
test('should identify other file types correctly', () => {
|
||||
expect(getFileType('doc')).toBe(FileTypes.WORD);
|
||||
expect(getFileType('pdf')).toBe(FileTypes.PDF);
|
||||
expect(getFileType('mp3')).toBe(FileTypes.AUDIO);
|
||||
expect(getFileType('mp4')).toBe(FileTypes.VIDEO);
|
||||
expect(getFileType('js')).toBe(FileTypes.CODE);
|
||||
expect(getFileType('txt')).toBe(FileTypes.TEXT);
|
||||
});
|
||||
|
||||
test('should handle null or undefined input', () => {
|
||||
expect(getFileType(null as any)).toBe(FileTypes.OTHER);
|
||||
expect(getFileType(undefined as any)).toBe(FileTypes.OTHER);
|
||||
expect(getFileType('')).toBe(FileTypes.OTHER);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -212,6 +212,43 @@ const removeQuerystringOrHash = (extin: string): string => {
|
||||
};
|
||||
|
||||
export const getFileType = (extin: string): typeof FileTypes[keyof typeof FileTypes] => {
|
||||
// Handle null or undefined input
|
||||
if (!extin) {
|
||||
return FileTypes.OTHER;
|
||||
}
|
||||
|
||||
// Special handling for image proxy URLs
|
||||
// Check for various forms of image proxy URLs
|
||||
if (extin.includes('/api/v4/image') &&
|
||||
(extin.includes('?url=') || extin.includes('&url='))) {
|
||||
return FileTypes.IMAGE;
|
||||
}
|
||||
|
||||
// Check for image file extensions in the URL path
|
||||
try {
|
||||
// Try to parse as a URL - this will validate if it's a proper URL
|
||||
const url = new URL(extin);
|
||||
const pathname = url.pathname;
|
||||
const pathParts = pathname.split('/');
|
||||
const lastPathPart = pathParts[pathParts.length - 1];
|
||||
|
||||
if (lastPathPart && lastPathPart.includes('.')) {
|
||||
const urlExtension = lastPathPart.split('.').pop()?.toLowerCase();
|
||||
if (urlExtension && Constants.IMAGE_TYPES.indexOf(urlExtension) > -1) {
|
||||
return FileTypes.IMAGE;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Not a valid URL, just check if the string itself has an extension
|
||||
if (extin.includes('.')) {
|
||||
const extension = extin.split('.').pop()?.toLowerCase();
|
||||
if (extension && Constants.IMAGE_TYPES.indexOf(extension) > -1) {
|
||||
return FileTypes.IMAGE;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Standard extension-based detection
|
||||
const ext = removeQuerystringOrHash(extin.toLowerCase());
|
||||
|
||||
if (Constants.TEXT_TYPES.indexOf(ext) > -1) {
|
||||
|
||||
Ссылка в новой задаче
Block a user