diff --git a/e2e-tests/cypress/tests/integration/channels/system_console/ui_and_api/customization_spec.js b/e2e-tests/cypress/tests/integration/channels/system_console/ui_and_api/customization_spec.js index d956613d9b8..8873927b318 100644 --- a/e2e-tests/cypress/tests/integration/channels/system_console/ui_and_api/customization_spec.js +++ b/e2e-tests/cypress/tests/integration/channels/system_console/ui_and_api/customization_spec.js @@ -49,10 +49,28 @@ describe('Customization', () => { // # Save setting saveSetting(); - // # Verify that after page reload image exist cy.reload(); cy.findByTestId('CustomBrandImage').should('be.visible').within(() => { + // * Verify that after page reload image exist cy.get('img').should('have.attr', 'src').and('include', '/api/v4/brand/image?t='); + + // * Verify that there's an option to delete the image. + cy.findByTestId('remove-image__btn').should('be.visible'); + + // # delete the image + cy.findByTestId('remove-image__btn').click(); + }); + + // # Save setting + saveSetting(); + + cy.reload(); + cy.findByTestId('CustomBrandImage').should('be.visible').within(() => { + // * Verify that after page reload, the image doesn't exist. + cy.findByAltText('brand image').should('not.exist'); + + // * Verify there's no option to delete the image. + cy.findByTestId('remove-image__btn').should('not.exist'); }); }); diff --git a/webapp/channels/src/components/admin_console/brand_image_setting/brand_image_setting.test.tsx b/webapp/channels/src/components/admin_console/brand_image_setting/brand_image_setting.test.tsx index 6f65ef09781..93ea64e1841 100644 --- a/webapp/channels/src/components/admin_console/brand_image_setting/brand_image_setting.test.tsx +++ b/webapp/channels/src/components/admin_console/brand_image_setting/brand_image_setting.test.tsx @@ -1,30 +1,18 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import nock from 'nock'; import React from 'react'; -import {uploadBrandImage, deleteBrandImage} from 'actions/admin_actions.jsx'; +import {Client4} from 'mattermost-redux/client'; import {renderWithContext, screen, userEvent, waitFor} from 'tests/react_testing_utils'; import BrandImageSetting from './brand_image_setting'; -// Real implementations are async (await dispatch(...)); mocks must return Promises so handleSave can await them. -jest.mock('actions/admin_actions.jsx', () => ({ - ...jest.requireActual('actions/admin_actions.jsx'), - uploadBrandImage: jest.fn(async () => {}), - deleteBrandImage: jest.fn(async () => {}), -})); +Client4.setUrl('http://localhost:8065'); describe('components/admin_console/brand_image_setting', () => { - beforeEach(() => { - jest.spyOn(global, 'fetch').mockResolvedValue({status: 404} as Response); - }); - - afterEach(() => { - jest.restoreAllMocks(); - }); - const baseProps = { disabled: false, setSaveNeeded: jest.fn(), @@ -32,71 +20,59 @@ describe('components/admin_console/brand_image_setting', () => { unRegisterSaveAction: jest.fn(), }; - test('should have called deleteBrandImage or uploadBrandImage on save depending on component state', async () => { - let saveAction: (() => Promise) | undefined; - const registerSaveAction = jest.fn((fn: () => Promise) => { - saveAction = fn; - }); + const deleteButtonTestId = 'remove-image__btn'; - const {container, unmount} = renderWithContext( - , - ); + let scope: nock.Scope; - // Wait for componentDidMount fetch to resolve - await waitFor(() => { - expect(registerSaveAction).toHaveBeenCalled(); - }); - expect(saveAction).toBeDefined(); + beforeAll(() => { + scope = nock(Client4.getBaseRoute()).persist().get('/brand/image').query(true).reply(200); + }); - // Simulate selecting a file via the file input to set brandImage - const file = new File(['brand_image_file'], 'brand.png', {type: 'image/png'}); - const fileInput = container.querySelector('input[type="file"]'); - expect(fileInput).toBeInTheDocument(); - await userEvent.upload(fileInput as HTMLInputElement, file); + afterAll(() => { + nock.cleanAll(); + }); - // Now call save - should call uploadBrandImage - await saveAction!(); - expect(deleteBrandImage).toHaveBeenCalledTimes(0); - expect(uploadBrandImage).toHaveBeenCalledTimes(1); + test('should register and unregister save handler when mounted and unmounted respectively', () => { + const {unmount} = renderWithContext(); + + expect(baseProps.registerSaveAction).toHaveBeenCalledTimes(1); - // To test deleteBrandImage path, unmount then re-mount with fetch returning 200 unmount(); - jest.clearAllMocks(); - (global.fetch as jest.Mock).mockResolvedValueOnce({status: 200} as Response); - let saveAction2: (() => Promise) | undefined; - const registerSaveAction2 = jest.fn((fn: () => Promise) => { - saveAction2 = fn; - }); + expect(baseProps.unRegisterSaveAction).toHaveBeenCalledTimes(1); + }); - renderWithContext( - , - ); + test('should show delete button if brand image exists', async () => { + renderWithContext(); - await waitFor(() => { - expect(registerSaveAction2).toHaveBeenCalled(); - }); - expect(saveAction2).toBeDefined(); + await waitFor(() => expect(scope.isDone()).toBe(true)); + + expect(screen.getByTestId(deleteButtonTestId)).toBeVisible(); + }); + + test('should hide delete button if the setting is disabled', async () => { + const props = {...baseProps, disabled: true}; + + renderWithContext(); + + await waitFor(() => expect(screen.queryByTestId(deleteButtonTestId)).toBe(null)); + }); + + test('should call setSaveNeeded when a brand image is uploaded', async () => { + renderWithContext(); + + await userEvent.upload(screen.getByTestId('file__upload-input'), new File(['brand_image_file'], 'brand_image_file.png', {type: 'image/png'})); + + expect(baseProps.setSaveNeeded).toHaveBeenCalledTimes(1); + }); + + test('should call setSaveNeeded when the delete button is pressed', async () => { + renderWithContext(); + + const deleteButton = await screen.findByTestId(deleteButtonTestId); - // Wait for the brand image to be detected and delete button to appear - await waitFor(() => { - expect(screen.getByText('×')).toBeInTheDocument(); - }); - const deleteButton = screen.getByText('×').closest('button')!; await userEvent.click(deleteButton); - await waitFor(() => { - expect(screen.getByText('No brand image uploaded')).toBeInTheDocument(); - }); - - await saveAction2!(); - expect(deleteBrandImage).toHaveBeenCalledTimes(1); - expect(uploadBrandImage).toHaveBeenCalledTimes(0); + expect(baseProps.setSaveNeeded).toHaveBeenCalledTimes(1); }); }); diff --git a/webapp/channels/src/components/admin_console/brand_image_setting/brand_image_setting.tsx b/webapp/channels/src/components/admin_console/brand_image_setting/brand_image_setting.tsx index f058e9bbc0c..7a155f07304 100644 --- a/webapp/channels/src/components/admin_console/brand_image_setting/brand_image_setting.tsx +++ b/webapp/channels/src/components/admin_console/brand_image_setting/brand_image_setting.tsx @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React from 'react'; +import React, {memo, useCallback, useEffect, useRef, useState} from 'react'; import {FormattedMessage} from 'react-intl'; import {Client4} from 'mattermost-redux/client'; @@ -9,6 +9,7 @@ import {Client4} from 'mattermost-redux/client'; import {uploadBrandImage, deleteBrandImage} from 'actions/admin_actions.jsx'; import SettingSet from 'components/admin_console/setting_set'; +import useDidUpdate from 'components/common/hooks/useDidUpdate'; import FormError from 'components/form_error'; import WithTooltip from 'components/with_tooltip'; @@ -44,242 +45,224 @@ type Props = { unRegisterSaveAction: (saveAction: () => Promise) => void; }; -type State = { - deleteBrandImage: boolean; - brandImage?: Blob; - brandImageExists: boolean; - brandImageTimestamp: number; - error: string; -}; +const BrandImageSetting = ({ + id, + disabled, + setSaveNeeded, + registerSaveAction, + unRegisterSaveAction, +}: Props) => { + const imageRef = useRef(null); + const fileInputRef = useRef(null); -export default class BrandImageSetting extends React.PureComponent { - private imageRef: React.RefObject; - private fileInputRef: React.RefObject; + const [brandImage, setBrandImage] = useState(); + const [shouldDeleteBrandImage, setShouldDeleteBrandImage] = useState(false); + const [brandImageExists, setBrandImageExists] = useState(false); + const [brandImageTimestamp, setBrandImageTimestamp] = useState(Date.now()); - constructor(props: Props) { - super(props); + const [errorFromState, setErrorFromState] = useState(''); - this.state = { - deleteBrandImage: false, - brandImageExists: false, - brandImageTimestamp: Date.now(), - error: '', - }; + const handleSave = useCallback(async () => { + setErrorFromState(''); - this.imageRef = React.createRef(); - this.fileInputRef = React.createRef(); - } + let error; + if (shouldDeleteBrandImage) { + await deleteBrandImage( + () => { + setShouldDeleteBrandImage(false); + setBrandImageExists(false); + setBrandImage(undefined); + }, + (err: Error) => { + error = err; + setErrorFromState(err.message); + }, + ); + } else if (brandImage) { + await uploadBrandImage( + brandImage, + () => { + setBrandImageExists(true); + setBrandImage(undefined); + setBrandImageTimestamp(Date.now()); + }, + (err: Error) => { + error = err; + setErrorFromState(err.message); + }, + ); + } + return {error}; + }, [brandImage, shouldDeleteBrandImage]); - componentDidMount() { + useEffect(() => { fetch( - Client4.getBrandImageUrl(String(this.state.brandImageTimestamp)), + Client4.getBrandImageUrl(String(Date.now())), ).then((resp) => { if (resp.status === HTTP_STATUS_OK) { - this.setState({brandImageExists: true}); + setBrandImageExists(true); } else { - this.setState({brandImageExists: false}); + setBrandImageExists(false); } }).catch((err) => { console.error(`unable to retrieve brand image: ${err}`); //eslint-disable-line no-console - this.setState({brandImageExists: false}); + setBrandImageExists(false); }); + }, []); - this.props.registerSaveAction(this.handleSave); - } + useEffect(() => { + registerSaveAction(handleSave); - componentWillUnmount() { - this.props.unRegisterSaveAction(this.handleSave); - } + return () => { + unRegisterSaveAction(handleSave); + }; + }, [handleSave, registerSaveAction, unRegisterSaveAction]); - componentDidUpdate() { - if (this.imageRef.current) { + useDidUpdate(() => { + if (imageRef.current) { const reader = new FileReader(); - const img = this.imageRef.current; + const img = imageRef.current; reader.onload = (e) => { const src = - e.target?.result instanceof ArrayBuffer ? e.target?.result.toString() : e.target?.result; + e.target?.result instanceof ArrayBuffer ? e.target?.result.toString() : e.target?.result; if (src) { img.setAttribute('src', src); } }; - if (this.state.brandImage) { - reader.readAsDataURL(this.state.brandImage); + if (brandImage) { + reader.readAsDataURL(brandImage); } } - } + }, [brandImage]); - handleSelectClick = () => { - this.fileInputRef.current?.click(); - }; + const handleSelectClick = useCallback(() => { + fileInputRef.current?.click(); + }, []); - handleImageChange = () => { - if (!this.fileInputRef.current) { + const handleImageChange = useCallback(() => { + if (!fileInputRef.current) { return; } - const element = this.fileInputRef.current; + const element = fileInputRef.current; if (element.files && element.files.length > 0) { - this.props.setSaveNeeded(); - this.setState({ - brandImage: element.files[0], - deleteBrandImage: false, - }); + setSaveNeeded(); + setBrandImage(element.files[0]); + setShouldDeleteBrandImage(false); } - }; + }, [setSaveNeeded]); - handleDeleteButtonPressed = () => { - this.setState({ - deleteBrandImage: true, - brandImage: undefined, - brandImageExists: false, - }); - this.props.setSaveNeeded(); - }; + const handleDeleteButtonPressed = useCallback(() => { + setShouldDeleteBrandImage(true); + setBrandImage(undefined); + setBrandImageExists(false); - handleSave = async () => { - this.setState({ - error: '', - }); + setSaveNeeded(); + }, [setSaveNeeded]); - let error; - if (this.state.deleteBrandImage) { - await deleteBrandImage( - () => { - this.setState({ - deleteBrandImage: false, - brandImageExists: false, - brandImage: undefined, - }); - }, - (err: Error) => { - error = err; - this.setState({ - error: err.message, - }); - }, - ); - } else if (this.state.brandImage) { - await uploadBrandImage( - this.state.brandImage, - () => { - this.setState({ - brandImageExists: true, - brandImage: undefined, - brandImageTimestamp: Date.now(), - }); - }, - (err: Error) => { - error = err; - this.setState({ - error: err.message, - }); - }, - ); - } - return {error}; - }; - - render() { - let img = null; - if (this.state.brandImage) { - img = ( -
- brand image -
- ); - } else if (this.state.brandImageExists) { - let overlay; - if (!this.props.disabled) { - overlay = ( - - )} - isVertical={false} - > - - - ); - } - img = ( -
- brand image - {overlay} -
- ); - } else { - img = ( -

- -

- ); - } - - return ( - - } - label={ - - } - setByEnv={false} - > -
-
{img}
-
-
+ let img = null; + if (brandImage) { + img = ( +
+ brand image +
+ ); + } else if (brandImageExists) { + let overlay; + if (!disabled) { + overlay = ( + + )} + isVertical={false} + > - -
- -
+ + ); + } + img = ( +
+ brand image + {overlay} +
+ ); + } else { + img = ( +

+ +

); } -} + + return ( + + } + label={ + + } + setByEnv={false} + > +
+
{img}
+
+
+ + +
+ +
+ ); +}; + +export default memo(BrandImageSetting);