diff --git a/client/src/components/profile/components/portfolio.tsx b/client/src/components/profile/components/portfolio.tsx index 987277134c2..7d45bae306b 100644 --- a/client/src/components/profile/components/portfolio.tsx +++ b/client/src/components/profile/components/portfolio.tsx @@ -33,6 +33,7 @@ type PortfolioProps = { type PortfolioState = { portfolio: PortfolioProjectData[]; unsavedItemId: string | null; + isImageValid: ProfileValidation; }; interface ProfileValidation { @@ -55,15 +56,20 @@ function createFindById(id: string) { } class PortfolioSettings extends Component { + validationImage: HTMLImageElement; static displayName: string; constructor(props: PortfolioProps) { super(props); - + this.validationImage = new Image(); const { portfolio = [] } = props; this.state = { portfolio: [...portfolio], - unsavedItemId: null + unsavedItemId: null, + isImageValid: { + state: 'success', + message: '' + } }; } @@ -76,7 +82,7 @@ class PortfolioSettings extends Component { (e: React.ChangeEvent) => { e.preventDefault(); const userInput = e.target.value.slice(); - return this.setState(state => { + this.setState(state => { const { portfolio: currentPortfolio } = state; const mutablePortfolio = currentPortfolio.slice(0); const index = findIndex(currentPortfolio, p => p.id === id); @@ -86,6 +92,14 @@ class PortfolioSettings extends Component { [key]: userInput }; + if (key === 'image' && userInput) { + void this.validateImageLoad(userInput).then(imageValidation => { + this.setState({ isImageValid: imageValidation }); + }); + } else if (key === 'image' && !userInput) { + this.setState({ isImageValid: { state: 'success', message: '' } }); + } + return { portfolio: mutablePortfolio }; }); }; @@ -168,29 +182,45 @@ class PortfolioSettings extends Component { return { state: 'success', message: '' }; } - getUrlValidation(maybeUrl: string, isImage?: boolean) { + async validateImageLoad(image: string): Promise { + return new Promise(resolve => { + this.validationImage.src = encodeURI(image); + + this.validationImage.onload = () => { + resolve({ + state: 'success', + message: '' + }); + }; + + this.validationImage.onerror = () => { + resolve({ + state: 'error', + message: this.props.t('validation.url-not-image') + }); + }; + }); + } + + getUrlValidation(url: string) { const { t } = this.props; - const len = maybeUrl.length; - if (len >= 4 && !hasProtocolRE.test(maybeUrl)) { + const len = url.length; + + if (!url) { + return { state: 'success', message: '' }; + } + + if (len >= 4 && !hasProtocolRE.test(url)) { return { state: 'error', message: t('validation.invalid-protocol') }; } - if (isImage && !maybeUrl) { - return { state: null, message: '' }; - } - if (isImage && !/\.(png|jpg|jpeg|gif)$/.test(maybeUrl)) { - return { - state: 'error', - message: t('validation.url-not-image') - }; - } - return isURL(maybeUrl) + + return isURL(url) ? { state: 'success', message: '' } : { state: 'warning', message: t('validation.use-valid-url') }; } - formCorrect(portfolio: PortfolioProjectData) { const { id, title, description, url, image } = portfolio; @@ -199,10 +229,9 @@ class PortfolioSettings extends Component { const { state: urlState, message: urlMessage } = this.getUrlValidation(url); const { state: descriptionState, message: descriptionMessage } = this.getDescriptionValidation(description); - const { state: imageState, message: imageMessage } = this.getUrlValidation( - image, - true - ); + const { state: imageState, message: imageMessage } = + this.getUrlValidation(image); + const pristine = this.isFormPristine(id); const urlIsValid = !isURL(url, { @@ -263,6 +292,13 @@ class PortfolioSettings extends Component { this.toggleEditing(); return this.updateItem(id); }; + + const combineImageStatus = + imageState === 'success' && this.state.isImageValid.state === 'success' + ? null + : 'error'; + const combineImageMessage = imageMessage || this.state.isImageValid.message; + return (
{ {t('settings.labels.image')} @@ -328,9 +364,9 @@ class PortfolioSettings extends Component { name='portfolio-image' id={`${id}-image-input`} /> - {imageMessage ? ( + {combineImageMessage ? ( - {imageMessage} + {combineImageMessage} ) : null} @@ -387,6 +423,7 @@ class PortfolioSettings extends Component { render() { const { t } = this.props; const { portfolio = [], unsavedItemId } = this.state; + return (
{t('settings.headings.portfolio')} diff --git a/e2e/portfolio.spec.ts b/e2e/portfolio.spec.ts index 5bd3fb0ebfd..631daba3f77 100644 --- a/e2e/portfolio.spec.ts +++ b/e2e/portfolio.spec.ts @@ -63,12 +63,21 @@ test.describe('Add Portfolio Item', () => { test('The image has validation', async ({ page }) => { await page.getByLabel(translations.settings.labels.image).fill('T'); await expect(page.getByTestId('image-validation')).toContainText( - 'URL must link directly to an image file' + 'Please use a valid URL' ); await page .getByLabel(translations.settings.labels.image) - .fill('http://helloworld.com/image.png'); + .fill( + 'https://cdn.freecodecamp.org/universal/favicons/favicon-32x32.png' + ); await expect(page.getByTestId('image-validation')).toBeHidden(); + + await page + .getByLabel(translations.settings.labels.image) + .fill('https://cdn.freecodecamp.org/universal/favicons/favicon-32x32.pn'); + await expect(page.getByTestId('image-validation')).toContainText( + 'URL must link directly to an image file' + ); }); test('The description has validation', async ({ page }) => {