MM-38745 feat: Add dont clear option for DND (#26334)

* feat: Add dont clear option for DND

* Merge conflicts fixed

* ci: lint, types, i18n fix

* refactor: Remove unused code

---------

Co-authored-by: Mattermost Build <build@mattermost.com>
This commit is contained in:
Tanmay Thole 2024-03-21 02:14:50 +05:30 committed by GitHub
parent 9e5d486a5b
commit 1dc5c006f2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 235 additions and 72 deletions

View File

@ -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();

View File

@ -102,6 +102,7 @@ exports[`components/StatusDropdown should match snapshot in default state 1`] =
text="Away"
/>
<SubMenuItem
action={[Function]}
ariaLabel="Do not disturb. Disables all notifications"
direction="left"
extraText="Disables all notifications"
@ -122,7 +123,13 @@ exports[`components/StatusDropdown should match snapshot in default state 1`] =
"direction": "right",
"id": "dndSubMenu-header",
"isHeader": true,
"text": "Disable notifications until:",
"text": "Clear after:",
},
Object {
"action": [Function],
"direction": "right",
"id": "dndTime-dont_clear",
"text": "Don't clear",
},
Object {
"action": [Function],
@ -168,7 +175,7 @@ exports[`components/StatusDropdown should match snapshot in default state 1`] =
"action": [Function],
"direction": "right",
"id": "dndTime-custom",
"text": "Custom",
"text": "Choose date and time",
},
]
}
@ -423,6 +430,7 @@ exports[`components/StatusDropdown should match snapshot with custom status and
text="Away"
/>
<SubMenuItem
action={[Function]}
ariaLabel="Do not disturb. Disables all notifications"
direction="left"
extraText="Disables all notifications"
@ -443,7 +451,13 @@ exports[`components/StatusDropdown should match snapshot with custom status and
"direction": "right",
"id": "dndSubMenu-header",
"isHeader": true,
"text": "Disable notifications until:",
"text": "Clear after:",
},
Object {
"action": [Function],
"direction": "right",
"id": "dndTime-dont_clear",
"text": "Don't clear",
},
Object {
"action": [Function],
@ -489,7 +503,7 @@ exports[`components/StatusDropdown should match snapshot with custom status and
"action": [Function],
"direction": "right",
"id": "dndTime-custom",
"text": "Custom",
"text": "Choose date and time",
},
]
}
@ -696,6 +710,7 @@ exports[`components/StatusDropdown should match snapshot with custom status enab
text="Away"
/>
<SubMenuItem
action={[Function]}
ariaLabel="Do not disturb. Disables all notifications"
direction="left"
extraText="Disables all notifications"
@ -716,7 +731,13 @@ exports[`components/StatusDropdown should match snapshot with custom status enab
"direction": "right",
"id": "dndSubMenu-header",
"isHeader": true,
"text": "Disable notifications until:",
"text": "Clear after:",
},
Object {
"action": [Function],
"direction": "right",
"id": "dndTime-dont_clear",
"text": "Don't clear",
},
Object {
"action": [Function],
@ -762,7 +783,7 @@ exports[`components/StatusDropdown should match snapshot with custom status enab
"action": [Function],
"direction": "right",
"id": "dndTime-custom",
"text": "Custom",
"text": "Choose date and time",
},
]
}
@ -969,6 +990,7 @@ exports[`components/StatusDropdown should match snapshot with custom status expi
text="Away"
/>
<SubMenuItem
action={[Function]}
ariaLabel="Do not disturb. Disables all notifications"
direction="left"
extraText="Disables all notifications"
@ -989,7 +1011,13 @@ exports[`components/StatusDropdown should match snapshot with custom status expi
"direction": "right",
"id": "dndSubMenu-header",
"isHeader": true,
"text": "Disable notifications until:",
"text": "Clear after:",
},
Object {
"action": [Function],
"direction": "right",
"id": "dndTime-dont_clear",
"text": "Don't clear",
},
Object {
"action": [Function],
@ -1035,7 +1063,7 @@ exports[`components/StatusDropdown should match snapshot with custom status expi
"action": [Function],
"direction": "right",
"id": "dndTime-custom",
"text": "Custom",
"text": "Choose date and time",
},
]
}
@ -1243,6 +1271,7 @@ exports[`components/StatusDropdown should match snapshot with custom status puls
text="Away"
/>
<SubMenuItem
action={[Function]}
ariaLabel="Do not disturb. Disables all notifications"
direction="left"
extraText="Disables all notifications"
@ -1263,7 +1292,13 @@ exports[`components/StatusDropdown should match snapshot with custom status puls
"direction": "right",
"id": "dndSubMenu-header",
"isHeader": true,
"text": "Disable notifications until:",
"text": "Clear after:",
},
Object {
"action": [Function],
"direction": "right",
"id": "dndTime-dont_clear",
"text": "Don't clear",
},
Object {
"action": [Function],
@ -1309,7 +1344,7 @@ exports[`components/StatusDropdown should match snapshot with custom status puls
"action": [Function],
"direction": "right",
"id": "dndTime-custom",
"text": "Custom",
"text": "Choose date and time",
},
]
}
@ -1492,6 +1527,7 @@ exports[`components/StatusDropdown should match snapshot with profile picture UR
text="Away"
/>
<SubMenuItem
action={[Function]}
ariaLabel="Do not disturb. Disables all notifications"
direction="left"
extraText="Disables all notifications"
@ -1512,7 +1548,13 @@ exports[`components/StatusDropdown should match snapshot with profile picture UR
"direction": "right",
"id": "dndSubMenu-header",
"isHeader": true,
"text": "Disable notifications until:",
"text": "Clear after:",
},
Object {
"action": [Function],
"direction": "right",
"id": "dndTime-dont_clear",
"text": "Don't clear",
},
Object {
"action": [Function],
@ -1558,7 +1600,7 @@ exports[`components/StatusDropdown should match snapshot with profile picture UR
"action": [Function],
"direction": "right",
"id": "dndTime-custom",
"text": "Custom",
"text": "Choose date and time",
},
]
}
@ -1733,6 +1775,7 @@ exports[`components/StatusDropdown should match snapshot with status dropdown op
text="Away"
/>
<SubMenuItem
action={[Function]}
ariaLabel="Do not disturb. Disables all notifications"
direction="left"
extraText="Disables all notifications"
@ -1753,7 +1796,13 @@ exports[`components/StatusDropdown should match snapshot with status dropdown op
"direction": "right",
"id": "dndSubMenu-header",
"isHeader": true,
"text": "Disable notifications until:",
"text": "Clear after:",
},
Object {
"action": [Function],
"direction": "right",
"id": "dndTime-dont_clear",
"text": "Don't clear",
},
Object {
"action": [Function],
@ -1799,7 +1848,7 @@ exports[`components/StatusDropdown should match snapshot with status dropdown op
"action": [Function],
"direction": "right",
"id": "dndTime-custom",
"text": "Custom",
"text": "Choose date and time",
},
]
}
@ -2006,6 +2055,7 @@ exports[`components/StatusDropdown should not show clear status button when cust
text="Away"
/>
<SubMenuItem
action={[Function]}
ariaLabel="Do not disturb. Disables all notifications"
direction="left"
extraText="Disables all notifications"
@ -2026,7 +2076,13 @@ exports[`components/StatusDropdown should not show clear status button when cust
"direction": "right",
"id": "dndSubMenu-header",
"isHeader": true,
"text": "Disable notifications until:",
"text": "Clear after:",
},
Object {
"action": [Function],
"direction": "right",
"id": "dndTime-dont_clear",
"text": "Don't clear",
},
Object {
"action": [Function],
@ -2072,7 +2128,7 @@ exports[`components/StatusDropdown should not show clear status button when cust
"action": [Function],
"direction": "right",
"id": "dndTime-custom",
"text": "Custom",
"text": "Choose date and time",
},
]
}
@ -2327,6 +2383,7 @@ exports[`components/StatusDropdown should show clear status button when custom s
text="Away"
/>
<SubMenuItem
action={[Function]}
ariaLabel="Do not disturb. Disables all notifications"
direction="left"
extraText="Disables all notifications"
@ -2347,7 +2404,13 @@ exports[`components/StatusDropdown should show clear status button when custom s
"direction": "right",
"id": "dndSubMenu-header",
"isHeader": true,
"text": "Disable notifications until:",
"text": "Clear after:",
},
Object {
"action": [Function],
"direction": "right",
"id": "dndTime-dont_clear",
"text": "Don't clear",
},
Object {
"action": [Function],
@ -2393,7 +2456,7 @@ exports[`components/StatusDropdown should show clear status button when custom s
"action": [Function],
"direction": "right",
"id": "dndTime-custom",
"text": "Custom",
"text": "Choose date and time",
},
]
}

View File

@ -11,7 +11,7 @@ import {Client4} from 'mattermost-redux/client';
import {Preferences} from 'mattermost-redux/constants';
import {get, getBool, getInt} from 'mattermost-redux/selectors/entities/preferences';
import {getCurrentTimezone} from 'mattermost-redux/selectors/entities/timezone';
import {getCurrentUser, getStatusForUserId} from 'mattermost-redux/selectors/entities/users';
import {getCurrentUser, getDndEndTimeForUserId, getStatusForUserId} from 'mattermost-redux/selectors/entities/users';
import {openModal} from 'actions/views/modals';
import {setStatusDropdown} from 'actions/views/status_dropdown';
@ -53,6 +53,7 @@ function makeMapStateToProps() {
showCustomStatusPulsatingDot: showStatusDropdownPulsatingDot(state),
showCompleteYourProfileTour,
timezone: getCurrentTimezone(state),
dndEndTime: getDndEndTimeForUserId(state, userId),
};
};
}

View File

@ -2,6 +2,7 @@
// See LICENSE.txt for license information.
import classNames from 'classnames';
import moment from 'moment-timezone';
import React from 'react';
import type {ReactNode} from 'react';
import {injectIntl, FormattedDate, FormattedMessage, FormattedTime, defineMessage, defineMessages} from 'react-intl';
@ -35,7 +36,7 @@ import Avatar from 'components/widgets/users/avatar/avatar';
import type {TAvatarSizeToken} from 'components/widgets/users/avatar/avatar';
import {Constants, ModalIdentifiers, UserStatuses} from 'utils/constants';
import {getCurrentDateTimeForTimezone, getCurrentMomentForTimezone} from 'utils/timezone';
import {getBrowserTimezone, getCurrentDateTimeForTimezone, getCurrentMomentForTimezone} from 'utils/timezone';
import type {ModalData} from 'types/actions';
import type {Menu as MenuType} from 'types/store/plugins';
@ -64,6 +65,7 @@ type Props = {
showCompleteYourProfileTour: boolean;
showCustomStatusPulsatingDot: boolean;
timezone?: string;
dndEndTime?: number;
}
type State = {
@ -100,10 +102,6 @@ export const statusDropdownMessages: Record<string, Record<string, MessageDescri
id: 'status_dropdown.set_dnd',
defaultMessage: 'Do not disturb',
},
extra: {
id: 'status_dropdown.set_dnd.extra',
defaultMessage: 'Disables all notifications',
},
}),
offline: defineMessages({
name: {
@ -115,11 +113,12 @@ export const statusDropdownMessages: Record<string, Record<string, MessageDescri
export class StatusDropdown extends React.PureComponent<Props, State> {
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<Props, State> {
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<Props, State> {
);
};
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 = (
<FormattedMessage
id='custom_status.expiry.until'
defaultMessage='Until {time}'
values={{time: endTime.format('h:mm A')}}
/>
);
break;
case 1:
formattedEndTime = (
<FormattedMessage
id='custom_status.expiry.until_tomorrow'
defaultMessage='Until Tomorrow {time}'
values={{time: endTime.format('h:mm A')}}
/>
);
break;
default:
formattedEndTime = (
<FormattedMessage
id='custom_status.expiry.until'
defaultMessage='Until {time}'
values={{time: endTime.format('lll')}}
/>
);
}
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<Props, State> {
{
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<MenuType>(({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<Props, State> {
direction: 'right',
text,
action:
index === 4 ? () => setCustomTimedDnd() : () => setDnd(index),
index === 5 ? () => setCustomTimedDnd() : () => setDnd(index),
};
}),
);
@ -483,6 +530,8 @@ export class StatusDropdown extends React.PureComponent<Props, State> {
});
}
const dndExtraText = this.renderDndExtraText(dndEndTime, timezone);
return (
<MenuWrapper
onToggle={this.onToggle}
@ -575,9 +624,9 @@ export class StatusDropdown extends React.PureComponent<Props, State> {
/>
<Menu.ItemSubMenu
subMenu={dndSubMenuItems}
ariaLabel={`${this.props.intl.formatMessage(statusDropdownMessages.dnd.name)}. ${this.props.intl.formatMessage(statusDropdownMessages.dnd.extra)}`}
ariaLabel={`${this.props.intl.formatMessage(statusDropdownMessages.dnd.name)}. ${dndExtraText}`}
text={this.props.intl.formatMessage(statusDropdownMessages.dnd.name)}
extraText={this.props.intl.formatMessage(statusDropdownMessages.dnd.extra)}
extraText={dndExtraText}
icon={(
<StatusIcon
status={'dnd'}
@ -588,6 +637,7 @@ export class StatusDropdown extends React.PureComponent<Props, State> {
direction={'left'}
openUp={this.state.openUp}
id={'status-menu-dnd'}
action={() => setDnd(0)}
/>
<Menu.ItemAction
onClick={setOffline}

View File

@ -21,7 +21,6 @@ exports[`components/widgets/menu/menu_items/submenu_item empty subMenu should ma
<div
className=""
id="1"
onClick={[Function]}
onKeyDown={[Function]}
onMouseEnter={[Function]}
onMouseLeave={[Function]}
@ -89,7 +88,6 @@ exports[`components/widgets/menu/menu_items/submenu_item present subMenu should
<div
className=""
id="1"
onClick={[Function]}
onKeyDown={[Function]}
onMouseEnter={[Function]}
onMouseLeave={[Function]}
@ -142,7 +140,6 @@ exports[`components/widgets/menu/menu_items/submenu_item present subMenu should
<div
className=""
id="A"
onClick={[Function]}
onKeyDown={[Function]}
onMouseEnter={[Function]}
onMouseLeave={[Function]}
@ -199,7 +196,6 @@ exports[`components/widgets/menu/menu_items/submenu_item present subMenu should
<div
className=""
id="B"
onClick={[Function]}
onKeyDown={[Function]}
onMouseEnter={[Function]}
onMouseLeave={[Function]}

View File

@ -4,6 +4,7 @@
import {mount} from 'enzyme';
import React from 'react';
import {render, screen, userEvent} from 'tests/react_testing_utils';
import Constants from 'utils/constants';
import SubMenuItem from './submenu_item';
@ -27,6 +28,7 @@ describe('components/widgets/menu/menu_items/submenu_item', () => {
expect(wrapper).toMatchSnapshot();
});
test('present subMenu should match snapshot with submenu', () => {
const wrapper = mount(
<SubMenuItem
@ -52,11 +54,13 @@ describe('components/widgets/menu/menu_items/submenu_item', () => {
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(
<SubMenuItem
key={'_pluginmenuitem'}
id={'Z'}
@ -79,16 +83,20 @@ describe('components/widgets/menu/menu_items/submenu_item', () => {
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<SubMenuItem>(
<SubMenuItem

View File

@ -55,7 +55,7 @@ export type Props = {
direction?: 'left' | 'right';
openUp?: boolean;
styleSelectableItem?: boolean;
extraText?: string;
extraText?: string| JSX.Element;
rightDecorator?: React.ReactNode;
isHeader?: boolean;
tabIndex?: number;
@ -95,7 +95,7 @@ export default class SubMenuItem extends React.PureComponent<Props, State> {
this.setState({show: false});
};
private onClick = (event: React.SyntheticEvent<HTMLElement>) => {
private onClick = (event: React.SyntheticEvent<HTMLElement>| React.BaseSyntheticEvent<HTMLElement>) => {
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<Props, State> {
} 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<Props, State> {
aria-label={ariaLabel}
onMouseEnter={this.show}
onMouseLeave={this.hide}
onClick={this.onClick}
tabIndex={tabIndex ?? 0}
onKeyDown={this.handleKeyDown}
>

View File

@ -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(
<SubMenuModal {...props}/>,
);
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', () => {

View File

@ -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",

View File

@ -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: {},

View File

@ -496,6 +496,35 @@ function addToState<T>(state: Record<string, T>, key: string, value: T): Record<
};
}
function dndEndTimes(state: RelationOneToOne<UserProfile, number> = {}, 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<UserProfile, string> = {}, 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,

View File

@ -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;
}

View File

@ -33,6 +33,7 @@ const state: GlobalState = {
filteredStats: {},
myUserAccessTokens: {},
lastActivity: {},
dndEndTimes: {},
},
limits: {
usersLimits: {

View File

@ -23,6 +23,7 @@ const emptyOtherUsersState: Omit<GlobalState['entities']['users'], 'profiles' |
filteredStats: {},
myUserAccessTokens: {},
lastActivity: {},
dndEndTimes: {},
};
export const adminUsersState: () => GlobalState['entities']['users'] = () => ({

View File

@ -82,6 +82,7 @@ export type UsersState = {
filteredStats: Partial<UsersStats>;
myUserAccessTokens: Record<string, UserAccessToken>;
lastActivity: RelationOneToOne<UserProfile, number>;
dndEndTimes: RelationOneToOne<UserProfile, number>;
};
export type UserTimezone = {