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 = {