Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

UIU-3080 - Handle invalid image URLs when uploading profile photo #2670

Merged
merged 12 commits into from
Apr 29, 2024
Merged
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
* Trim input values and delete properties with empty string when user record save. Refs UIU-2049.
* Update username field validation to trim leading and trailing spaces. Refs UIU-3099.
* Fix "Total paid amount" value that set as "$NaN" on "Refund fee/fine" modal. Refs UIU-3094.
* Handle invalid image URLs when uploading profile photo via External URL. Refs UIU-3080.
manvendra-s-rathore marked this conversation as resolved.
Show resolved Hide resolved

## [10.1.0](https://github.com/folio-org/ui-users/tree/v10.1.0) (2024-03-20)
[Full Changelog](https://github.com/folio-org/ui-users/compare/v10.0.4...v10.1.0)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@ jest.mock('react-image', () => ({
Img: jest.fn(() => null),
}));

jest.mock('../../../../util', () => ({
isAValidImageUrl: jest.fn(() => true),
isAValidURL: jest.fn(() => true),
}));

const defaultProps = {
profilePictureMaxFileSize: 3,
profilePictureId: 'https://folio.org/wp-content/uploads/2023/08/folio-site-general-Illustration-social-image-1200.jpg',
Expand Down Expand Up @@ -157,7 +162,7 @@ describe('Edit User Profile Picture', () => {
await userEvent.click(saveButton);
expect(Img).toHaveBeenCalled();
const renderedProfileImg = Img.mock.lastCall[0];
expect(expect(renderedProfileImg.src).toContain('https://upload.wikimedia.org/wikipedia/commons/e/e2/FOLIO_400x400.jpg'));
expect(renderedProfileImg.src).toContain('https://upload.wikimedia.org/wikipedia/commons/e/e2/FOLIO_400x400.jpg');
});

it('should invoke local file upload handlers with compression', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
TextField,
} from '@folio/stripes/components';
import { FormattedMessage } from 'react-intl';
import { isAValidURL } from '../../../../util';
import { isAValidURL, isAValidImageUrl } from '../../../../util';

const ExternalLinkModal = ({
open,
Expand All @@ -23,37 +23,41 @@ const ExternalLinkModal = ({
const [inputValue, setInputValue] = useState('');
const previousInputValue = useRef(profilePictureLink);
const [disabled, setDisabled] = useState(false);
const [error, setError] = useState(false);
const externalURLValidityError = error ?
<FormattedMessage id="ui-users.information.profilePicture.externalLink.modal.externalURL.errorMessage" />
: null;
const [externalURLValidityError, setExternalURLValidityError] = useState(null);

useEffect(() => {
setInputValue(profilePictureLink);
setError(false);
}, [profilePictureLink]);

useEffect(() => {
if (inputValue) {
setDisabled(previousInputValue.current === inputValue);
setError(false);
} else {
setDisabled(true);
setExternalURLValidityError(null);
}
}, [inputValue]);

const handleSave = () => {
onSave(inputValue);
const handleSave = async () => {
const isValidImgURL = await isAValidImageUrl(inputValue);
if (isValidImgURL) {
onSave(inputValue);
}
manvendra-s-rathore marked this conversation as resolved.
Show resolved Hide resolved
};

const handleInputChange = (e) => {
previousInputValue.current = inputValue;
setInputValue(e.target.value);
};

const handleBlur = () => {
if (inputValue && !isAValidURL(inputValue)) {
setError(true);
const handleBlur = async () => {
if (!inputValue || !isAValidURL(inputValue)) {
manvendra-s-rathore marked this conversation as resolved.
Show resolved Hide resolved
setExternalURLValidityError(<FormattedMessage id="ui-users.information.profilePicture.externalLink.modal.externalURL.invalidURLErrorMessage" />);
setDisabled(true);
return;
}

const isValidImgURL = await isAValidImageUrl(inputValue);
if (!isValidImgURL) {
setExternalURLValidityError(<FormattedMessage id="ui-users.information.profilePicture.externalLink.modal.externalURL.invalidImageURLErrorMessage" />);
setDisabled(true);
}
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
} from '@folio/jest-config-stripes/testing-library/react';
import userEvent from '@folio/jest-config-stripes/testing-library/user-event';

import { isAValidURL } from '../../../../util';
import { isAValidURL, isAValidImageUrl } from '../../../../util';

import '../../../../../../test/jest/__mock__';

Expand Down Expand Up @@ -43,6 +43,7 @@ describe('ExternalLinkModal', () => {

beforeEach(() => {
isAValidURL.mockReset();
isAValidImageUrl.mockReset();
renderExternalLinkModal(props);
});

Expand All @@ -53,6 +54,7 @@ describe('ExternalLinkModal', () => {
expect(screen.getByText('ui-users.information.profilePicture.externalLink.modal.externalURL')).toBeInTheDocument();
});
it('should call onSave', async () => {
isAValidImageUrl.mockImplementationOnce(() => true);
const saveButton = screen.getByRole('button', { name: 'ui-users.save' });
const inputElement = screen.getByLabelText('ui-users.information.profilePicture.externalLink.modal.externalURL');

Expand All @@ -61,14 +63,24 @@ describe('ExternalLinkModal', () => {

expect(props.onSave).toHaveBeenCalled();
});
it('should show error text when url is invalid', async () => {
it('should show error text when url is invalid url', async () => {
isAValidURL.mockImplementationOnce(() => false);
const inputElement = screen.getByLabelText('ui-users.information.profilePicture.externalLink.modal.externalURL');

fireEvent.change(inputElement, { target: { value: 'profile picture' } });
fireEvent.blur(inputElement);

await waitFor(() => expect(screen.getByText('ui-users.information.profilePicture.externalLink.modal.externalURL.errorMessage')).toBeInTheDocument());
await waitFor(() => expect(screen.getByText('ui-users.information.profilePicture.externalLink.modal.externalURL.invalidURLErrorMessage')).toBeInTheDocument());
});
it('should show error text when url is invalid image url', async () => {
isAValidURL.mockImplementationOnce(() => true);
isAValidImageUrl.mockImplementationOnce(() => false);
const inputElement = screen.getByLabelText('ui-users.information.profilePicture.externalLink.modal.externalURL');

fireEvent.change(inputElement, { target: { value: 'https://folio-org.atlassian.net/browse/UIU-3080' } });
await fireEvent.blur(inputElement);

await waitFor(() => expect(screen.getByText('ui-users.information.profilePicture.externalLink.modal.externalURL.invalidImageURLErrorMessage')).toBeInTheDocument());
});
it('should call onClose', async () => {
const cancelButton = screen.getByRole('button', { name: 'stripes-core.button.cancel' });
Expand Down
11 changes: 11 additions & 0 deletions src/components/util/util.js
Original file line number Diff line number Diff line change
Expand Up @@ -222,3 +222,14 @@ export const isAValidURL = (str) => {
return URL.canParse(str);
};

export const isAValidImageUrl = async (url) => {
try {
const response = await fetch(url);
manvendra-s-rathore marked this conversation as resolved.
Show resolved Hide resolved
if (!response.ok) return false;

const contentType = response.headers.get('content-type');
return contentType?.startsWith('image/') ?? false;
} catch (e) {
return false;
}
};
40 changes: 40 additions & 0 deletions src/components/util/util.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
getRequestUrl,
isAffiliationsEnabled,
isDCBItem,
isAValidImageUrl,
} from './util';

const STRIPES = {
Expand Down Expand Up @@ -474,3 +475,42 @@ describe('isDCBItem ', () => {
expect(isDCBItem(item)).toBeFalsy();
});
});

describe('isAValidImageUrl', () => {
it('should return true for a valid image URL with correct content-type', async () => {
global.fetch = jest.fn().mockResolvedValue({
ok: true,
headers: {
get: jest.fn().mockReturnValue('image/jpeg'),
},
});

const url = 'https://folio.org/wp-content/folio-site-general-Illustration-social-image-1200.jpg';
const result = await isAValidImageUrl(url);

expect(result).toBe(true);
});

it('should return false for an invalid image URL with incorrect content-type', async () => {
global.fetch = jest.fn().mockResolvedValue({
ok: true,
headers: {
get: jest.fn().mockReturnValue('text/plain'),
},
});

const url = 'https://folio.org/wp-content/folio-site-general-Illustration-social-image-1200.txt';
const result = await isAValidImageUrl(url);

expect(result).toBe(false);
});

it('should return false for network errors', async () => {
global.fetch = jest.fn().mockRejectedValue(new Error('Network Error'));

const url = 'https://folio.org/wp-content/folio-site-general-Illustration-social-image-1200.jpg';
const result = await isAValidImageUrl(url);
expect(result).toBe(false);
});
});

3 changes: 2 additions & 1 deletion translations/ui-users/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -316,7 +316,8 @@
"information.profilePicture.delete": "Delete",
"information.profilePicture.externalLink.modal.updateProfilePicture": "Update profile picture",
"information.profilePicture.externalLink.modal.externalURL": "External URL",
"information.profilePicture.externalLink.modal.externalURL.errorMessage": "Invalid image URL",
"information.profilePicture.externalLink.modal.externalURL.invalidURLErrorMessage": "Invalid image URL",
"information.profilePicture.externalLink.modal.externalURL.invalidImageURLErrorMessage": "The provided URL is valid but does not point to an image file",
"information.profilePicture.delete.modal.message": "You are deleting the profile picture for {name}",
"information.profilePicture.delete.modal.heading": "Delete profile picture",
"information.profilePicture.localFile.modal.previewAndEdit": "Preview and edit",
Expand Down
Loading