diff --git a/components/admin_console/admin_definition.jsx b/components/admin_console/admin_definition.jsx index 1e6b6cf3947d..4ab9b9b50bec 100644 --- a/components/admin_console/admin_definition.jsx +++ b/components/admin_console/admin_definition.jsx @@ -2383,6 +2383,36 @@ const AdminDefinition = { ], }, }, + notices: { + url: 'site_config/notices', + title: t('admin.sidebar.notices'), + title_default: 'Notices', + schema: { + id: 'NoticesSettings', + name: t('admin.site.notices'), + name_default: 'Notices', + settings: [ + { + type: Constants.SettingsTypes.TYPE_BOOL, + key: 'AnnouncementSettings.AdminNoticesEnabled', + label: t('admin.notices.enableAdminNoticesTitle'), + label_default: 'Enable Admin Notices: ', + help_text: t('admin.notices.enableAdminNoticesDescription'), + help_text_default: 'When enabled, System Admins will receive notices about available server upgrades and relevant system administration features. [Learn more about notices](!https://about.mattermost.com/default-notices) in our documentation.', + help_text_markdown: true, + }, + { + type: Constants.SettingsTypes.TYPE_BOOL, + key: 'AnnouncementSettings.UserNoticesEnabled', + label: t('admin.notices.enableEndUserNoticesTitle'), + label_default: 'Enable End User Notices: ', + help_text: t('admin.notices.enableEndUserNoticesDescription'), + help_text_default: 'When enabled, all users will receive notices about available client upgrades and relevant end user features to improve user experience. [Learn more about notices](!https://about.mattermost.com/default-notices) in our documentation.', + help_text_markdown: true, + }, + ], + }, + }, }, authentication: { icon: 'fa-shield', diff --git a/components/channel_layout/channel_controller.jsx b/components/channel_layout/channel_controller.jsx index 4841123aa867..238ba061aa78 100644 --- a/components/channel_layout/channel_controller.jsx +++ b/components/channel_layout/channel_controller.jsx @@ -27,6 +27,7 @@ import * as UserAgent from 'utils/user_agent'; import CenterChannel from 'components/channel_layout/center_channel'; import LoadingScreen from 'components/loading_screen'; import FaviconTitleHandler from 'components/favicon_title_handler'; +import ProductNoticesModal from 'components/product_notices_modal'; export default class ChannelController extends React.Component { static propTypes = { @@ -74,7 +75,7 @@ export default class ChannelController extends React.Component { - +
diff --git a/components/generic_modal.tsx b/components/generic_modal.tsx index 4748dc5c1846..1291a6e23b59 100644 --- a/components/generic_modal.tsx +++ b/components/generic_modal.tsx @@ -19,6 +19,8 @@ type Props = { confirmButtonClassName?: string; cancelButtonText?: React.ReactNode; isConfirmDisabled?: boolean; + autoCloseOnCancelButton?: boolean; + autoCloseOnConfirmButton?: boolean; }; type State = { @@ -28,6 +30,8 @@ type State = { export default class GenericModal extends React.PureComponent { static defaultProps: Partial = { show: true, + autoCloseOnCancelButton: true, + autoCloseOnConfirmButton: true, }; constructor(props: Props) { @@ -44,7 +48,9 @@ export default class GenericModal extends React.PureComponent { handleCancel = (event: React.MouseEvent) => { event.preventDefault(); - this.onHide(); + if (this.props.autoCloseOnCancelButton) { + this.onHide(); + } if (this.props.handleCancel) { this.props.handleCancel(); } @@ -52,7 +58,9 @@ export default class GenericModal extends React.PureComponent { handleConfirm = (event: React.MouseEvent) => { event.preventDefault(); - this.onHide(); + if (this.props.autoCloseOnConfirmButton) { + this.onHide(); + } if (this.props.handleConfirm) { this.props.handleConfirm(); } diff --git a/components/product_notices_modal/__snapshots__/product_notices.test.tsx.snap b/components/product_notices_modal/__snapshots__/product_notices.test.tsx.snap new file mode 100644 index 000000000000..3a04e94fcd4c --- /dev/null +++ b/components/product_notices_modal/__snapshots__/product_notices.test.tsx.snap @@ -0,0 +1,195 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ProductNoticesModal Match snapshot for single notice 1`] = ` + + Download + + } + handleConfirm={[Function]} + modalHeaderText={ + + title + + } + onHide={[Function]} + show={true} +> + + + +
+
+ +`; + +exports[`ProductNoticesModal Match snapshot for user notice 1`] = ` + + + + + } + className="productNotices" + confirmButtonText={ + + } + handleCancel={[Function]} + handleConfirm={[Function]} + modalHeaderText={ + + title + + } + onHide={[Function]} + show={true} +> + + + + + Download + +
+ + +`; + +exports[`ProductNoticesModal Should match snapshot for system admin notice 1`] = ` + + + + + } + handleConfirm={[Function]} + modalHeaderText={ + + for sysadmin + + } + onHide={[Function]} + show={true} +> + + + + + Download + +
+ +
+ + +`; + +exports[`ProductNoticesModal Should match snapshot when there are no notices 1`] = `""`; diff --git a/components/product_notices_modal/index.tsx b/components/product_notices_modal/index.tsx new file mode 100644 index 000000000000..6027167ddcc5 --- /dev/null +++ b/components/product_notices_modal/index.tsx @@ -0,0 +1,47 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {connect} from 'react-redux'; +import {bindActionCreators, Dispatch, ActionCreatorsMapObject} from 'redux'; +import {ActionFunc} from 'mattermost-redux/types/actions'; +import {ProductNotices} from 'mattermost-redux/types/product_notices'; +import {WebsocketStatus} from 'mattermost-redux/types/websocket'; +import {getInProductNotices, updateNoticesAsViewed} from 'mattermost-redux/actions/teams'; +import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams'; +import {getConfig} from 'mattermost-redux/selectors/entities/general'; +import {ClientConfig} from 'mattermost-redux/types/config'; + +import {getSocketStatus} from 'selectors/views/websocket'; +import {GlobalState} from 'types/store'; + +import ProductNoticesModal from './product_notices_modal'; + +type Actions = { + getInProductNotices: (teamId: string, client: string, clientVersion: string) => Promise<{ + data: ProductNotices; + }>; + updateNoticesAsViewed: (noticeIds: string[]) => Promise>; +} + +function mapStateToProps(state: GlobalState) { + const config: Partial = getConfig(state); + const version: string = config.Version || ''; //this should always exist but TS throws error + const socketStatus: WebsocketStatus = getSocketStatus(state); + + return { + currentTeamId: getCurrentTeamId(state), + version, + socketStatus, + }; +} + +function mapDispatchToProps(dispatch: Dispatch) { + return { + actions: bindActionCreators, Actions>({ + getInProductNotices, + updateNoticesAsViewed, + }, dispatch), + }; +} + +export default connect(mapStateToProps, mapDispatchToProps)(ProductNoticesModal); diff --git a/components/product_notices_modal/product_notices.test.tsx b/components/product_notices_modal/product_notices.test.tsx new file mode 100644 index 000000000000..34a8afff15f4 --- /dev/null +++ b/components/product_notices_modal/product_notices.test.tsx @@ -0,0 +1,176 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {shallow} from 'enzyme'; + +import GenericModal from 'components/generic_modal'; +import {isDesktopApp, getDesktopVersion} from 'utils/user_agent'; + +import ProductNoticesModal from './product_notices_modal'; + +jest.mock('utils/user_agent'); + +describe('ProductNoticesModal', () => { + const noticesData = [{ + id: '124', + title: 'for sysadmin', + description: 'your eyes only! [test](https://test.com)', + image: 'https://raw.githubusercontent.com/reflog/notices-experiment/master/images/2020-08-11_11-42.png', + actionText: 'Download', + actionParam: 'http://download.com/path', + sysAdminOnly: true, + teamAdminOnly: false, + }, + { + id: '123', + title: 'title', + description: 'descr', + actionText: 'Download', + actionParam: 'http://download.com/path', + sysAdminOnly: false, + teamAdminOnly: false, + }]; + + const baseProps = { + version: '5.28.0', + currentTeamId: 'currentTeamId', + socketStatus: { + connected: true, + lastConnectAt: 1599760193593, + lastDisconnectAt: 0, + }, + actions: { + getInProductNotices: jest.fn().mockResolvedValue({data: noticesData}), + updateNoticesAsViewed: jest.fn().mockResolvedValue({}), + }, + }; + + test('Should match snapshot when there are no notices', async () => { + const wrapper = shallow(); + expect(wrapper).toMatchSnapshot(); + }); + + test('Should match snapshot for system admin notice', async () => { + const wrapper = shallow(); + await baseProps.actions.getInProductNotices(); + expect(wrapper).toMatchSnapshot(); + }); + + test('Match snapshot for user notice', async () => { + const wrapper = shallow(); + await baseProps.actions.getInProductNotices(); + wrapper.setState({presentNoticeIndex: 1}); + expect(wrapper).toMatchSnapshot(); + }); + + test('Match snapshot for single notice', async () => { + const wrapper = shallow(); + await baseProps.actions.getInProductNotices(); + wrapper.setState({noticesData: [noticesData[1]]}); + expect(wrapper).toMatchSnapshot(); + }); + + test('Should change the state of presentNoticeIndex on click of next, previous button', async () => { + const wrapper = shallow(); + await baseProps.actions.getInProductNotices(); + expect(wrapper.state('presentNoticeIndex')).toBe(0); + wrapper.find(GenericModal).prop('handleConfirm')?.(); + expect(wrapper.state('presentNoticeIndex')).toBe(1); + wrapper.find(GenericModal).prop('handleCancel')?.(); + expect(wrapper.state('presentNoticeIndex')).toBe(0); + }); + + test('Should not have previous button if there is only one notice', async () => { + const wrapper = shallow(); + await baseProps.actions.getInProductNotices(); + expect(wrapper.find(GenericModal).props().handleCancel).toEqual(undefined); + }); + + test('Should not have previous button if there is only one notice', async () => { + const wrapper = shallow(); + await baseProps.actions.getInProductNotices(baseProps.currentTeamId, 'web', baseProps.version); + expect(wrapper.find(GenericModal).props().handleCancel).toEqual(undefined); + }); + + test('Should open url in a new window on click of handleConfirm for single notice', async () => { + window.open = jest.fn(); + const wrapper = shallow(); + await baseProps.actions.getInProductNotices(); + wrapper.setState({noticesData: [noticesData[1]]}); + wrapper.find(GenericModal).prop('handleConfirm')?.(); + expect(window.open).toHaveBeenCalledWith(noticesData[1].actionParam, '_blank'); + }); + + test('Should call for getInProductNotices and updateNoticesAsViewed on mount', async () => { + shallow(); + expect(baseProps.actions.getInProductNotices).toHaveBeenCalledWith(baseProps.currentTeamId, 'web', baseProps.version); + await baseProps.actions.getInProductNotices(); + expect(baseProps.actions.updateNoticesAsViewed).toHaveBeenCalledWith([noticesData[0].id]); + }); + + test('Should call for updateNoticesAsViewed on click of next button', async () => { + const wrapper = shallow(); + await baseProps.actions.getInProductNotices(); + wrapper.find(GenericModal).prop('handleConfirm')?.(); + expect(baseProps.actions.updateNoticesAsViewed).toHaveBeenCalledWith([noticesData[1].id]); + }); + + test('Should clear state on onHide with a timer', async () => { + jest.useFakeTimers(); + const wrapper = shallow(); + await baseProps.actions.getInProductNotices(); + wrapper.find(GenericModal).prop('onHide')?.(); + jest.runOnlyPendingTimers(); + expect(wrapper.state('noticesData')).toEqual([]); + expect(wrapper.state('presentNoticeIndex')).toEqual(0); + }); + + test('Should call for getInProductNotices if socket reconnects for the first time in a day', () => { + const wrapper = shallow(); + Date.now = jest.fn().mockReturnValue(1599807605628); + wrapper.setProps({ + socketStatus: { + ...baseProps.socketStatus, + connected: false, + }, + }); + + wrapper.setProps({ + socketStatus: { + ...baseProps.socketStatus, + connected: true, + }, + }); + + expect(baseProps.actions.getInProductNotices).toHaveBeenCalledWith(baseProps.currentTeamId, 'web', baseProps.version); + expect(baseProps.actions.getInProductNotices).toHaveBeenCalledTimes(2); + }); + + test('Should call for getInProductNotices with desktop as client if isDesktopApp returns true', () => { + (getDesktopVersion as any).mockReturnValue('4.5.0'); + (isDesktopApp as any).mockReturnValue(true); + shallow(); + expect(baseProps.actions.getInProductNotices).toHaveBeenCalledWith(baseProps.currentTeamId, 'desktop', '4.5.0'); + }); + + test('Should not call for getInProductNotices if socket reconnects on the same day', () => { + const wrapper = shallow(); + Date.now = jest.fn().mockReturnValue(1599760196593); + wrapper.setProps({ + socketStatus: { + ...baseProps.socketStatus, + connected: false, + }, + }); + + wrapper.setProps({ + socketStatus: { + ...baseProps.socketStatus, + connected: true, + }, + }); + + expect(baseProps.actions.getInProductNotices).toHaveBeenCalledTimes(1); + }); +}); diff --git a/components/product_notices_modal/product_notices_modal.scss b/components/product_notices_modal/product_notices_modal.scss new file mode 100644 index 000000000000..bf1fc5f23cff --- /dev/null +++ b/components/product_notices_modal/product_notices_modal.scss @@ -0,0 +1,85 @@ +@charset 'UTF-8'; + +.productNotices { + width: 692px; + + .modal-content { + padding: 40px; + } + + .GenericModal__header { + padding: 0px; + } + + .GenericModal__body { + padding: 0px; + } + + .productNotices__helpText { + margin-top: 8px; + } + + .productNotices__img { + border: 1px solid var(--center-channel-color-16); + border-radius: 4px; + max-height: 347px; + margin: 20px auto 0px; + display: block; + } + .productNotices__imageDiv { + margin: 0px auto; + } + + .modal-footer { + margin-top: 20px; + } + + .productNotices__info { + position: fixed; + bottom: 45px; + z-index: 1; + } + + .circle { + height: 6px; + width: 6px; + margin: 0 3px; + vertical-align: middle; + } + + svg { + margin-left: 23px; + margin-right: 5px; + vertical-align: middle; + } + + &.GenericModal.modal-dialog { + height: unset; + margin-top: calc(50vh - 307px); + } + + &.GenericModal .modal-footer { + position: relative; + padding: 0px; + } + .GenericModal__button { + padding: 10px 16px; + span { + font-size: 12px; + line-height: 9px; + } + &.actionButton { + border-width: 2px; + border-color: rgb(35, 137, 215); + border: 1px solid var(--sys-button-bg); + border-radius: 4px; + display: inline-block; + } + } + + .fa { + width: 14px; + font-size: 14px; + margin-left: 5px; + } +} diff --git a/components/product_notices_modal/product_notices_modal.tsx b/components/product_notices_modal/product_notices_modal.tsx new file mode 100644 index 000000000000..d9c322bf3ae5 --- /dev/null +++ b/components/product_notices_modal/product_notices_modal.tsx @@ -0,0 +1,284 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {FormattedMessage} from 'react-intl'; + +import {ProductNotices, ProductNotice} from 'mattermost-redux/types/product_notices'; +import {WebsocketStatus} from 'mattermost-redux/types/websocket'; + +import Markdown from 'components/markdown'; +import GenericModal from 'components/generic_modal'; +import NextIcon from 'components/widgets/icons/fa_next_icon'; +import PreviousIcon from 'components/widgets/icons/fa_previous_icon'; +import AdminEyeIcon from 'components/widgets/icons/admin_eye_icon'; + +import {isDesktopApp, getDesktopVersion} from 'utils/user_agent'; + +import './product_notices_modal.scss'; + +type Props = { + version: string; + currentTeamId: string; + socketStatus: WebsocketStatus; + actions: { + getInProductNotices: (teamId: string, client: string, clientVersion: string) => Promise<{ + data: ProductNotices; + }>; + updateNoticesAsViewed: (noticeIds: string[]) => Promise>; + }; +} + +type State = { + presentNoticeIndex: number; + noticesData: ProductNotices; +} + +export default class ProductNoticesModal extends React.PureComponent { + clearDataTimer?: number; + constructor(props: Props) { + super(props); + this.state = { + presentNoticeIndex: 0, + noticesData: [], + }; + this.clearDataTimer = undefined; + } + + public componentDidMount() { + this.fetchNoticesData(); + } + + public componentDidUpdate(prevProps: Props) { + const presentSocketState = this.props.socketStatus; + const prevSocketState = prevProps.socketStatus; + if (presentSocketState.connected && !prevSocketState.connected && prevSocketState.lastConnectAt) { + const presentTime = Date.now(); + const previousSocketConnectDate = new Date(prevSocketState.lastConnectAt).getDate(); + const presentDate = new Date(presentTime).getDate(); + if (presentDate !== previousSocketConnectDate && presentTime > prevSocketState.lastConnectAt) { + this.fetchNoticesData(); + } + } + } + + public componentWillUnmount() { + clearTimeout(this.clearDataTimer); + } + + private async fetchNoticesData() { + const {version, currentTeamId} = this.props; + let client = 'web'; + let clientVersion = version; + if (isDesktopApp()) { + client = 'desktop'; + clientVersion = getDesktopVersion(); + } + + const {data} = await this.props.actions.getInProductNotices(currentTeamId, client, clientVersion); + if (data) { + this.setState({ + noticesData: data, + }); + if (data.length) { + const presentNoticeInfo = this.state.noticesData[this.state.presentNoticeIndex]; + this.props.actions.updateNoticesAsViewed([presentNoticeInfo.id]); + } + } + } + + private confirmButtonText(presentNoticeInfo: ProductNotice) { + const noOfNotices = this.state.noticesData.length; + + if (noOfNotices === 1 && presentNoticeInfo.actionText) { + return ( + + {presentNoticeInfo.actionText} + + ); + } else if (noOfNotices === this.state.presentNoticeIndex + 1) { + return ( + + ); + } + return ( + <> + + + + ); + } + + private cancelButtonText() { + if (this.state.presentNoticeIndex !== 0) { + return ( + <> + + + + ); + } + return null; + } + + private renderCicrleIndicators() { + const noOfNotices = this.state.noticesData.length; + if (noOfNotices === 1) { + return null; + } + + const indicators = []; + for (let i = 0; i < noOfNotices; i++) { + let className = 'circle'; + if (i === this.state.presentNoticeIndex) { + className += ' active'; + } + + indicators.push( + , + ); + } + return ( + + {indicators} + + ); + } + + private renderAdminOnlyText() { + if (this.state.noticesData[this.state.presentNoticeIndex].sysAdminOnly) { + return ( + <> + + + + ); + } + return null; + } + + private renderImage(image: string | undefined) { + if (image) { + return ( + + ); + } + return null; + } + + private renderActionButton(presentNoticeInfo: ProductNotice) { + const noOfNotices = this.state.noticesData.length; + + if (noOfNotices !== 1 && presentNoticeInfo.actionText) { + return ( + + {presentNoticeInfo.actionText} + + ); + } + return null; + } + + private handleNextButton = () => { + const presentNoticeInfo = this.state.noticesData[this.state.presentNoticeIndex]; + const noOfNotices = this.state.noticesData.length; + if (noOfNotices === 1 && presentNoticeInfo.actionText) { + window.open(presentNoticeInfo.actionParam, '_blank'); + } else if (this.state.presentNoticeIndex + 1 < noOfNotices) { + const nextNoticeInfo = this.state.noticesData[this.state.presentNoticeIndex + 1]; + + this.props.actions.updateNoticesAsViewed([nextNoticeInfo.id]); + + this.setState({ + presentNoticeIndex: this.state.presentNoticeIndex + 1, + }); + } + } + + private handlePreviousButton = () => { + if (this.state.presentNoticeIndex !== 0) { + this.setState({ + presentNoticeIndex: this.state.presentNoticeIndex - 1, + }); + } + } + + onModalDismiss = () => { + this.clearDataTimer = window.setTimeout(() => { + this.setState({ + noticesData: [], + presentNoticeIndex: 0, + }); + }, 3000); + } + + render() { + if (!this.state.noticesData.length) { + return null; + } + + const presentNoticeInfo = this.state.noticesData[this.state.presentNoticeIndex]; + const handlePreviousButton = this.state.presentNoticeIndex === 0 ? undefined : this.handlePreviousButton; + const autoCloseOnConfirmButton = this.state.presentNoticeIndex === this.state.noticesData.length - 1; + + return ( + + {presentNoticeInfo.title} + + )} + confirmButtonText={this.confirmButtonText(presentNoticeInfo)} + cancelButtonText={this.cancelButtonText()} + className='productNotices' + autoCloseOnConfirmButton={autoCloseOnConfirmButton} + autoCloseOnCancelButton={false} + > + + + + {this.renderActionButton(presentNoticeInfo)} +
+ {this.renderImage(presentNoticeInfo.image)} +
+
+ {this.renderCicrleIndicators()} + {this.renderAdminOnlyText()} +
+
+ ); + } +} diff --git a/components/widgets/icons/admin_eye_icon.jsx b/components/widgets/icons/admin_eye_icon.jsx new file mode 100644 index 000000000000..be3f82e1d7e3 --- /dev/null +++ b/components/widgets/icons/admin_eye_icon.jsx @@ -0,0 +1,35 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {FormattedMessage} from 'react-intl'; + +export default class AdminEyeIcon extends React.PureComponent { + render() { + return ( + + + {(ariaLabel) => ( + + + + )} + + + ); + } +} diff --git a/e2e/cypress/integration/modals/product_notice_spec.js b/e2e/cypress/integration/modals/product_notice_spec.js new file mode 100644 index 000000000000..8a0d14e5ca16 --- /dev/null +++ b/e2e/cypress/integration/modals/product_notice_spec.js @@ -0,0 +1,103 @@ +// 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. +// *************************************************************** + +// Group: @modals + +describe('MM-T3361 In product notices', () => { + let testTeam; + let testUser; + + before(() => { + cy.apiInitSetup().then(({team, user}) => { + testTeam = team; + testUser = user; + cy.visit(`/${testTeam.name}/channels/town-square`); + }); + }); + + it('MM-T3361_1 UI for miltiple notices', () => { + cy.visit(`/${testTeam.name}/channels/town-square`); + + // * Verify that the notices modal is open + cy.get('[aria-labelledby="genericModalLabel"').should('be.visible').within(() => { + // * Verify the title for sysadmin + cy.get('#genericModalLabel').should('be.visible').and('contain', 'Sysadmin notice title'); + + // * Verify the description for sysadmin notice + cy.get('.productNotices__helpText p').should('be.visible').and('contain', 'your eyes only!'); + + // * Verify the action button text and the href of the link + cy.get('.GenericModal__body #actionButton').should('have.attr', 'href', 'https://github.com/mattermost/mattermost-server').and('contain', 'Download'); + + // * Verify that the previous button is not visible as this is the first notice + cy.get('.GenericModal__button.cancel').should('be.not.visible'); + + // * Verify that the sysadmin notice has info that it is visible to only sysadmins + cy.get('.productNotices__info').within(() => cy.findByText('Visible to Admins only').should('be.visible')); + + // * Verify that the first circle indicator circle has class active + cy.get('#tutorialIntroCircle0').should('have.class', 'active'); + + // * Verify that there is a next button and click on it + cy.findByText('Next').should('be.visible').click(); + + // * Verify the end user modal notice title + cy.get('#genericModalLabel').should('be.visible').and('contain', 'End user notice title'); + + // * Verify the end user modal notice description + cy.get('.productNotices__helpText p').should('be.visible').and('contain', 'End user notice description'); + + // * Verify the action button text and the href of the link + cy.get('.GenericModal__body #actionButton').should('have.attr', 'href', 'https://github.com/mattermost/mattermost-webapp').and('contain', 'Update'); + + // * Verify that the previous button is visible as this is the second notice + cy.get('.GenericModal__button.cancel').should('be.visible'); + + // * Verify that the second circle indicator circle has class active + cy.get('#tutorialIntroCircle1').should('have.class', 'active'); + + // * Verify that there is a done button and click on it + cy.findByText('Done').should('be.visible').click(); + }); + + // * Verify that the notices modal is closed + cy.get('[aria-labelledby="genericModalLabel"').should('be.not.visible'); + cy.reload(); + cy.get('#postListContent').should('be.visible'); + + // * Verify that there is no notices modal + cy.get('[aria-labelledby="genericModalLabel"').should('be.not.visible'); + }); + + it('MM-T3361_2 UI for end user notice', () => { + cy.apiLogout(); + + // # Login as test user and go to town square + cy.apiLogin(testUser); + cy.visit(`/${testTeam.name}/channels/town-square`); + + // * Verify the end user modal notice title + cy.get('#genericModalLabel').should('be.visible').and('contain', 'End user notice title'); + + // * Verify the end user modal notice description + cy.get('.productNotices__helpText p').should('be.visible').and('contain', 'End user notice description'); + + // * Verify there is no action button + cy.get('.GenericModal__body #actionButton').should('be.not.visible'); + + // * Verify that the previous button is not visible as there is only one notice + cy.get('.GenericModal__button.cancel').should('be.not.visible'); + + // * Verify that there are no indicators + cy.get('.tutorial__circles').should('be.not.visible'); + + // * Verify that there is a done button and click on it + cy.findByText('Update').should('be.visible'); + }); +}); diff --git a/i18n/en.json b/i18n/en.json index c15da0955b24..6a8f2e470e78 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1070,6 +1070,10 @@ "admin.nav.menuAriaLabel": "Admin Console Menu", "admin.nav.switch": "Team Selection", "admin.nav.troubleshootingForum": "Troubleshooting Forum", + "admin.notices.enableAdminNoticesDescription": "When enabled, System Admins will receive notices about available server upgrades and relevant system administration features. [Learn more about notices](!https://about.mattermost.com/default-notices) in our documentation.", + "admin.notices.enableAdminNoticesTitle": "Enable Admin Notices: ", + "admin.notices.enableEndUserNoticesDescription": "When enabled, all users will receive notices about available client upgrades and relevant end user features to improve user experience. [Learn more about notices](!https://about.mattermost.com/default-notices) in our documentation.", + "admin.notices.enableEndUserNoticesTitle": "Enable End User Notices: ", "admin.oauth.gitlab": "GitLab", "admin.oauth.google": "Google Apps", "admin.oauth.off": "Do not allow sign-in via an OAuth 2.0 provider", @@ -1642,6 +1646,7 @@ "admin.sidebar.logs": "Server Logs", "admin.sidebar.metrics": "Performance Monitoring", "admin.sidebar.mfa": "MFA", + "admin.sidebar.notices": "Notices", "admin.sidebar.notifications": "Notifications", "admin.sidebar.oauth": "OAuth 2.0", "admin.sidebar.password": "Password", @@ -1672,6 +1677,7 @@ "admin.site.emoji": "Emoji", "admin.site.fileSharingDownloads": "File Sharing and Downloads", "admin.site.localization": "Localization", + "admin.site.notices": "Notices", "admin.site.posts": "Posts", "admin.site.public_links": "Public Links", "admin.site.usersAndTeams": "Users and Teams", @@ -2557,6 +2563,7 @@ "generic_icons.add": "Add Icon", "generic_icons.add-mail": "Add Mail Icon", "generic_icons.add-reaction": "Add Reaction Icon", + "generic_icons.adminOnlyIcon": "Admin View Only Icon", "generic_icons.alert": "Alert Icon", "generic_icons.archive": "Archive Icon", "generic_icons.arrow.down": "Down Arrow Icon", @@ -2609,6 +2616,9 @@ "generic_icons.warning": "Warning Icon", "generic_modal.cancel": "Cancel", "generic_modal.confirm": "Confirm", + "generic.done": "Done", + "generic.next": "Next", + "generic.previous": "Previous", "get_app.continueToBrowser": "View in Browser", "get_app.dontHaveTheDesktopApp": "Don't have the Desktop App?", "get_app.dontHaveTheMobileApp": "Don't have the Mobile App?", @@ -2763,6 +2773,7 @@ "help.messaging.reply": "**Reply to messages** by clicking the reply arrow next to the message text.", "help.messaging.title": "Messaging Basics", "help.messaging.write": "**Write messages** using the text input box at the bottom of Mattermost. Press ENTER to send a message. Use SHIFT+ENTER to create a new line without sending a message.", + "inProduct_notices.adminOnlyMessage": "Visible to Admins only", "input.clear": "Clear", "installed_command.header": "Slash Commands", "installed_commands.add": "Add Slash Command", diff --git a/package-lock.json b/package-lock.json index 065f243d08dd..9612f79563f3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17655,8 +17655,8 @@ "integrity": "sha512-6qE4B9deFBIa9YSpOc9O0Sgc43zTeVYbgDT5veRKSlB2+ZuHNoVVxA1L/ckMUayV9Ay9y7Z/SZCLcGteW9i7bg==" }, "mattermost-redux": { - "version": "github:mattermost/mattermost-redux#285e4105b21fcbe8ff091d302b92a9f9a8e0b262", - "from": "github:mattermost/mattermost-redux#285e4105b21fcbe8ff091d302b92a9f9a8e0b262", + "version": "github:mattermost/mattermost-redux#76b5cb575db485f0cc0ed40fdbded2fe020c1350", + "from": "github:mattermost/mattermost-redux#76b5cb575db485f0cc0ed40fdbded2fe020c1350", "requires": { "core-js": "3.6.5", "form-data": "3.0.0", diff --git a/package.json b/package.json index dc9becf92528..eac3ee6c6f05 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "localforage-observable": "2.0.1", "mark.js": "8.11.1", "marked": "github:mattermost/marked#87769262aa02e1784570f61f4f962050e07cc335", - "mattermost-redux": "github:mattermost/mattermost-redux#285e4105b21fcbe8ff091d302b92a9f9a8e0b262", + "mattermost-redux": "github:mattermost/mattermost-redux#76b5cb575db485f0cc0ed40fdbded2fe020c1350", "moment-timezone": "0.5.31", "p-queue": "6.6.1", "pdfjs-dist": "2.1.266", diff --git a/utils/user_agent.tsx b/utils/user_agent.tsx index 1d7c01bb46db..906d57c8adda 100644 --- a/utils/user_agent.tsx +++ b/utils/user_agent.tsx @@ -144,3 +144,10 @@ export function isWindows7(): boolean { return (/\bWindows NT 6\.1\b/).test(appVersion); } + +export function getDesktopVersion(): string { + // use if the value window.desktop.version is not set yet + const regex = /Mattermost\/(\d\.\d\.\d{0,1})/gm; + const match = regex.exec(window.navigator.appVersion)?.[1] || ''; + return match; +}