diff --git a/e2e-tests/cypress/tests/integration/channels/status/status_dnd_1_spec.js b/e2e-tests/cypress/tests/integration/channels/status/status_dnd_1_spec.js index 64e4f21820..610ac7d6dc 100644 --- a/e2e-tests/cypress/tests/integration/channels/status/status_dnd_1_spec.js +++ b/e2e-tests/cypress/tests/integration/channels/status/status_dnd_1_spec.js @@ -11,6 +11,7 @@ describe('DND Status - Setting Your Own DND Status', () => { const dndTimes = [ + 'dndTime-dont_clear_menuitem', 'dndTime-thirty_minutes_menuitem', 'dndTime-one_hour_menuitem', 'dndTime-two_hours_menuitem', @@ -29,7 +30,7 @@ describe('DND Status - Setting Your Own DND Status', () => { it('MM-8497_1 Set status DND with predefined end times', () => { // # Loop through all predefined end times and verify them - for (let i = 0; i < 4; i++) { + for (let i = 0; i < 5; i++) { // # Open status dropdown menu and hover over Do Not Disturb option openDndStatusSubMenu(); diff --git a/webapp/channels/src/components/status_dropdown/__snapshots__/status_dropdown.test.tsx.snap b/webapp/channels/src/components/status_dropdown/__snapshots__/status_dropdown.test.tsx.snap index e71b9f8629..ebe335aac3 100644 --- a/webapp/channels/src/components/status_dropdown/__snapshots__/status_dropdown.test.tsx.snap +++ b/webapp/channels/src/components/status_dropdown/__snapshots__/status_dropdown.test.tsx.snap @@ -102,6 +102,7 @@ exports[`components/StatusDropdown should match snapshot in default state 1`] = text="Away" /> { dndTimes = [ + {id: 'dont_clear', label: defineMessage({id: 'status_dropdown.dnd_sub_menu_item.dont_clear', defaultMessage: 'Don\'t clear'})}, {id: 'thirty_minutes', label: defineMessage({id: 'status_dropdown.dnd_sub_menu_item.thirty_minutes', defaultMessage: '30 mins'})}, {id: 'one_hour', label: defineMessage({id: 'status_dropdown.dnd_sub_menu_item.one_hour', defaultMessage: '1 hour'})}, {id: 'two_hours', label: defineMessage({id: 'status_dropdown.dnd_sub_menu_item.two_hours', defaultMessage: '2 hours'})}, {id: 'tomorrow', label: defineMessage({id: 'status_dropdown.dnd_sub_menu_item.tomorrow', defaultMessage: 'Tomorrow'})}, - {id: 'custom', label: defineMessage({id: 'status_dropdown.dnd_sub_menu_item.custom', defaultMessage: 'Custom'})}, + {id: 'custom', label: defineMessage({id: 'status_dropdown.dnd_sub_menu_item.custom', defaultMessage: 'Choose date and time'})}, ]; static defaultProps = { userId: '', @@ -176,18 +175,21 @@ export class StatusDropdown extends React.PureComponent { let endTime = currentDate; switch (index) { case 0: + endTime = moment(0); + break; + case 1: // add 30 minutes in current time endTime = currentDate.add(30, 'minutes'); break; - case 1: + case 2: // add 1 hour in current time endTime = currentDate.add(1, 'hour'); break; - case 2: + case 3: // add 2 hours in current time endTime = currentDate.add(2, 'hours'); break; - case 3: + case 4: // set to next day 9 in the morning endTime = currentDate.add(1, 'day').set({hour: 9, minute: 0}); break; @@ -381,10 +383,55 @@ export class StatusDropdown extends React.PureComponent { ); }; + renderDndExtraText = (dndEndTime?: number, timezone?: string) => { + if (!(dndEndTime && dndEndTime > 0)) { + return this.props.intl.formatMessage({id: 'status_dropdown.set_dnd.extra', defaultMessage: 'Disables all notifications'}); + } + + const tz = timezone || getBrowserTimezone(); + const currentTime = moment().tz(tz); + const endTime = moment.unix(dndEndTime).tz(tz); + + let formattedEndTime; + + const diffDays = endTime.clone().startOf('day').diff(currentTime.clone().startOf('day'), 'days'); + + switch (diffDays) { + case 0: + formattedEndTime = ( + + ); + break; + case 1: + formattedEndTime = ( + + ); + break; + default: + formattedEndTime = ( + + ); + } + + return formattedEndTime; + }; + render = (): JSX.Element => { const {intl} = this.props; const needsConfirm = this.isUserOutOfOffice() && this.props.autoResetPref === ''; - const {status, customStatus, isCustomStatusExpired, currentUser} = this.props; + const {status, customStatus, isCustomStatusExpired, currentUser, timezone, dndEndTime} = this.props; const isStatusSet = customStatus && !isCustomStatusExpired && (customStatus.text?.length > 0 || customStatus.emoji?.length > 0); const setOnline = needsConfirm ? () => this.showStatusChangeConfirmation('online') : this.setOnline; @@ -404,13 +451,13 @@ export class StatusDropdown extends React.PureComponent { { id: 'dndSubMenu-header', direction: 'right', - text: this.props.intl.formatMessage({id: 'status_dropdown.dnd_sub_menu_header', defaultMessage: 'Disable notifications until:'}), + text: this.props.intl.formatMessage({id: 'status_dropdown.dnd_sub_menu_header', defaultMessage: 'Clear after:'}), isHeader: true, }, ] as MenuType[])?.concat( this.dndTimes.map(({id, label}, index) => { let text: MenuType['text'] = this.props.intl.formatMessage(label); - if (index === 3) { + if (index === 4) { const tomorrow = getCurrentMomentForTimezone(this.props.timezone).add(1, 'day').set({hour: 9, minute: 0}).toDate(); text = ( <> @@ -437,7 +484,7 @@ export class StatusDropdown extends React.PureComponent { direction: 'right', text, action: - index === 4 ? () => setCustomTimedDnd() : () => setDnd(index), + index === 5 ? () => setCustomTimedDnd() : () => setDnd(index), }; }), ); @@ -483,6 +530,8 @@ export class StatusDropdown extends React.PureComponent { }); } + const dndExtraText = this.renderDndExtraText(dndEndTime, timezone); + return ( { /> { direction={'left'} openUp={this.state.openUp} id={'status-menu-dnd'} + action={() => setDnd(0)} /> { expect(wrapper).toMatchSnapshot(); }); + test('present subMenu should match snapshot with submenu', () => { const wrapper = mount( { expect(wrapper).toMatchSnapshot(); }); - test('test subMenu click triggers action', async () => { - const action1 = jest.fn().mockReturnValueOnce('default'); - const action2 = jest.fn().mockReturnValueOnce('default'); - const action3 = jest.fn().mockReturnValueOnce('default'); - const wrapper = mount( + + test('test subMenu click triggers action', () => { + const action1 = jest.fn(); + const action2 = jest.fn(); + const action3 = jest.fn(); + + render( { root={true} />, ); - wrapper.setState({show: true}); - wrapper.find('#Z').at(1).simulate('click'); - await expect(action1).toHaveBeenCalledTimes(1); - wrapper.setState({show: true}); - wrapper.find('#A').at(1).simulate('click'); - await expect(action2).toHaveBeenCalledTimes(1); - wrapper.setState({show: true}); - wrapper.find('#B').at(1).simulate('click'); - await expect(action3).toHaveBeenCalledTimes(1); + + userEvent.click(screen.getByText('test')); + expect(action1).toHaveBeenCalledTimes(1); + + userEvent.click(screen.getByText('Test A')); + expect(action2).toHaveBeenCalledTimes(1); + + userEvent.click(screen.getByText('Test B')); + expect(action3).toHaveBeenCalledTimes(1); + + // Confirm that the parent's action wasn't called again when clicking on a child item + expect(action1).toHaveBeenCalledTimes(1); }); + test('should show/hide submenu based on keyboard commands', () => { const wrapper = mount( { this.setState({show: false}); }; - private onClick = (event: React.SyntheticEvent) => { + private onClick = (event: React.SyntheticEvent| React.BaseSyntheticEvent) => { event.preventDefault(); const {id, postId, subMenu, action, root, isHeader} = this.props; const isMobile = isMobileViewHack(); @@ -112,8 +112,13 @@ export default class SubMenuItem extends React.PureComponent { } else if (action) { // leaf node in the tree handles action only action(postId); } - } else if (event.currentTarget.id === id && action) { - action(postId); + } else { + const shouldCallAction = + (event.type === 'keydown' && event.currentTarget.id === id) || + event.target.parentElement.id === id; + if (shouldCallAction && action) { + action(postId); + } } }; @@ -233,7 +238,6 @@ export default class SubMenuItem extends React.PureComponent { aria-label={ariaLabel} onMouseEnter={this.show} onMouseLeave={this.hide} - onClick={this.onClick} tabIndex={tabIndex ?? 0} onKeyDown={this.handleKeyDown} > diff --git a/webapp/channels/src/components/widgets/menu/menu_modals/submenu_modal/submenu_modal.test.tsx b/webapp/channels/src/components/widgets/menu/menu_modals/submenu_modal/submenu_modal.test.tsx index c3420a6fba..4bc9f2aa52 100644 --- a/webapp/channels/src/components/widgets/menu/menu_modals/submenu_modal/submenu_modal.test.tsx +++ b/webapp/channels/src/components/widgets/menu/menu_modals/submenu_modal/submenu_modal.test.tsx @@ -1,10 +1,12 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {shallow, mount} from 'enzyme'; +import {shallow} from 'enzyme'; import React from 'react'; import {Modal} from 'react-bootstrap'; +import {render, screen, userEvent} from 'tests/react_testing_utils'; + import SubMenuModal from './submenu_modal'; jest.mock('../../is_mobile_view_hack', () => ({ @@ -67,24 +69,19 @@ describe('components/submenu_modal', () => { const props = { ...baseProps, }; - const wrapper = mount( + + render( , ); - wrapper.setState({show: true}); - await wrapper.find('#A').at(1).simulate('click'); + userEvent.click(screen.getByText('Text A')); expect(action1).toHaveBeenCalledTimes(1); - expect(wrapper.state('show')).toEqual(false); - wrapper.setState({show: true}); - await wrapper.find('#B').at(1).simulate('click'); + userEvent.click(screen.getByText('Text B')); expect(action2).toHaveBeenCalledTimes(1); - expect(wrapper.state('show')).toEqual(false); - wrapper.setState({show: true}); - await wrapper.find('#C').at(1).simulate('click'); + userEvent.click(screen.getByText('Text C')); expect(action3).toHaveBeenCalledTimes(1); - expect(wrapper.state('show')).toEqual(false); }); test('should have called props.onExited when Modal.onExited is called', () => { diff --git a/webapp/channels/src/i18n/en.json b/webapp/channels/src/i18n/en.json index 3e0a7efaa8..2ee8f38cbf 100644 --- a/webapp/channels/src/i18n/en.json +++ b/webapp/channels/src/i18n/en.json @@ -3433,7 +3433,8 @@ "custom_status.expiry_dropdown.this_week": "This week", "custom_status.expiry_dropdown.today": "Today", "custom_status.expiry.time_picker.title": "Time", - "custom_status.expiry.until": "Until", + "custom_status.expiry.until": "Until {time}", + "custom_status.expiry.until_tomorrow": "Until Tomorrow {time}", "custom_status.modal_cancel": "Clear Status", "custom_status.modal_confirm": "Set Status", "custom_status.set_status": "Set a status", @@ -5181,8 +5182,9 @@ "start_trial.modal.loading": "Loading...", "start_trial.tutorialTip.desc": "Explore our most requested premium features. Determine user access with Guest Accounts, automate compliance reports, and send secure ID-only mobile push notifications.", "start_trial.tutorialTip.title": "Try our premium features for free", - "status_dropdown.dnd_sub_menu_header": "Disable notifications until:", - "status_dropdown.dnd_sub_menu_item.custom": "Custom", + "status_dropdown.dnd_sub_menu_header": "Clear after:", + "status_dropdown.dnd_sub_menu_item.custom": "Choose date and time", + "status_dropdown.dnd_sub_menu_item.dont_clear": "Don't clear", "status_dropdown.dnd_sub_menu_item.one_hour": "1 hour", "status_dropdown.dnd_sub_menu_item.thirty_minutes": "30 mins", "status_dropdown.dnd_sub_menu_item.tomorrow": "Tomorrow", diff --git a/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/users.test.ts b/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/users.test.ts index c86b1c1eda..4610e9b1c0 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/users.test.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/users.test.ts @@ -1012,6 +1012,7 @@ describe('Reducers.users', () => { let state: UsersState = { currentUserId: '', + dndEndTimes: {}, mySessions: [], myAudits: [], myUserAccessTokens: {}, @@ -1060,6 +1061,7 @@ describe('Reducers.users', () => { expect(nextState).toEqual({ currentUserId: '', + dndEndTimes: {}, mySessions: [], myAudits: [], myUserAccessTokens: {}, diff --git a/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/users.ts b/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/users.ts index 94451e0111..d504bb28ea 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/users.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/users.ts @@ -496,6 +496,35 @@ function addToState(state: Record, key: string, value: T): Record< }; } +function dndEndTimes(state: RelationOneToOne = {}, action: AnyAction) { + switch (action.type) { + case UserTypes.RECEIVED_STATUS: { + const userId = action.data.user_id; + const endTime = action.data.dnd_end_time; + + return addToState(state, userId, endTime); + } + case UserTypes.RECEIVED_STATUSES: { + const userStatuses: UserStatus[] = action.data; + + return userStatuses.reduce((nextState, userStatus) => addToState(nextState, userStatus.user_id, userStatus.dnd_end_time || 0), state); + } + case UserTypes.PROFILE_NO_LONGER_VISIBLE: { + if (state[action.data.user_id]) { + const newState = {...state}; + delete newState[action.data.user_id]; + return newState; + } + return state; + } + + case UserTypes.LOGOUT_SUCCESS: + return {}; + default: + return state; + } +} + function statuses(state: RelationOneToOne = {}, action: AnyAction) { switch (action.type) { case UserTypes.RECEIVED_STATUS: { @@ -705,6 +734,9 @@ export default combineReducers({ // object where every key is a group id and has a Set with the users id that are members of the group profilesNotInGroup, + // object where every key is the user id and has a value with the dnd end time of each user + dndEndTimes, + // object where every key is the user id and has a value with the current status of each user statuses, diff --git a/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/users.ts b/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/users.ts index cdbc3d6867..e1e14e9ebe 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/users.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/users.ts @@ -435,6 +435,10 @@ export function getStatusForUserId(state: GlobalState, userId: UserProfile['id'] return getUserStatuses(state)[userId]; } +export function getDndEndTimeForUserId(state: GlobalState, userId: UserProfile['id']): number { + return state.entities.users.dndEndTimes[userId]; +} + export function getTotalUsersStats(state: GlobalState) { return state.entities.users.stats; } diff --git a/webapp/channels/src/packages/mattermost-redux/src/store/initial_state.ts b/webapp/channels/src/packages/mattermost-redux/src/store/initial_state.ts index e6f89d8b38..3000cb76c5 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/store/initial_state.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/store/initial_state.ts @@ -33,6 +33,7 @@ const state: GlobalState = { filteredStats: {}, myUserAccessTokens: {}, lastActivity: {}, + dndEndTimes: {}, }, limits: { usersLimits: { diff --git a/webapp/channels/src/tests/constants/users.ts b/webapp/channels/src/tests/constants/users.ts index 65e953ca03..709af09759 100644 --- a/webapp/channels/src/tests/constants/users.ts +++ b/webapp/channels/src/tests/constants/users.ts @@ -23,6 +23,7 @@ const emptyOtherUsersState: Omit GlobalState['entities']['users'] = () => ({ diff --git a/webapp/platform/types/src/users.ts b/webapp/platform/types/src/users.ts index 1d69f6c605..ef60cc8ea2 100644 --- a/webapp/platform/types/src/users.ts +++ b/webapp/platform/types/src/users.ts @@ -82,6 +82,7 @@ export type UsersState = { filteredStats: Partial; myUserAccessTokens: Record; lastActivity: RelationOneToOne; + dndEndTimes: RelationOneToOne; }; export type UserTimezone = {