[MM-55274] User account menu doesn't follow standard accessible keyboard behavior (#29173)

Этот коммит содержится в:
M-ZubairAhmed
2025-01-15 00:55:38 +05:30
коммит произвёл GitHub
родитель 3f9aa4a212
Коммит 4b6216f5a4
75 изменённых файлов: 2020 добавлений и 4305 удалений

Просмотреть файл

@@ -26,9 +26,9 @@ describe('Verify Accessibility Support in Different Images', () => {
it('MM-T1508 Accessibility support in different images', () => {
// * Verify image alt in profile image
cy.uiGetProfileHeader().
find('.Avatar').
should('have.attr', 'alt', 'user profile image');
cy.get('#userAccountMenuButton').within(() => {
cy.findByAltText('user profile image').should('be.visible');
});
// # Upload an image in the post
cy.get('#fileUploadInput').attachFile('small-image.png');

Просмотреть файл

@@ -163,9 +163,6 @@ describe('Profile > Profile Settings > Email', () => {
// # Click on the link
cy.visit(permalink);
// * Verify announcement bar
cy.get('.announcement-bar').should('be.visible').should('contain.text', 'Email verified');
// # Wait for one second for the mail to be sent out.
cy.wait(TIMEOUTS.FIVE_SEC);
@@ -199,7 +196,7 @@ describe('Profile > Profile Settings > Email', () => {
// # Close modal then logout
cy.get('body').type('{esc}');
cy.uiOpenUserMenu('Log Out');
cy.uiLogout();
// # Wait for one second for the mail to be sent out.
cy.wait(TIMEOUTS.ONE_SEC);

Просмотреть файл

@@ -116,8 +116,8 @@ describe('Settings > Sidebar > General > Edit', () => {
// # Step 3
// * Verify that we've logged in as the temp user
cy.visit(offTopicUrl);
cy.uiOpenUserMenu().findByText(`@${newTempUserName}`);
cy.uiGetSetStatusButton().click();
cy.uiOpenUserMenu().findByText(`@${newTempUserName}`).should('exist');
cy.get('body').type('{esc}');
// # Step 4
const text = `${newTempUserName} test message!`;

Просмотреть файл

@@ -124,5 +124,5 @@ function verifyUsername(username) {
cy.uiOpenUserMenu().findByText(`@${username}`);
// # Close the user menu
cy.uiGetSetStatusButton().click();
cy.get('body').type('{esc}');
}

Просмотреть файл

@@ -1,6 +1,8 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import * as TIMEOUTS from '../../../fixtures/timeouts';
import {clickCategoryMenuItem} from './helpers';
// ***************************************************************
@@ -22,6 +24,8 @@ describe('Sidebar category menu', () => {
it('MM-T3171_1 Verify that the 3-dot menu on the Channels Category contains an option to Create New Category', () => {
clickCategoryMenuItem('CHANNELS', 'Create New Category');
cy.wait(TIMEOUTS.ONE_SEC);
cy.get('body').type('{esc}', {force: true});
});
@@ -41,6 +45,8 @@ describe('Sidebar category menu', () => {
// # Verify that Create New Category exists on Favorites category and click on it
clickCategoryMenuItem('FAVORITES', 'Create New Category');
cy.wait(TIMEOUTS.ONE_SEC);
cy.get('body').type('{esc}', {force: true});
});
});

Просмотреть файл

@@ -28,15 +28,23 @@ describe('Custom Status - Setting a Custom Status', () => {
it('MM-T3836_1 should open status dropdown', () => {
// # Click on the sidebar header to open status dropdown
cy.get('.MenuWrapper .status-wrapper').click();
cy.uiGetSetStatusButton().click();
// * Check if the status dropdown opens
cy.get('#statusDropdownMenu').should('exist');
cy.uiGetStatusMenuContainer();
// # Close the status dropdown
cy.get('body').type('{esc}');
});
it('MM-T3836_2 Custom status modal opens with 5 default statuses listed', () => {
// # Click on the sidebar header to open status dropdown
cy.uiGetSetStatusButton().click();
// * Check if the status dropdown opens
cy.uiGetStatusMenuContainer().findByText('Set custom status').click();
// # Open custom status modal
cy.get('#statusDropdownMenu li#status-menu-custom-status').click();
cy.get('#custom_status_modal').should('exist');
// * Check if all the default suggestions exist
@@ -111,13 +119,15 @@ describe('Custom Status - Setting a Custom Status', () => {
trigger('mouseover');
// * Custom status tooltip should be visible
cy.get('#custom-status-tooltip').should('exist');
cy.get('#custom-status-tooltip').should('exist').and('be.visible');
// * Tooltip should contain the correct custom status emoji
cy.get('#custom-status-tooltip .custom-status span.emoticon').invoke('attr', 'data-emoticon').should('contain', customStatus.emoji);
cy.get('#custom-status-tooltip').within(() => {
// * Tooltip should contain the correct custom status emoji
cy.get(`span[data-emoticon="${customStatus.emoji}"]`).should('exist');
// * Tooltip should contain the correct custom status text
cy.get('#custom-status-tooltip .custom-status span.custom-status-text').should('have.text', customStatus.text);
// * Tooltip should contain the correct custom status text
cy.findByText(customStatus.text).should('exist');
});
});
it('MM-T3836_8 should open custom status modal when emoji in LHS header is clicked', () => {

Просмотреть файл

@@ -27,7 +27,7 @@ describe('Custom Status - Setting Your Own Custom Status', () => {
it('MM-T3846_1 should change the emoji to speech balloon when typed in the input', () => {
// # Open the custom status modal
cy.uiOpenUserMenu('Set a custom status');
cy.uiOpenUserMenu('Set custom status');
// * Default emoji is currently visible in the custom status input
cy.get('#custom_status_modal .StatusModal__emoji-button span').should('have.class', 'icon--emoji');
@@ -72,20 +72,26 @@ describe('Custom Status - Setting Your Own Custom Status', () => {
cy.uiGetProfileHeader().
find('.emoticon').
should('have.attr', 'data-emoticon', customStatus.emoji);
cy.get('body').type('{esc}');
});
it('MM-T3846_5 should show custom status with emoji in the status dropdown', () => {
// # Open user menu
cy.uiOpenUserMenu();
cy.uiOpenUserMenu().within(() => {
// * Correct custom status text and emoji should be displayed in the status dropdown
cy.findByText(customStatus.text).should('exist');
cy.get(`span[data-emoticon="${customStatus.emoji}"]`).should('exist');
});
// * Correct custom status text and emoji should be displayed in the status dropdown
cy.get('.status-dropdown-menu .custom_status__container').should('have.text', customStatus.text);
cy.get('.status-dropdown-menu .custom_status__row span.emoticon').invoke('attr', 'data-emoticon').should('contain', customStatus.emoji);
cy.get('body').type('{esc}');
});
it('MM-T3846_6 should show previosly set status in the first position in Recents list', () => {
it('MM-T3846_6 should show previously set status in the first position in Recents list', () => {
// # Click on the "Set a Custom Status" option in the status dropdown
cy.get('.status-dropdown-menu li#status-menu-custom-status').click();
cy.uiOpenUserMenu().within(() => {
cy.findByText(customStatus.text).should('exist').click({force: true});
});
// * Custom status modal should open
cy.get('#custom_status_modal').should('exist');
@@ -118,9 +124,9 @@ describe('Custom Status - Setting Your Own Custom Status', () => {
cy.findByText('Clear Status').click();
// # Open user menu
cy.uiOpenUserMenu();
// * Custom status text should not be displayed in the status dropdown
cy.get('.status-dropdown-menu .custom_status__row').should('not.have.text', customStatus.text);
cy.uiOpenUserMenu().within(() => {
// * Custom status text should not be displayed in the status dropdown
cy.findByText(customStatus.text).should('not.exist');
});
});
});

Просмотреть файл

@@ -32,7 +32,7 @@ describe('Custom Status - Recent Statuses', () => {
it('MM-T3847_1 set a status', () => {
// # Open the custom status modal
cy.uiOpenUserMenu('Set a custom status');
cy.uiOpenUserMenu('Set custom status');
// # Type the custom status text in the custom status modal input
cy.get('#custom_status_modal .StatusModal__input input').typeWithForce(customStatus.text);

Просмотреть файл

@@ -27,7 +27,7 @@ describe('Custom Status - Verifying Where Custom Status Appears', () => {
});
it('MM-T3850_1 set a status', () => {
cy.uiOpenUserMenu('Set a Custom Status');
cy.uiOpenUserMenu('Set custom status');
// # Type the custom status text in the custom status modal input
cy.findByPlaceholderText('Set a status').type(customStatus.text, {force: true});

Просмотреть файл

@@ -25,6 +25,7 @@ describe('MM-T4063 Custom status expiry', () => {
const defaultCustomStatuses = ['In a meeting', 'Out for lunch', 'Out sick', 'Working from home', 'On a vacation'];
const customStatus = {
emoji: 'hamburger',
emojiAriaLabel: ':hamburger:',
text: 'Out for lunch',
duration: '30 minutes',
};
@@ -36,19 +37,25 @@ describe('MM-T4063 Custom status expiry', () => {
const expiryTimeFormat = 'h:mm A';
it('MM-T4063_1 should open status dropdown', () => {
// # Click on the sidebar header to open status dropdown
cy.get('.MenuWrapper .status-wrapper').click();
cy.uiGetSetStatusButton().click();
// * Check if the status dropdown opens
cy.get('#statusDropdownMenu').should('exist');
cy.uiGetStatusMenu();
// # Close the status dropdown
cy.get('body').click();
});
it('MM-T4063_2 Custom status modal opens with 5 default statuses listed', () => {
// # Open custom status modal
cy.get('#statusDropdownMenu li#status-menu-custom-status').click();
cy.get('#custom_status_modal').should('exist');
cy.uiOpenUserMenu('Set custom status');
// * Check if all the default suggestions exist
defaultCustomStatuses.map((statusText) => cy.get('#custom_status_modal .statusSuggestion__content').contains('span', statusText));
cy.findByRole('dialog', {name: 'Set a status'}).should('exist').within(() => {
// * Check if all the default suggestions exist
defaultCustomStatuses.forEach((statusText) => {
cy.findByText(statusText).should('exist');
});
});
});
it('MM-T4063_3 Correct custom status is selected with the correct emoji and correct duration', () => {
@@ -94,17 +101,17 @@ describe('MM-T4063 Custom status expiry', () => {
it('MM-T4063_5 should show the set custom status with expiry when status dropdown is opened', () => {
// # Click on the sidebar header to open status dropdown
cy.get('.MenuWrapper .status-wrapper').click();
cy.uiGetSetStatusButton().click();
// * Check if the status dropdown opens
cy.get('#statusDropdownMenu').should('exist');
cy.uiGetStatusMenu().within(() => {
// * Correct custom status text and emoji should be displayed in the status dropdown
cy.findByText(customStatus.text).should('exist');
cy.findByLabelText(customStatus.emojiAriaLabel).should('exist');
// * Correct custom status text and emoji should be displayed in the status dropdown
cy.get('.status-dropdown-menu .custom_status__container').should('have.text', customStatus.text);
cy.get('.status-dropdown-menu .custom_status__row span.emoticon').invoke('attr', 'data-emoticon').should('contain', customStatus.emoji);
// * Correct clear time should be displayed in the status dropdown
cy.get('.status-dropdown-menu .custom_status__expiry time').invoke('text').should('match', expiresAtRegexp);
// * Correct clear time should be displayed in the status dropdown
cy.findByText(expiresAtRegexp).should('exist');
});
});
it('MM-T4063_6 custom status should be cleared after duration of set custom status', () => {
@@ -118,8 +125,7 @@ describe('MM-T4063 Custom status expiry', () => {
it('MM-T4063_7 current custom status should display expiry time in custom status modal', () => {
// # Open custom status modal
cy.get('#statusDropdownMenu li#status-menu-custom-status').click();
cy.get('#custom_status_modal').should('exist');
cy.get('.userAccountMenu_customStatusMenuItem').should('be.visible').click();
// * Should show expiry time of status when current status is selected
cy.get('#custom_status_modal .statusSuggestion__content').contains('span', customStatus.text).click();
@@ -132,15 +138,12 @@ describe('MM-T4063 Custom status expiry', () => {
it('MM-T4063_8 previous custom status duration should be reset if custom status is expired', () => {
// # Forwarding the time by the duration of custom status
cy.clock(Date.now());
cy.tick(waitingTime * 60 * 1000);
// # Open custom status modal
cy.get('.status-dropdown-menu').click();
cy.get('#statusDropdownMenu li#status-menu-custom-status').click();
cy.get('#custom_status_modal').should('exist');
// * Should show duration of previous status when previous status is selected
cy.get('#custom_status_modal .statusSuggestion__content').contains('span', customStatus.text).click();
cy.get('#custom_status_modal .expiry-value').should('have.text', customStatus.duration);
cy.tick(waitingTime * 60 * 1000).then(() => {
// # Open custom status modal
cy.uiOpenUserMenu().within(() => {
// * Verify that there is no custom status in the dropdown
cy.findByText('Set custom status').should('exist').and('be.visible');
});
});
});
});

Просмотреть файл

@@ -21,86 +21,30 @@ describe('MM-T4064 Status expiry visibility', () => {
cy.visit(channelUrl);
});
});
const defaultCustomStatuses = ['In a meeting', 'Out for lunch', 'Out sick', 'Working from home', 'On a vacation'];
const customStatus = {
emoji: 'calendar',
text: 'In a meeting',
duration: '1 hour',
};
const waitingTime = 60; //minutes
let expiresAt = dayjs();
const expiryTimeFormat = 'h:mm A';
it('MM-T4064_1 should open status dropdown', () => {
// # Click on the sidebar header to open status dropdown
cy.get('.MenuWrapper .status-wrapper').click();
// * Check if the status dropdown opens
cy.get('#statusDropdownMenu').should('exist');
});
it('MM-T4064_6 should show expiry time in the tooltip of custom status emoji in the post header', () => {
// # Open the user account menu
cy.uiOpenUserMenu('Set custom status');
it('MM-T4064_2 Custom status modal opens with 5 default statuses listed', () => {
// # Open custom status modal
cy.get('#statusDropdownMenu li#status-menu-custom-status').click();
cy.get('#custom_status_modal').should('exist');
// * Verify that the custom status modal opens
cy.findByRole('dialog', {name: 'Set a status'}).should('exist').within(() => {
// # Select a custom status from the suggestions
cy.get('.statusSuggestion__row').first().click();
// * Check if all the default suggestions exist
defaultCustomStatuses.map((statusText) => cy.get('#custom_status_modal .statusSuggestion__content').contains('span', statusText));
});
it('MM-T4064_3 Correct custom status is selected with the correct emoji and correct duration', () => {
// * Default emoji is currently visible in the custom status input
cy.get('#custom_status_modal .StatusModal__emoji-button span').should('have.class', 'icon--emoji');
// * Input should be empty
cy.get('#custom_status_modal input.form-control').should('have.value', '');
// # Select a custom status from the suggestions
cy.get('#custom_status_modal .statusSuggestion__content').contains('span', customStatus.text).click();
// * Emoji in the custom status input should be changed
cy.get('#custom_status_modal .StatusModal__emoji-button span').invoke('attr', 'data-emoticon').should('contain', customStatus.emoji);
// * Selected custom status text should be in the input
cy.get('#custom_status_modal input.form-control').should('have.value', customStatus.text);
// * Selected custom status duration should be displayed in the Clear after section
cy.get('#custom_status_modal .expiry-wrapper .expiry-value').should('have.text', customStatus.duration);
});
it('MM-T4064_4 should set custom status when click on Set Status', () => {
// # Click on the Set Status button
cy.get('#custom_status_modal .GenericModal__button.confirm').click();
// # Click on the Set Status button
cy.findByText('Set Status').click();
});
// * Modal should be closed
cy.get('#custom_status_modal').should('not.exist');
// # Setting the time at which the custom status should be expired
// # Note that we need to be flexible around accepted values, as this calculation and the server-side one may differ slightly
expiresAt = dayjs().add(waitingTime, 'minute');
// * Status should be set and the emoji should be visible in the sidebar header
cy.uiGetProfileHeader().
find('.emoticon').
should('have.attr', 'data-emoticon', customStatus.emoji);
});
it('MM-T4064_5 should show the set custom status with expiry when status dropdown is opened', () => {
// # Click on the sidebar header to open status dropdown
cy.get('.MenuWrapper .status-wrapper').click();
// * Check if the status dropdown opens
cy.get('#statusDropdownMenu').should('exist');
// * Correct custom status text and emoji should be displayed in the status dropdown
cy.get('.status-dropdown-menu .custom_status__container').should('have.text', customStatus.text);
cy.get('.status-dropdown-menu .custom_status__row span.emoticon').invoke('attr', 'data-emoticon').should('contain', customStatus.emoji);
// * Correct clear time should be displayed in the status dropdown
cy.get('.status-dropdown-menu .custom_status__expiry time').should('have.text', expiresAt.format(expiryTimeFormat));
});
it('MM-T4064_6 should show expiry time in the tooltip of custom status emoji in the post header', () => {
// # Post a message in the channel
cy.postMessage('Hello World!');

Просмотреть файл

@@ -39,15 +39,15 @@ describe('MM-T4065 Setting manual status clear time less than 7 days away', () =
it('MM-T4065_1 should open status dropdown', () => {
// # Click on the sidebar header to open status dropdown
cy.get('.MenuWrapper .status-wrapper').click();
cy.uiGetSetStatusButton().click();
// * Check if the status dropdown opens
cy.get('#statusDropdownMenu').should('exist');
cy.get('#userAccountMenu').should('exist');
});
it('MM-T4065_2 Custom status modal opens with 5 default statuses listed', () => {
// # Open custom status modal
cy.get('#statusDropdownMenu li#status-menu-custom-status').click();
cy.get('.userAccountMenu_setCustomStatusMenuItem').click();
cy.get('#custom_status_modal').should('exist');
// * Check if all the default suggestions exist

Просмотреть файл

@@ -38,15 +38,15 @@ describe('MM-T4066 Setting manual status clear time more than 7 days away', () =
const months = dateToBeSelected.get('month') - today.get('month');
it('MM-T4066_1 should open status dropdown', () => {
// # Click on the sidebar header to open status dropdown
cy.get('.MenuWrapper .status-wrapper').click();
cy.uiGetSetStatusButton().click();
// * Check if the status dropdown opens
cy.get('#statusDropdownMenu').should('exist');
cy.get('#userAccountMenu').should('exist');
});
it('MM-T4066_2 Custom status modal opens with 5 default statuses listed', () => {
// # Open custom status modal
cy.get('#statusDropdownMenu li#status-menu-custom-status').click();
cy.get('.userAccountMenu_setCustomStatusMenuItem').click();
cy.get('#custom_status_modal').should('exist');
// * Check if all the default suggestions exist

Просмотреть файл

@@ -12,10 +12,10 @@
describe('Integrations', () => {
const testCases = [
{command: '/away', className: 'icon-clock', message: 'You are now away'},
{command: '/dnd', className: 'icon-minus-circle', message: 'Do Not Disturb is enabled. You will not receive desktop or mobile push notifications until Do Not Disturb is turned off.'},
{command: '/offline', className: 'icon-circle-outline', message: 'You are now offline'},
{command: '/online', className: 'icon-check-circle', message: 'You are now online'},
{command: '/away', className: 'userAccountMenu_awayMenuItem_icon', message: 'You are now away'},
{command: '/dnd', className: 'userAccountMenu_dndMenuItem_icon', message: 'Do Not Disturb is enabled. You will not receive desktop or mobile push notifications until Do Not Disturb is turned off.'},
{command: '/offline', className: 'userAccountMenu_offlineMenuItem_icon', message: 'You are now offline'},
{command: '/online', className: 'userAccountMenu_onlineMenuItem_icon', message: 'You are now online'},
];
let offTopicUrl;
@@ -67,7 +67,7 @@ describe('Integrations', () => {
function verifyUserStatus(testCase, isCompactMode) {
// * Verify that the user status is as indicated
cy.uiGetProfileHeader().
find('i').
find('svg').
should('be.visible').
and('have.class', testCase.className);

Просмотреть файл

@@ -26,14 +26,14 @@ describe('Integrations', () => {
it('MM-T670 /away', () => {
// # Set online status and verify it's changed as the initial status
setStatus(online.name, online.profileClassName);
setStatus(online.name);
verifyUserStatus(away);
});
it('MM-T672 /offline', () => {
// # Set online status and verify it's changed as the initial status
setStatus(online.name, online.profileClassName);
setStatus(online.name);
verifyUserStatus(offline);
@@ -60,17 +60,14 @@ describe('Integrations', () => {
it('MM-T674 /online', () => {
// # Set offline status and verify it's changed as the initial status
setStatus(offline.name, offline.className);
setStatus(offline.name);
verifyUserStatus(online);
});
});
function setStatus(status, icon) {
function setStatus(status) {
cy.apiUpdateUserStatus(status);
cy.uiGetProfileHeader().
find('i').
and('have.class', icon);
}
function verifyUserStatus(testCase) {
@@ -90,9 +87,7 @@ function verifyUserStatus(testCase) {
});
// * Verify status shown at user profile in LHS
cy.uiGetProfileHeader().
find('i').
and('have.class', testCase.profileClassName || testCase.className);
cy.get('#userAccountMenuButtonDescribedBy').should('exist').and('include.text', `Status is "${testCase.name.charAt(0).toUpperCase() + testCase.name.slice(1)}"`);
// # Post a message
cy.postMessage(testCase.name);

Просмотреть файл

@@ -33,10 +33,9 @@ describe('Main menu', () => {
cy.apiLogin(testUser);
cy.visit(`/${testTeam.name}/channels/town-square`);
cy.uiOpenUserMenu();
cy.findByRole('menu').should('exist').find('.menu-divider:visible').last().click();
cy.findByRole('menu').should('exist');
cy.uiOpenUserMenu().within(() => {
cy.findAllByRole('separator').last().click();
});
});
it('should show integrations option for system administrator', () => {

Просмотреть файл

@@ -10,15 +10,14 @@
// Stage: @prod
// Group: @channels @menu @custom_status @status_menu
import * as TIMEOUTS from '../../../fixtures/timeouts';
import theme from '../../../fixtures/theme.json';
describe('Status dropdown menu', () => {
const statusTestCases = [
{text: 'Online', className: 'icon-check-circle', profileClassName: 'icon-check-circle'},
{text: 'Away', className: 'icon-clock'},
{text: 'Do not disturb', className: 'icon-minus-circle'},
{text: 'Offline', className: 'icon-circle-outline'},
{text: 'Online', className: 'userAccountMenu_onlineMenuItem_icon'},
{text: 'Away', className: 'userAccountMenu_awayMenuItem_icon'},
{text: 'Do not disturb', className: 'userAccountMenu_dndMenuItem_icon'},
{text: 'Offline', className: 'userAccountMenu_offlineMenuItem_icon'},
];
before(() => {
@@ -40,9 +39,10 @@ describe('Status dropdown menu', () => {
// * Verify all available statuses are shown with icon and text
statusTestCases.forEach((tc) => {
cy.get('@userMenu').findByText(tc.text).
parent().
find('i').should('have.class', tc.className);
cy.uiGetStatusMenu().within(() => {
cy.findByText(tc.text).should('be.visible');
cy.get('svg').filter(`.${tc.className}`).should('have.length', 1);
});
});
});
@@ -67,7 +67,7 @@ describe('Status dropdown menu', () => {
cy.uiOpenUserMenu().as('userMenu');
// * Verify "Set a Custom Status" header is clickable
cy.get('@userMenu').findByText('Set a custom status').should('have.css', 'cursor', 'pointer');
cy.get('@userMenu').findByText('Set custom status').should('have.css', 'cursor', 'pointer');
});
it('MM-T2927_5 When custom status is disabled, status menu is displayed when status icon is clicked', () => {
@@ -81,22 +81,12 @@ describe('Status dropdown menu', () => {
cy.uiOpenUserMenu();
});
it('MM-T4420 Should stay open when dnd sub-menu header is clicked', () => {
// # Open Dnd sub menu and click on header
cy.uiOpenDndStatusSubMenu().find('#dndSubMenu-header_menuitem').click().then(() => {
cy.wait(TIMEOUTS.HALF_SEC);
// * Verify that dnd submenu is still visible
cy.get('body').find('#dndSubMenu-header_menuitem').should('be.visible');
});
});
it('MM-T4914 Profile menu header is clickable, opens Profile settings', () => {
// # Open user menu
cy.uiOpenUserMenu().as('userMenu');
// * Verify menu header is clickable
cy.get('@userMenu').get('.MenuHeader').should('have.css', 'cursor', 'pointer').click();
cy.uiOpenUserMenu().within(() => {
// * Verify menu header is clickable
cy.get('li').first().should('have.css', 'cursor', 'pointer').click();
});
// * Verify click on header opens Profile settings modal
cy.findByRole('dialog', {name: 'Profile'}).should('be.visible');
@@ -111,14 +101,14 @@ function stepThroughStatuses(statusTestCases = []) {
statusTestCases.forEach((tc) => {
// # Open user menu and click option
if (tc.text === 'Do not disturb') {
cy.uiOpenDndStatusSubMenu().find('#dndTime-thirty_minutes_menuitem').click();
cy.uiOpenDndStatusSubMenuAndClick30Mins();
} else {
cy.uiOpenUserMenu(tc.text);
}
// # Verify correct status icon is shown on user's profile picture
cy.uiGetProfileHeader().
find('i').
and('have.class', tc.profileClassName || tc.className);
cy.uiGetSetStatusButton().within(() => {
cy.get('svg').filter(`.${tc.className}`).should('have.length', 1);
});
});
}

Просмотреть файл

@@ -78,7 +78,7 @@ describe('Notifications', () => {
cy.uiOpenUserMenu().findByText(`@${firstUser.username}`);
// # Close the user menu
cy.uiGetSetStatusButton().click();
cy.get('body').type('{esc}');
// * Check that 'Town Square' is currently being selected
cy.get('.active').within(() => {

Просмотреть файл

@@ -71,7 +71,7 @@ describe('Profile Settings', () => {
fileTypes.forEach((fileType, index) => {
it(`MM-T2078_${index + 1} Profile picture: file ${fileType.extension} type accepted`, () => {
// # Save the default profile picture link so it can be compared to the new one
cy.uiGetProfileHeader().findByRole('img').invoke('attr', 'src').as('defaultProfilePictureLink');
cy.uiGetSetStatusButton().get('img').invoke('attr', 'src').as('defaultProfilePictureLink');
cy.uiOpenProfileModal('Profile Settings');
@@ -95,7 +95,7 @@ describe('Profile Settings', () => {
cy.uiClose();
// # Save the new custom profile picture link so it can be compared to the old one
cy.uiGetProfileHeader().findByRole('img').invoke('attr', 'src').as('customProfilePictureLink');
cy.uiGetSetStatusButton().get('img').invoke('attr', 'src').as('customProfilePictureLink');
cy.then(function() {
expect(this.customProfilePictureLink).to.not.equal(this.defaultProfilePictureLink);

Просмотреть файл

@@ -92,18 +92,18 @@ describe('Custom Theme - Sidebar Styles', () => {
cy.uiOpenUserMenu('Online');
// * Check Online Indicator color
cy.get('.icon-check-circle').should('have.css', 'color', rgbArrayToString(themeRgbColor.onlineIndicator));
cy.uiGetSetStatusButton().find('svg').should('have.css', 'color', rgbArrayToString(themeRgbColor.onlineIndicator));
// # Set user status to away
cy.uiOpenUserMenu('Away');
// * Check Away Indicator color
cy.get('.icon-clock').should('have.css', 'color', rgbArrayToString(themeRgbColor.awayIndicator));
cy.uiGetSetStatusButton().find('svg').should('have.css', 'color', rgbArrayToString(themeRgbColor.awayIndicator));
// # Set user status to do not disturb
cy.uiOpenDndStatusSubMenu().find('#dndTime-thirty_minutes_menuitem').click();
cy.uiOpenDndStatusSubMenuAndClick30Mins();
// * Check Do Not Disturb Indicator color
cy.get('.icon-minus-circle').should('have.css', 'color', rgbArrayToString(themeRgbColor.dndIndicator));
cy.uiGetSetStatusButton().find('svg').should('have.css', 'color', rgbArrayToString(themeRgbColor.dndIndicator));
});
});

Просмотреть файл

@@ -42,7 +42,7 @@ describe('SignIn Authentication', () => {
cy.url().should('include', '/channels/town-square');
// # Click logout via user menu
cy.uiOpenUserMenu('Log Out');
cy.uiOpenUserMenu('Log out');
// * Check that it logout successfully and it redirects into the login page
cy.url().should('include', '/login');

Просмотреть файл

@@ -186,7 +186,7 @@ describe('Login page with open server', () => {
cy.url().should('include', '/channels/town-square');
// # Click logout via user menu
cy.uiOpenUserMenu('Log Out');
cy.uiOpenUserMenu('Log out');
// * Check that it logout successfully and it redirects into the login page
cy.url().should('include', '/login');
@@ -209,7 +209,7 @@ describe('Login page with open server', () => {
cy.url().should('include', '/channels/town-square');
// # Click logout via user menu
cy.uiOpenUserMenu('Log Out');
cy.uiOpenUserMenu('Log out');
// * Check that it logout successfully and it redirects into the login page
cy.url().should('include', '/login');

Просмотреть файл

@@ -92,12 +92,12 @@ describe('Status of current user', () => {
});
function verifyStatus(status: 'online' | 'away' | 'offline' | 'dnd') {
cy.get('[aria-label="Current status: Online. Select to open profile and status menu."]').
cy.get('[aria-label="Status is \\"Online\\". Open user\'s account menu."]').
should(status === 'online' ? 'exist' : 'not.exist');
cy.get('[aria-label="Current status: Away. Select to open profile and status menu."]').
cy.get('[aria-label="Status is \\"Away\\". Open user\'s account menu."]').
should(status === 'away' ? 'exist' : 'not.exist');
cy.get('[aria-label="Current status: Offline. Select to open profile and status menu."]').
cy.get('[aria-label="Status is \\"Offline\\". Open user\'s account menu."]').
should(status === 'offline' ? 'exist' : 'not.exist');
cy.get('[aria-label="Current status: Do not disturb. Select to open profile and status menu."]').
cy.get('[aria-label="Status is \\"Do not disturb\\". Open user\'s account menu."]').
should(status === 'dnd' ? 'exist' : 'not.exist');
}

Просмотреть файл

@@ -53,7 +53,7 @@ Cypress.Commands.add('checkLeftSideBar', (settings = {}) => {
cy.uiOpenUserMenu().findByText(`@${settings.user.username}`);
// # Close status menu
cy.uiGetSetStatusButton().click();
cy.get('body').type('{esc}');
}
if (settings.user.userType === 'Admin' || settings.user.isAdmin) {

Просмотреть файл

@@ -27,7 +27,8 @@ Cypress.Commands.add('uiOpenProductMenu', (item = '') => {
});
Cypress.Commands.add('uiGetSetStatusButton', () => {
return cy.findByRole('button', {name: /Select to open profile and status menu\./i}).should('be.visible');
// # Get set status button
return cy.get('#userAccountMenuButton').should('be.visible');
});
Cypress.Commands.add('uiGetProfileHeader', () => {
@@ -36,22 +37,18 @@ Cypress.Commands.add('uiGetProfileHeader', () => {
Cypress.Commands.add('uiGetStatusMenuContainer', (options = {exist: true}) => {
if (options.exist) {
return cy.findByRole('menu').should('exist');
return cy.get('#userAccountMenu').should('exist').and('be.visible');
}
return cy.findByRole('menu').should('not.exist');
return cy.get('#userAccountMenu').should('not.exist');
});
Cypress.Commands.add('uiGetStatusMenu', (options = {visible: true}) => {
if (options.visible) {
return cy.uiGetStatusMenuContainer().
find('ul').
should('be.visible');
return cy.get('#userAccountMenu').should('exist').and('be.visible');
}
return cy.uiGetStatusMenuContainer().
find('ul').
should('not.be.visible');
return cy.get('#userAccountMenu').should('not.exist');
});
Cypress.Commands.add('uiOpenHelpMenu', (item = '') => {
@@ -147,7 +144,7 @@ Cypress.Commands.add('uiOpenSettingsModal', (section = '') => {
Cypress.Commands.add('uiLogout', () => {
// # Click logout via user menu
cy.uiOpenUserMenu('Log Out');
cy.uiOpenUserMenu('Log out');
cy.url().should('include', '/login');
cy.get('.login-body-message').should('be.visible');

Просмотреть файл

@@ -33,14 +33,8 @@ Cypress.Commands.add('uiGetSystemConsoleMainMenu', () => {
return getMenu(SYSTEM_CONSOLE_MAIN_MENU);
});
Cypress.Commands.add('uiOpenDndStatusSubMenu', () => {
cy.uiOpenUserMenu();
Cypress.Commands.add('uiOpenDndStatusSubMenuAndClick30Mins', () => {
cy.uiOpenUserMenu().findByText('Do not disturb').should('be.visible').trigger('mouseover');
// # Wait for status menu to transition in
cy.get('.MenuWrapper.status-dropdown-menu .Menu__content.dropdown-menu').should('be.visible');
// # Hover over Do Not Disturb option
cy.get('.MenuWrapper.status-dropdown-menu .Menu__content.dropdown-menu li#status-menu-dnd_menuitem').trigger('mouseover');
return cy.get('#status-menu-dnd');
cy.findByText('30 mins').should('be.visible').click({force: true});
});

Просмотреть файл

@@ -1,28 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {setStatusDropdown} from 'actions/views/status_dropdown';
import {isStatusDropdownOpen} from 'selectors/views/status_dropdown';
import configureStore from 'store';
describe('status_dropdown view actions', () => {
const initialState = {
views: {
statusDropdown: {
isOpen: false,
},
},
};
it('setStatusDropdown should set the status dropdown open or not', () => {
const store = configureStore(initialState);
store.dispatch(setStatusDropdown(false));
expect(isStatusDropdownOpen(store.getState())).toBe(false);
store.dispatch(setStatusDropdown(true));
expect(isStatusDropdownOpen(store.getState())).toBe(true);
});
});

Просмотреть файл

@@ -1,11 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {ActionTypes} from 'utils/constants';
export function setStatusDropdown(open: boolean) {
return {
type: ActionTypes.STATUS_DROPDOWN_TOGGLE,
open,
};
}

Просмотреть файл

@@ -2,6 +2,7 @@
// See LICENSE.txt for license information.
import React, {memo, useMemo, useRef} from 'react';
import type {MouseEvent, KeyboardEvent} from 'react';
import {useSelector} from 'react-redux';
import {CustomStatusDuration} from '@mattermost/types/users';
@@ -23,7 +24,7 @@ interface Props {
spanStyle?: React.CSSProperties;
emojiStyle?: React.CSSProperties;
userID?: string;
onClick?: () => void;
onClick?: (event: MouseEvent<HTMLSpanElement> | KeyboardEvent<HTMLSpanElement>) => void;
}
function CustomStatusEmoji({

Просмотреть файл

@@ -57,7 +57,8 @@ const ExpiryTime = ({time, timezone, className, showPrefix, withinBrackets}: Pro
id='custom_status.expiry.until'
defaultMessage='Until {time}'
values={{time: ''}}
/>{' '}
/>
{' '}
</>
);

Просмотреть файл

@@ -2,6 +2,7 @@
// See LICENSE.txt for license information.
import React from 'react';
import type {MouseEvent, KeyboardEvent} from 'react';
import {useSelector} from 'react-redux';
import {getEmojiImageUrl} from 'mattermost-redux/utils/emoji_utils';
@@ -14,7 +15,7 @@ interface ComponentProps {
emojiName: string;
size?: number;
emojiStyle?: React.CSSProperties;
onClick?: () => void;
onClick?: (event: MouseEvent<HTMLSpanElement> | KeyboardEvent<HTMLSpanElement>) => void;
}
const RenderEmoji = ({emojiName, emojiStyle, size, onClick}: ComponentProps) => {

Просмотреть файл

@@ -9,12 +9,12 @@ import type {ProductIdentifier} from '@mattermost/types/products';
import {isCurrentUserGuestUser} from 'mattermost-redux/selectors/entities/users';
import StatusDropdown from 'components/status_dropdown';
import {OnboardingTourSteps, OnboardingTourStepsForGuestUsers} from 'components/tours';
import {
CustomizeYourExperienceTour,
useShowOnboardingTutorialStep,
} from 'components/tours/onboarding_tour';
import UserAccountMenu from 'components/user_account_menu';
import Pluggable from 'plugins/pluggable';
import {isChannels} from 'utils/products';
@@ -46,10 +46,6 @@ const StyledCustomizeYourExperienceTour = styled.div`
height: 100%
`;
const StyledStatusDropdown = styled.div`
margin-left: 8px;
`;
export type Props = {
productId?: ProductIdentifier;
}
@@ -87,9 +83,7 @@ const RightControls = ({productId = null}: Props): JSX.Element => {
</>
) : null
}
<StyledStatusDropdown>
<StatusDropdown/>
</StyledStatusDropdown>
<UserAccountMenu/>
</StyledCustomizeYourExperienceTour>
</RightControlsContainer>
);

Просмотреть файл

@@ -1,7 +1,10 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import './menu.scss';
export {Menu as Container} from './menu';
export {SubMenu} from './sub_menu';
export {MenuItem as Item} from './menu_item';
export type {FirstMenuItemProps} from './menu_item';
export {MenuItemSeparator as Separator} from './menu_item_separator';

Просмотреть файл

@@ -0,0 +1,28 @@
@use "utils/variables";
.MuiPopover-root.MuiModal-root.menu_menuStyled {
z-index: variables.$z-index-menu;
& .MuiPaper-root {
min-width: 114px;
max-width: 496px;
max-height: 80vh;
background-color: var(--center-channel-bg);
box-shadow: var(--elevation-4);
}
&.AsSubMenu {
& .MuiPaper-root {
box-shadow: var(--elevation-5);
}
}
}
.menuModal {
& .modal-header {
display: none;
}
& .GenericModal__body {
padding: 0;
}
}

Просмотреть файл

@@ -1,7 +1,10 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import MuiMenu from '@mui/material/Menu';
import MuiMenuList from '@mui/material/MenuList';
import type {PopoverOrigin} from '@mui/material/Popover';
import classNames from 'classnames';
import React, {
useState,
useEffect,
@@ -28,7 +31,8 @@ import Constants, {A11yClassNames} from 'utils/constants';
import {isKeyPressed} from 'utils/keyboard';
import {MenuContext, useMenuContextValue} from './menu_context';
import {MuiMenuStyled} from './menu_styled';
import './menu.scss';
const MENU_OPEN_ANIMATION_DURATION = 150;
const MENU_CLOSE_ANIMATION_DURATION = 100;
@@ -37,6 +41,7 @@ type MenuButtonProps = {
id: string;
dateTestId?: string;
'aria-label'?: string;
'aria-describedby'?: string;
disabled?: boolean;
class?: string;
as?: keyof JSX.IntrinsicElements;
@@ -55,18 +60,15 @@ type MenuProps = {
'aria-label'?: string;
/**
* @warning Make the styling of your components such a way that they dont need this handler
* @warning Make the styling of your components such a way that they don't need this handler
*/
onToggle?: (isOpen: boolean) => void;
onKeyDown?: (event: KeyboardEvent<HTMLDivElement>, forceCloseMenu?: () => void) => void;
width?: string;
}
const defaultAnchorOrigin = {vertical: 'bottom', horizontal: 'left'};
const defaultTransformOrigin = {vertical: 'top', horizontal: 'left'};
type VerticalOrigin = 'top' | 'center' | 'bottom';
type HorizontalOrigin = 'left' | 'center' | 'right';
const defaultAnchorOrigin = {vertical: 'bottom', horizontal: 'left'} as PopoverOrigin;
const defaultTransformOrigin = {vertical: 'top', horizontal: 'left'} as PopoverOrigin;
interface Props {
menuButton: MenuButtonProps;
@@ -76,14 +78,8 @@ interface Props {
// Use MUI Anchor Playgroup to try various anchorOrigin
// and transformOrigin values - https://mui.com/material-ui/react-popover/#anchor-playground
anchorOrigin?: {
vertical: VerticalOrigin;
horizontal: HorizontalOrigin;
};
transformOrigin?: {
vertical: VerticalOrigin;
horizontal: HorizontalOrigin;
};
anchorOrigin?: PopoverOrigin;
transformOrigin?: PopoverOrigin;
}
/**
@@ -107,7 +103,7 @@ export function Menu(props: Props) {
const [disableAutoFocusItem, setDisableAutoFocusItem] = useState(false);
const isMenuOpen = Boolean(anchorElement);
// Callback funtion handler called when menu is closed by escapeKeyDown, backdropClick or tabKeyDown
// Callback function handler called when menu is closed by escapeKeyDown, backdropClick or tabKeyDown
function handleMenuClose(event: MouseEvent<HTMLDivElement>) {
event.preventDefault();
setAnchorElement(null);
@@ -125,9 +121,9 @@ export function Menu(props: Props) {
setAnchorElement(null);
}
// Stop sythetic events from bubbling up to the parent
// Stop synthetic events from bubbling up to the parent
// @see https://github.com/mui/material-ui/issues/32064
function handleMenuClick(e: MouseEvent<HTMLLIElement> | KeyboardEvent<HTMLLIElement>) {
function handleMenuClick(e: MouseEvent<HTMLDivElement> | KeyboardEvent<HTMLDivElement>) {
e.stopPropagation();
}
@@ -193,7 +189,8 @@ export function Menu(props: Props) {
aria-haspopup={true}
aria-expanded={isMenuOpen}
disabled={props.menuButton?.disabled ?? false}
aria-label={props.menuButton?.['aria-label'] ?? ''}
aria-label={props.menuButton?.['aria-label']}
aria-describedby={props.menuButton?.['aria-describedby']}
className={props.menuButton?.class ?? ''}
onClick={handleMenuButtonClick}
onMouseDown={handleMenuButtonMouseDown}
@@ -234,21 +231,23 @@ export function Menu(props: Props) {
<CompassDesignProvider theme={theme}>
{renderMenuButton()}
<MenuContext.Provider value={providerValue}>
<MuiMenuStyled
<MuiMenu
anchorEl={anchorElement}
open={isMenuOpen}
onClose={handleMenuClose}
onClick={handleMenuClick}
onTransitionExited={providerValue.handleClosed}
onKeyDown={handleMenuKeyDown}
className={A11yClassNames.POPUP}
width={props.menu.width}
className={classNames(A11yClassNames.POPUP, 'menu_menuStyled')}
marginThreshold={0}
anchorOrigin={props.anchorOrigin || defaultAnchorOrigin}
transformOrigin={props.transformOrigin || defaultTransformOrigin}
disableAutoFocusItem={disableAutoFocusItem} // This is not anti-pattern, see handleMenuButtonMouseDown
MenuListProps={{
id: props.menu.id,
'aria-label': props.menu?.['aria-label'] ?? '',
style: {
width: props.menu.width || 'inherit',
},
}}
TransitionProps={{
mountOnEnter: true,
@@ -258,9 +257,12 @@ export function Menu(props: Props) {
exit: MENU_CLOSE_ANIMATION_DURATION,
},
}}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error This exists in source code of mui, but its types are missing
onTransitionExited={providerValue.handleClosed}
>
{props.children}
</MuiMenuStyled>
</MuiMenu>
</MenuContext.Provider>
</CompassDesignProvider>
);

Просмотреть файл

@@ -89,6 +89,25 @@ export interface Props extends MuiMenuItemProps {
children?: ReactNode;
}
/**
* The props for the first menu item to be passed in.
* @example
* <Menu.Container>
* <WrapperOfMenuFirstItem/> <-- Container passes the props to the first item
* <Menu.Item/>
* </Menu.Container>
*/
export type FirstMenuItemProps = Omit<
Props,
| 'onClick'
| 'leadingElement'
| 'labels'
| 'trailingElements'
| 'isDestructive'
| 'isLabelsRowLayout'
| 'children'
>;
/**
* To be used as a child of Menu component.
* Checkout Compass's Menu Item(compass.mattermost.com) for terminology, styling and usage guidelines.
@@ -161,7 +180,11 @@ export function MenuItem(props: Props) {
}
// When both primary and secondary labels are passed, we need to apply minor changes to the styling. Check below in styled component for more details.
const hasSecondaryLabel = labels && labels.props && labels.props.children && Children.count(labels.props.children) === 2;
// we count after converting to array as it removes falsy values from labels.props.children
const hasSecondaryLabel = labels &&
labels.props &&
labels.props.children &&
Children.count(Children.toArray(labels.props.children)) === 2;
return (
<MenuItemStyled
@@ -283,7 +306,6 @@ export const MenuItemStyled = styled(MuiMenuItem, {
justifyContent: 'flex-end',
color: isRegular ? 'rgba(var(--center-channel-color-rgb), 0.75)' : 'var(--error-text)',
gap: '4px',
marginInlineStart: '24px',
fontSize: '12px',
lineHeight: '16px',
alignItems: 'center',

Просмотреть файл

@@ -1,32 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import MuiMenu from '@mui/material/Menu';
import type {MenuProps as MuiMenuProps} from '@mui/material/Menu';
import {styled} from '@mui/material/styles';
interface Props extends MuiMenuProps {
asSubMenu?: boolean;
width?: string;
}
/**
* A styled version of the Material-UI Menu component with few overrides.
* @warning This component is meant to be only used inside of the Menu component directory.
*/
export const MuiMenuStyled = styled(MuiMenu, {
shouldForwardProp: (prop) => prop !== 'asSubMenu',
})<Props>(
({asSubMenu, width}) => ({
'& .MuiPaper-root': {
backgroundColor: 'var(--center-channel-bg)',
boxShadow: `${
asSubMenu ? 'var(--elevation-5)' : 'var(--elevation-4)'
}, 0 0 0 1px rgba(var(--center-channel-color-rgb), 0.12) inset`,
minWidth: '114px',
maxWidth: '496px',
maxHeight: '80vh',
width,
},
}),
);

Просмотреть файл

@@ -1,11 +0,0 @@
.menuModal {
& .modal-header {
& > .close {
visibility: hidden;
}
}
& .GenericModal__body {
padding: 0;
}
}

Просмотреть файл

@@ -1,6 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import MuiMenu from '@mui/material/Menu';
import MuiMenuList from '@mui/material/MenuList';
import type {PopoverOrigin} from '@mui/material/Popover';
import React, {
@@ -31,9 +32,6 @@ import {isKeyPressed} from 'utils/keyboard';
import {SubMenuContext, useMenuContextValue} from './menu_context';
import {MenuItem} from './menu_item';
import type {Props as MenuItemProps} from './menu_item';
import {MuiMenuStyled} from './menu_styled';
import './sub_menu.scss';
interface Props {
id: MenuItemProps['id'];
@@ -41,10 +39,12 @@ interface Props {
labels: MenuItemProps['labels'];
trailingElements?: MenuItemProps['trailingElements'];
isDestructive?: MenuItemProps['isDestructive'];
role?: MenuItemProps['role'];
// Menu props
menuId: string;
menuAriaLabel?: string;
menuAriaDescribedBy?: string;
forceOpenOnLeft?: boolean; // Most of the times this is not needed, since submenu position is calculated and placed
children: ReactNode;
@@ -57,8 +57,10 @@ export function SubMenu(props: Props) {
labels,
trailingElements,
isDestructive,
role,
menuId,
menuAriaLabel,
menuAriaDescribedBy,
forceOpenOnLeft,
children,
...rest
@@ -148,6 +150,7 @@ export function SubMenu(props: Props) {
labels,
trailingElements,
isDestructive,
role,
onClick: isMobileView ? handleOnClick : undefined, // OnClicks on parent menuItem of subMenu is only needed in mobile view
};
@@ -163,13 +166,13 @@ export function SubMenu(props: Props) {
onMouseLeave={handleMouseLeave}
onKeyDown={handleKeyDown}
>
<MuiMenuStyled
<MuiMenu
anchorEl={anchorElement}
open={isSubMenuOpen}
asSubMenu={true}
anchorOrigin={originOfAnchorAndTransform.anchorOrigin}
transformOrigin={originOfAnchorAndTransform.transformOrigin}
sx={{pointerEvents: 'none'}}
className='menu_menuStyled AsSubMenu'
>
{/* This component is needed here to re enable pointer events for the submenu items which we had to disable above as */}
{/* pointer turns to default as soon as it leaves the parent menu */}
@@ -178,6 +181,7 @@ export function SubMenu(props: Props) {
id={menuId}
component='ul'
aria-label={menuAriaLabel}
aria-describedby={menuAriaDescribedBy}
className={A11yClassNames.POPUP}
onKeyDown={handleSubMenuKeyDown}
sx={{
@@ -190,7 +194,7 @@ export function SubMenu(props: Props) {
{children}
</SubMenuContext.Provider>
</MuiMenuList>
</MuiMenuStyled>
</MuiMenu>
</MenuItem>
);
}

Просмотреть файл

@@ -43,7 +43,7 @@ export const CompleteYourProfileTour = () => {
</p>
);
const overlayPunchOut = useMeasurePunchouts(['status-drop-down-menu-list'], [], {y: -6, height: 6, x: 0, width: 0});
const overlayPunchOut = useMeasurePunchouts(['userAccountMenu'], [], {y: -6, height: 6, x: 0, width: 0});
const onDismiss = (e: React.MouseEvent) => {
e.stopPropagation();
e.preventDefault();
@@ -64,6 +64,7 @@ export const CompleteYourProfileTour = () => {
singleTip={true}
showOptOut={false}
interactivePunchOut={true}
zIndex={1058}
/>
);
};

Просмотреть файл

@@ -20,7 +20,6 @@ import {
switchToChannels,
} from 'actions/views/onboarding_tasks';
import {setProductMenuSwitcherOpen} from 'actions/views/product_menu';
import {setStatusDropdown} from 'actions/views/status_dropdown';
import {getOnboardingTaskPreferences} from 'selectors/onboarding';
import Channels from 'components/common/svg_images_components/channels_svg';
@@ -40,6 +39,7 @@ import {
} from 'components/tours';
import {ModalIdentifiers, TELEMETRY_CATEGORIES} from 'utils/constants';
import {clickOnMenuButton, MenuButtonIds} from 'utils/keyboard';
import type {GlobalState} from 'types/store';
@@ -239,7 +239,7 @@ export const useHandleOnBoardingTaskTrigger = () => {
break;
}
case OnboardingTasksName.COMPLETE_YOUR_PROFILE: {
dispatch(setStatusDropdown(true));
clickOnMenuButton(MenuButtonIds.userAccountMenu);
dispatch(setShowOnboardingCompleteProfileTour(true));
handleSaveData(taskName, TaskNameMapToSteps[taskName].STARTED, true);
if (inAdminConsole) {

Просмотреть файл

@@ -3,14 +3,15 @@
import React, {useMemo} from 'react';
import {FormattedMessage} from 'react-intl';
import {useDispatch, useSelector} from 'react-redux';
import {useSelector} from 'react-redux';
import {setStatusDropdown} from 'actions/views/status_dropdown';
import {makeGetCustomStatus, showPostHeaderUpdateStatusButton, isCustomStatusEnabled} from 'selectors/views/custom_status';
import CustomStatusEmoji from 'components/custom_status/custom_status_emoji';
import EmojiIcon from 'components/widgets/icons/emoji_icon';
import {clickOnMenuButton, MenuButtonIds} from 'utils/keyboard';
import type {GlobalState} from 'types/store';
interface ComponentProps {
@@ -22,7 +23,6 @@ interface ComponentProps {
const PostHeaderCustomStatus = (props: ComponentProps) => {
const getCustomStatus = useMemo(makeGetCustomStatus, []);
const {userId, isSystemMessage, isBot} = props;
const dispatch = useDispatch();
const userCustomStatus = useSelector((state: GlobalState) => getCustomStatus(state, userId));
const showUpdateStatusButton = useSelector(showPostHeaderUpdateStatusButton);
const customStatusEnabled = useSelector(isCustomStatusEnabled);
@@ -51,7 +51,7 @@ const PostHeaderCustomStatus = (props: ComponentProps) => {
const updateStatus = (event: React.MouseEvent) => {
event.preventDefault();
dispatch(setStatusDropdown(true));
clickOnMenuButton(MenuButtonIds.userAccountMenu);
};
return (

Разница между файлами не показана из-за своего большого размера Загрузить разницу

Просмотреть файл

@@ -1,73 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';
import type {Dispatch} from 'redux';
import {savePreferences} from 'mattermost-redux/actions/preferences';
import {setStatus, unsetCustomStatus} from 'mattermost-redux/actions/users';
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, getDndEndTimeForUserId, getStatusForUserId} from 'mattermost-redux/selectors/entities/users';
import {openModal} from 'actions/views/modals';
import {setStatusDropdown} from 'actions/views/status_dropdown';
import {makeGetCustomStatus, isCustomStatusEnabled, showStatusDropdownPulsatingDot, isCustomStatusExpired} from 'selectors/views/custom_status';
import {isStatusDropdownOpen} from 'selectors/views/status_dropdown';
import {
OnboardingTaskCategory,
OnboardingTasksName,
TaskNameMapToSteps,
} from 'components/onboarding_tasks';
import type {GlobalState} from 'types/store';
import StatusDropdown from './status_dropdown';
function makeMapStateToProps() {
const getCustomStatus = makeGetCustomStatus();
return function mapStateToProps(state: GlobalState) {
const currentUser = getCurrentUser(state);
const userId = currentUser?.id;
const customStatus = getCustomStatus(state, userId);
const isMilitaryTime = getBool(state, Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.USE_MILITARY_TIME, false);
const step = getInt(state, OnboardingTaskCategory, OnboardingTasksName.COMPLETE_YOUR_PROFILE, 0);
const showCompleteYourProfileTour = step === TaskNameMapToSteps[OnboardingTasksName.COMPLETE_YOUR_PROFILE].STARTED;
return {
userId,
profilePicture: Client4.getProfilePictureUrl(userId, currentUser?.last_picture_update),
autoResetPref: get(state, Preferences.CATEGORY_AUTO_RESET_MANUAL_STATUS, userId, ''),
status: getStatusForUserId(state, userId),
customStatus,
currentUser,
isCustomStatusEnabled: isCustomStatusEnabled(state),
isCustomStatusExpired: isCustomStatusExpired(state, customStatus),
isMilitaryTime,
isStatusDropdownOpen: isStatusDropdownOpen(state),
showCustomStatusPulsatingDot: showStatusDropdownPulsatingDot(state),
showCompleteYourProfileTour,
timezone: getCurrentTimezone(state),
dndEndTime: getDndEndTimeForUserId(state, userId),
};
};
}
function mapDispatchToProps(dispatch: Dispatch) {
return {
actions: bindActionCreators({
openModal,
setStatus,
unsetCustomStatus,
setStatusDropdown,
savePreferences,
}, dispatch),
};
}
export default connect(makeMapStateToProps, mapDispatchToProps)(StatusDropdown);

Просмотреть файл

@@ -1,358 +0,0 @@
.logout__icon {
&:hover {
fill: rgba(255, 0, 0, 0.75);
}
}
#statusDropdownMenu {
.icon {
position: relative;
display: inline-flex;
width: 24px;
height: 24px;
justify-content: center;
padding: 0;
margin: 0 8px 0 0;
span {
display: flex;
}
svg {
width: 16px;
height: 16px;
}
}
#status-menu-dnd {
padding: 4px 16px;
.dropdown-menu {
margin: auto;
#dndSubMenu-header {
font-weight: bold;
pointer-events: none;
}
}
.icon {
top: 2px;
}
.SubMenu__icon-right {
padding-top: 5px;
}
}
}
#status-menu-dnd_menuitem {
.SubMenuItem.MenuItem {
flex-grow: 1;
}
.SubMenuItemContainer {
&:hover,
&:focus {
background-color: unset;
}
}
#dndTime-tomorrow .dndTime-tomorrow_timestamp {
&::before {
content: "\00a0";
}
margin-left: auto;
opacity: 0.7;
}
}
button.custom_status__row {
position: relative;
height: auto;
font-size: inherit;
&.flex {
display: flex;
}
&:hover {
background-color: rgba(var(--center-channel-color-rgb), 0.08);
.custom-status-emoji {
fill: rgba(var(--center-channel-color-rgb), 0.75);
}
}
&:active {
background-color: rgba(var(--button-bg-rgb), 0.08);
}
}
.custom_status {
&__container {
display: flex;
padding: 7px 0;
color: rgba(var(--center-channel-color-rgb), 1);
line-height: 20px;
}
&__icon {
margin-right: 12px;
}
&__text {
overflow: hidden;
max-width: 73%;
height: 16px;
line-height: 16px;
text-overflow: ellipsis;
white-space: nowrap;
}
&__expiry {
font-size: 12px;
line-height: 16px;
opacity: 0.7;
&.padded {
display: block;
padding-left: 28px;
}
}
}
#custom_status__clear {
position: absolute;
top: 0;
right: 10px;
width: auto;
padding: 0;
color: rgba(var(--center-channel-color-rgb), 0.52);
&:hover {
background: transparent;
}
}
.custom-status {
&-emoji {
fill: rgba(var(--center-channel-color-rgb), 0.75);
&:hover {
fill: rgba(var(--center-channel-color-rgb), 0.88);
}
}
&-suggestion-emoji > span:first-child {
background-size: 20px;
}
}
.status-dropdown-menu-global-header {
padding: 4px 8px;
border-radius: 4px;
&:hover {
background: var(--sidebar-text-08);
cursor: pointer;
.status {
background-image:
linear-gradient(
0deg,
rgba(var(--global-header-text-rgb), 0.08),
rgba(var(--global-header-text-rgb), 0.08)
);
}
}
&.active {
background: rgba(var(--global-header-text-rgb), 0.16);
.status {
background-image:
linear-gradient(
0deg,
rgba(var(--global-header-text-rgb), 0.16),
rgba(var(--global-header-text-rgb), 0.16)
);
}
}
.Menu__content {
right: -1px;
left: inherit;
width: 264px;
}
.custom_status__row > #custom_status__clear {
top: 4px;
}
.custom_status__container {
align-items: center;
color: rgba(var(--center-channel-color-rgb), 0.75);
line-height: 14px;
}
.username-wrapper {
padding: 0 12px;
> p {
overflow: hidden;
max-width: 150px;
text-overflow: ellipsis;
white-space: nowrap;
&.contrast {
color: rgba(var(--center-channel-color-rgb), 0.75);
}
&.bold {
font-family: 'Metropolis', sans-serif;
font-size: 16px;
font-weight: 600;
}
}
}
.MenuHeader {
height: 50px;
padding: 0 16px;
cursor: pointer;
&:hover {
background-color: rgba(var(--center-channel-color-rgb), 0.1);
}
}
.MenuItem > button,
#status-menu-dnd {
padding: 1px 16px;
.flex {
display: flex;
}
.MenuItem__primary-text,
.grid {
display: grid;
align-items: center;
grid-template-columns: auto 1fr auto auto auto;
.SubMenu__icon-right {
padding-top: 0;
padding-left: 12px;
}
}
> .MenuItem__help-text {
padding-left: 28px;
}
}
#statusDropdownMenu,
#statusDropdownMenu #status-menu-dnd {
.icon {
top: unset;
width: auto;
height: auto;
padding: 0 12px 0 0;
margin: 0;
}
.SubMenu__icon-right {
padding-top: 0;
}
}
.dnd--icon {
vertical-align: middle;
}
.status-wrapper {
display: flex;
height: auto;
align-items: center;
.status {
position: absolute;
top: auto;
right: -4px;
bottom: -4px;
display: inline-flex;
width: 16px;
height: 16px;
align-items: center;
justify-content: center;
background-color: var(--global-header-background);
color: var(--global-header-text);
&:hover {
cursor: pointer;
}
svg {
top: 0;
}
}
}
}
@keyframes pulse1 {
0% {
opacity: 1;
transform: scale(1);
}
80% {
opacity: 0;
transform: scale(2.5);
}
100% {
opacity: 0;
transform: scale(2.5);
}
}
@keyframes pulse2 {
0% {
opacity: 1;
transform: scale(1);
}
30% {
opacity: 1;
transform: scale(1);
}
100% {
opacity: 0;
transform: scale(2.5);
}
}
.status-dropdpwn-menu .status-dropdown-menu__clear-container .input-clear {
position: absolute;
color: rgba(var(--center-channel-color-rgb), 0.64);
opacity: 1;
}
.status-dropdpwn-account-settings {
position: relative;
}
#accountSettings {
position: relative;
}
.account-settings-complete {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
}

Просмотреть файл

@@ -1,194 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {CustomStatusDuration} from '@mattermost/types/users';
import type {UserProfile} from '@mattermost/types/users';
import {fakeDate} from 'tests/helpers/date';
import {shallowWithIntl} from 'tests/helpers/intl-test-helper';
import StatusDropdown from './status_dropdown';
describe('components/StatusDropdown', () => {
let resetFakeDate: () => void;
beforeEach(() => {
resetFakeDate = fakeDate(new Date('2021-11-02T22:48:57Z'));
});
afterEach(() => {
resetFakeDate();
});
const actions = {
openModal: jest.fn(),
setStatus: jest.fn(),
unsetCustomStatus: jest.fn(),
setStatusDropdown: jest.fn(),
savePreferences: jest.fn(),
};
const baseProps = {
actions,
userId: '',
currentUser: {
id: 'user_id',
first_name: 'Nev',
last_name: 'Aa',
} as UserProfile,
userTimezone: {
useAutomaticTimezone: 'true',
automaticTimezone: 'America/New_York',
manualTimezone: '',
},
status: 'away',
isMilitaryTime: false,
isCustomStatusEnabled: false,
isCustomStatusExpired: false,
isStatusDropdownOpen: false,
showCustomStatusPulsatingDot: false,
showCompleteYourProfileTour: false,
};
test('should match snapshot in default state', () => {
const wrapper = shallowWithIntl(
<StatusDropdown {...baseProps}/>,
);
expect(wrapper).toMatchSnapshot();
});
test('should match snapshot with profile picture URL', () => {
const props = {
...baseProps,
profilePicture: 'http://localhost:8065/api/v4/users/jsx5jmdiyjyuzp9rzwfaf5pwjo/image?_=1590519110944',
};
const wrapper = shallowWithIntl(
<StatusDropdown {...props}/>,
);
expect(wrapper).toMatchSnapshot();
});
test('should match snapshot with status dropdown open', () => {
const props = {
...baseProps,
isStatusDropdownOpen: true,
};
const wrapper = shallowWithIntl(
<StatusDropdown {...props}/>,
);
expect(wrapper).toMatchSnapshot();
});
test('should match snapshot with custom status enabled', () => {
const props = {
...baseProps,
isStatusDropdownOpen: true,
isCustomStatusEnabled: true,
};
const wrapper = shallowWithIntl(
<StatusDropdown {...props}/>,
);
expect(wrapper).toMatchSnapshot();
});
test('should match snapshot with custom status pulsating dot enabled', () => {
const props = {
...baseProps,
isStatusDropdownOpen: true,
isCustomStatusEnabled: true,
showCustomStatusPulsatingDot: true,
};
const wrapper = shallowWithIntl(
<StatusDropdown {...props}/>,
);
expect(wrapper).toMatchSnapshot();
});
test('should match snapshot with custom status and expiry', () => {
const customStatus = {
emoji: 'calendar',
text: 'In a meeting',
duration: CustomStatusDuration.TODAY,
expires_at: '2021-05-03T23:59:59.000Z',
};
const props = {
...baseProps,
isStatusDropdownOpen: true,
isCustomStatusEnabled: true,
customStatus,
};
const wrapper = shallowWithIntl(
<StatusDropdown {...props}/>,
);
expect(wrapper).toMatchSnapshot();
});
test('should match snapshot with custom status expired', () => {
const customStatus = {
emoji: 'calendar',
text: 'In a meeting',
duration: CustomStatusDuration.TODAY,
expires_at: '2021-05-03T23:59:59.000Z',
};
const props = {
...baseProps,
isStatusDropdownOpen: true,
isCustomStatusEnabled: true,
isCustomStatusExpired: true,
customStatus,
};
const wrapper = shallowWithIntl(
<StatusDropdown {...props}/>,
);
expect(wrapper).toMatchSnapshot();
});
test('should show clear status button when custom status is set', () => {
const customStatus = {
emoji: 'calendar',
text: 'In a meeting',
duration: CustomStatusDuration.TODAY,
expires_at: '2021-05-03T23:59:59.000Z',
};
const props = {
...baseProps,
isStatusDropdownOpen: true,
isCustomStatusEnabled: true,
isCustomStatusExpired: false,
customStatus,
};
const wrapper = shallowWithIntl(
<StatusDropdown {...props}/>,
);
expect(wrapper.find('.status-dropdown-menu__clear-container').exists()).toBe(true);
expect(wrapper).toMatchSnapshot();
});
test('should not show clear status button when custom status is not set', () => {
const customStatus = undefined;
const props = {
...baseProps,
isStatusDropdownOpen: true,
isCustomStatusEnabled: true,
isCustomStatusExpired: false,
customStatus,
};
const wrapper = shallowWithIntl(
<StatusDropdown {...props}/>,
);
expect(wrapper.find('.status-dropdown-menu__clear-container').exists()).toBe(false);
expect(wrapper).toMatchSnapshot();
});
});

Просмотреть файл

@@ -1,701 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// 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';
import type {IntlShape, MessageDescriptor} from 'react-intl';
import StatusIcon from '@mattermost/compass-components/components/status-icon'; // eslint-disable-line no-restricted-imports
import Text from '@mattermost/compass-components/components/text'; // eslint-disable-line no-restricted-imports
import type {TUserStatus} from '@mattermost/compass-components/shared'; // eslint-disable-line no-restricted-imports
import {AccountOutlineIcon, CancelIcon, CheckIcon, ExitToAppIcon} from '@mattermost/compass-icons/components';
import {PulsatingDot} from '@mattermost/components';
import type {PreferenceType} from '@mattermost/types/preferences';
import {CustomStatusDuration} from '@mattermost/types/users';
import type {UserCustomStatus, UserProfile, UserStatus} from '@mattermost/types/users';
import * as GlobalActions from 'actions/global_actions';
import CustomStatusEmoji from 'components/custom_status/custom_status_emoji';
import CustomStatusModal from 'components/custom_status/custom_status_modal';
import CustomStatusText from 'components/custom_status/custom_status_text';
import ExpiryTime from 'components/custom_status/expiry_time';
import DndCustomTimePicker from 'components/dnd_custom_time_picker_modal';
import {OnboardingTaskCategory, OnboardingTasksName, TaskNameMapToSteps, CompleteYourProfileTour} from 'components/onboarding_tasks';
import ResetStatusModal from 'components/reset_status_modal';
import UserSettingsModal from 'components/user_settings/modal';
import EmojiIcon from 'components/widgets/icons/emoji_icon';
import Menu from 'components/widgets/menu/menu';
import MenuWrapper from 'components/widgets/menu/menu_wrapper';
import Avatar from 'components/widgets/users/avatar/avatar';
import type {TAvatarSizeToken} from 'components/widgets/users/avatar/avatar';
import WithTooltip from 'components/with_tooltip';
import {ModalIdentifiers, UserStatuses} from 'utils/constants';
import {getBrowserTimezone, getCurrentDateTimeForTimezone, getCurrentMomentForTimezone} from 'utils/timezone';
import type {ModalData} from 'types/actions';
import type {Menu as MenuType} from 'types/store/plugins';
import './status_dropdown.scss';
type Props = {
intl: IntlShape;
status?: string;
userId: string;
profilePicture?: string;
autoResetPref?: string;
actions: {
openModal: <P>(modalData: ModalData<P>) => void;
setStatus: (status: UserStatus) => void;
unsetCustomStatus: () => void;
savePreferences: (userId: string, preferences: PreferenceType[]) => void;
setStatusDropdown: (open: boolean) => void;
};
customStatus?: UserCustomStatus;
currentUser: UserProfile;
isCustomStatusEnabled: boolean;
isCustomStatusExpired: boolean;
isMilitaryTime: boolean;
isStatusDropdownOpen: boolean;
showCompleteYourProfileTour: boolean;
showCustomStatusPulsatingDot: boolean;
timezone?: string;
dndEndTime?: number;
}
type State = {
openUp: boolean;
width: number;
isStatusSet: boolean;
};
export const statusDropdownMessages: Record<string, Record<string, MessageDescriptor>> = {
ooo: defineMessages({
name: {
id: 'status_dropdown.set_ooo',
defaultMessage: 'Out of office',
},
extra: {
id: 'status_dropdown.set_ooo.extra',
defaultMessage: 'Automatic Replies are enabled',
},
}),
online: defineMessages({
name: {
id: 'status_dropdown.set_online',
defaultMessage: 'Online',
},
}),
away: defineMessages({
name: {
id: 'status_dropdown.set_away',
defaultMessage: 'Away',
},
}),
dnd: defineMessages({
name: {
id: 'status_dropdown.set_dnd',
defaultMessage: 'Do not disturb',
},
}),
offline: defineMessages({
name: {
id: 'status_dropdown.set_offline',
defaultMessage: 'Offline',
},
}),
};
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: 'Choose date and time'})},
];
static defaultProps = {
userId: '',
profilePicture: '',
};
constructor(props: Props) {
super(props);
this.state = {
openUp: false,
width: 0,
isStatusSet: false,
};
}
openProfileModal = (): void => {
this.props.actions.openModal({
modalId: ModalIdentifiers.USER_SETTINGS,
dialogType: UserSettingsModal,
dialogProps: {isContentProductSettings: false},
});
};
setStatus = (status: string, dndEndTime?: number): void => {
this.props.actions.setStatus({
user_id: this.props.userId,
status,
dnd_end_time: dndEndTime,
});
};
isUserOutOfOffice = (): boolean => {
return this.props.status === UserStatuses.OUT_OF_OFFICE;
};
setOnline = (event: Event): void => {
event.preventDefault();
this.setStatus(UserStatuses.ONLINE);
};
setOffline = (event: Event): void => {
event.preventDefault();
this.setStatus(UserStatuses.OFFLINE);
};
setAway = (event: Event): void => {
event.preventDefault();
this.setStatus(UserStatuses.AWAY);
};
setDnd = (index: number): void => {
const currentDate = getCurrentMomentForTimezone(this.props.timezone);
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 2:
// add 1 hour in current time
endTime = currentDate.add(1, 'hour');
break;
case 3:
// add 2 hours in current time
endTime = currentDate.add(2, 'hours');
break;
case 4:
// set to next day 9 in the morning
endTime = currentDate.add(1, 'day').set({hour: 9, minute: 0});
break;
}
this.setStatus(UserStatuses.DND, endTime.utc().unix());
};
setCustomTimedDnd = (): void => {
const dndCustomTimePicker = {
modalId: ModalIdentifiers.DND_CUSTOM_TIME_PICKER,
dialogType: DndCustomTimePicker,
dialogProps: {
currentDate: this.props.timezone ? getCurrentDateTimeForTimezone(this.props.timezone) : new Date(),
},
};
this.props.actions.openModal(dndCustomTimePicker);
};
showStatusChangeConfirmation = (status: string): void => {
const resetStatusModalData = {
modalId: ModalIdentifiers.RESET_STATUS,
dialogType: ResetStatusModal,
dialogProps: {newStatus: status},
};
this.props.actions.openModal(resetStatusModalData);
};
renderProfilePicture = (size: TAvatarSizeToken): ReactNode => {
if (!this.props.profilePicture) {
return null;
}
return (
<Avatar
size={size}
url={this.props.profilePicture}
tabIndex={undefined}
/>
);
};
handleClearStatus = (e: React.MouseEvent<HTMLButtonElement> | React.MouseEvent<HTMLDivElement> | React.TouchEvent): void => {
e.stopPropagation();
e.preventDefault();
this.props.actions.unsetCustomStatus();
};
handleEmitUserLoggedOutEvent = (): void => {
GlobalActions.emitUserLoggedOutEvent();
};
onToggle = (open: boolean): void => {
this.props.actions.setStatusDropdown(open);
};
handleCompleteYourProfileTask = (): void => {
const taskName = OnboardingTasksName.COMPLETE_YOUR_PROFILE;
const steps = TaskNameMapToSteps[taskName];
const currentUserId = this.props.currentUser.id;
const preferences = [
{
user_id: currentUserId,
category: OnboardingTaskCategory,
name: taskName,
value: steps.FINISHED.toString(),
},
];
this.props.actions.savePreferences(currentUserId, preferences);
};
handleCustomStatusEmojiClick = (event: React.MouseEvent): void => {
event.stopPropagation();
const customStatusInputModalData = {
modalId: ModalIdentifiers.CUSTOM_STATUS,
dialogType: CustomStatusModal,
};
this.props.actions.openModal(customStatusInputModalData);
};
renderCustomStatus = (isStatusSet: boolean | undefined): ReactNode => {
if (!this.props.isCustomStatusEnabled) {
return null;
}
const {customStatus} = this.props;
let customStatusText;
let customStatusHelpText;
switch (true) {
case isStatusSet && customStatus?.text && customStatus.text.length > 0:
customStatusText = customStatus?.text;
break;
case isStatusSet && !customStatus?.text && customStatus?.duration === CustomStatusDuration.DONT_CLEAR:
customStatusHelpText = this.props.intl.formatMessage({id: 'status_dropdown.set_custom_text', defaultMessage: 'Set custom status text...'});
break;
case isStatusSet && !customStatus?.text && customStatus?.duration !== CustomStatusDuration.DONT_CLEAR:
customStatusText = '';
break;
case !isStatusSet:
customStatusHelpText = this.props.intl.formatMessage({id: 'status_dropdown.set_custom', defaultMessage: 'Set a custom status'});
}
const customStatusEmoji = isStatusSet ? (
<span className='d-flex'>
<CustomStatusEmoji
showTooltip={false}
emojiStyle={{marginLeft: 0}}
/>
</span>
) : (
<EmojiIcon className={'custom-status-emoji'}/>
);
const pulsatingDot = !isStatusSet && this.props.showCustomStatusPulsatingDot && (
<PulsatingDot/>
);
const clearableTooltipText = (
<FormattedMessage
id={'input.clear'}
defaultMessage='Clear'
/>
);
const clearButton = isStatusSet && !pulsatingDot && (
<div
className={classNames('status-dropdown-menu__clear-container', 'input-clear visible')}
onClick={this.handleClearStatus}
onTouchEnd={this.handleClearStatus}
>
<WithTooltip
title={clearableTooltipText}
>
<span
className='input-clear-x'
aria-hidden='true'
>
<i className='icon icon-close-circle'/>
</span>
</WithTooltip>
</div>
);
const expiryTime = isStatusSet && customStatus?.expires_at && customStatus.duration !== CustomStatusDuration.DONT_CLEAR &&
(
<ExpiryTime
time={customStatus.expires_at}
timezone={this.props.timezone}
className={classNames('custom_status__expiry', {
padded: customStatus?.text?.length > 0,
})}
withinBrackets={true}
/>
);
return (
<Menu.Group>
<Menu.ItemToggleModalRedux
ariaLabel={customStatusText || customStatusHelpText}
modalId={ModalIdentifiers.CUSTOM_STATUS}
dialogType={CustomStatusModal}
className={classNames('MenuItem__primary-text custom_status__row', {
flex: customStatus?.text?.length === 0,
})}
id={'status-menu-custom-status'}
>
<span className='custom_status__container'>
<span className='custom_status__icon'>
{customStatusEmoji}
</span>
<CustomStatusText
text={customStatusText}
className='custom_status__text'
/>
<Text
margin='none'
>
{customStatusHelpText}
</Text>
{clearButton}
{pulsatingDot}
</span>
{expiryTime}
</Menu.ItemToggleModalRedux>
</Menu.Group>
);
};
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, timezone, dndEndTime} = this.props;
const isStatusSet = customStatus && !isCustomStatusExpired && (customStatus.text?.length > 0 || customStatus.emoji?.length > 0);
const setOnline = needsConfirm ? () => this.showStatusChangeConfirmation('online') : this.setOnline;
const setDnd = needsConfirm ? () => this.showStatusChangeConfirmation('dnd') : this.setDnd;
const setAway = needsConfirm ? () => this.showStatusChangeConfirmation('away') : this.setAway;
const setOffline = needsConfirm ? () => this.showStatusChangeConfirmation('offline') : this.setOffline;
const setCustomTimedDnd = needsConfirm ? () => this.showStatusChangeConfirmation('dnd') : this.setCustomTimedDnd;
const selectedIndicator = (
<CheckIcon
size={16}
color={'var(--button-bg)'}
/>
);
const dndSubMenuItems = ([
{
id: 'dndSubMenu-header',
direction: 'right',
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 === 4) {
const tomorrow = getCurrentMomentForTimezone(this.props.timezone).add(1, 'day').set({hour: 9, minute: 0}).toDate();
text = (
<>
{text}
<span className={`dndTime-${id}_timestamp`}>
<FormattedDate
value={tomorrow}
weekday='short'
timeZone={this.props.timezone}
/>
{', '}
<FormattedTime
value={tomorrow}
timeStyle='short'
hour12={!this.props.isMilitaryTime}
timeZone={this.props.timezone}
/>
</span>
</>
);
}
return {
id: `dndTime-${id}`,
direction: 'right',
text,
action:
index === 5 ? () => setCustomTimedDnd() : () => setDnd(index),
};
}),
);
const customStatusComponent = this.renderCustomStatus(isStatusSet);
let menuAriaLabeltext;
switch (this.props.status) {
case UserStatuses.AWAY:
menuAriaLabeltext = intl.formatMessage({
id: 'status_dropdown.profile_button_label.away',
defaultMessage: 'Current status: Away. Select to open profile and status menu.',
});
break;
case UserStatuses.DND:
menuAriaLabeltext = intl.formatMessage({
id: 'status_dropdown.profile_button_label.dnd',
defaultMessage: 'Current status: Do not disturb. Select to open profile and status menu.',
});
break;
case UserStatuses.OFFLINE:
menuAriaLabeltext = intl.formatMessage({
id: 'status_dropdown.profile_button_label.offline',
defaultMessage: 'Current status: Offline. Select to open profile and status menu.',
});
break;
case UserStatuses.ONLINE:
menuAriaLabeltext = intl.formatMessage({
id: 'status_dropdown.profile_button_label.online',
defaultMessage: 'Current status: Online. Select to open profile and status menu.',
});
break;
case UserStatuses.OUT_OF_OFFICE:
menuAriaLabeltext = intl.formatMessage({
id: 'status_dropdown.profile_button_label.ooo',
defaultMessage: 'Current status: Out of office. Select to open profile and status menu.',
});
break;
default:
menuAriaLabeltext = intl.formatMessage({
id: 'status_dropdown.profile_button_label',
defaultMessage: 'Select to open profile and status menu.',
});
}
const dndExtraText = this.renderDndExtraText(dndEndTime, timezone);
return (
<MenuWrapper
onToggle={this.onToggle}
open={this.props.isStatusDropdownOpen}
className={classNames('status-dropdown-menu status-dropdown-menu-global-header', {
active: this.props.isStatusDropdownOpen || isStatusSet,
})}
>
<button
className='status-wrapper style--none'
aria-label={menuAriaLabeltext}
aria-expanded={this.props.isStatusDropdownOpen}
aria-controls='statusDropdownMenu'
>
<CustomStatusEmoji
showTooltip={true}
emojiStyle={{marginRight: '6px'}}
onClick={this.handleCustomStatusEmojiClick as () => void}
/>
{this.renderProfilePicture('sm')}
<div
className='status'
>
<StatusIcon
size={'sm'}
status={(this.props.status || 'offline') as TUserStatus}
/>
</div>
</button>
<Menu
ariaLabel={this.props.intl.formatMessage({id: 'status_dropdown.menuAriaLabel', defaultMessage: 'Set a status'})}
id={'statusDropdownMenu'}
listId={'status-drop-down-menu-list'}
>
{currentUser && (
<Menu.Header onClick={this.openProfileModal}>
{this.renderProfilePicture('lg')}
<div className={'username-wrapper'}>
<Text
className={'bold'}
margin={'none'}
>{`${currentUser.first_name} ${currentUser.last_name}`}</Text>
<Text
margin={'none'}
className={!currentUser.first_name && !currentUser.last_name ? 'bold' : 'contrast'}
color={!currentUser.first_name && !currentUser.last_name ? undefined : 'inherit'}
>
{'@' + currentUser.username}
</Text>
</div>
</Menu.Header>
)}
<Menu.Group>
<Menu.ItemAction
show={this.isUserOutOfOffice()}
onClick={setOnline}
ariaLabel={this.props.intl.formatMessage(statusDropdownMessages.ooo.name)}
text={this.props.intl.formatMessage(statusDropdownMessages.ooo.name)}
icon={(
<CancelIcon
color={'rgba(var(--center-channel-color-rgb), 0.56)'}
/>
)}
extraText={this.props.intl.formatMessage(statusDropdownMessages.ooo.extra)}
rightDecorator={selectedIndicator}
/>
</Menu.Group>
{customStatusComponent}
<Menu.Group>
<Menu.ItemAction
onClick={setOnline}
ariaLabel={this.props.intl.formatMessage(statusDropdownMessages.online.name)}
text={this.props.intl.formatMessage(statusDropdownMessages.online.name)}
icon={(
<StatusIcon
status={'online'}
className={'status-icon'}
/>
)}
rightDecorator={status === 'online' && selectedIndicator}
id={'status-menu-online'}
/>
<Menu.ItemAction
onClick={setAway}
ariaLabel={this.props.intl.formatMessage(statusDropdownMessages.away.name)}
text={this.props.intl.formatMessage(statusDropdownMessages.away.name)}
icon={(
<StatusIcon
status={'away'}
className={'status-icon'}
/>
)}
rightDecorator={status === 'away' && selectedIndicator}
id={'status-menu-away'}
/>
<Menu.ItemSubMenu
subMenu={dndSubMenuItems}
ariaLabel={`${this.props.intl.formatMessage(statusDropdownMessages.dnd.name)}. ${dndExtraText}`}
text={this.props.intl.formatMessage(statusDropdownMessages.dnd.name)}
extraText={dndExtraText}
icon={(
<StatusIcon
status={'dnd'}
className={'status-icon'}
/>
)}
rightDecorator={status === 'dnd' && selectedIndicator}
direction={'left'}
openUp={this.state.openUp}
id={'status-menu-dnd'}
action={() => setDnd(0)}
/>
<Menu.ItemAction
onClick={setOffline}
ariaLabel={this.props.intl.formatMessage(statusDropdownMessages.offline.name)}
text={this.props.intl.formatMessage(statusDropdownMessages.offline.name)}
icon={(
<StatusIcon
status={'offline'}
className={'status-icon'}
/>
)}
rightDecorator={status === 'offline' && selectedIndicator}
id={'status-menu-offline'}
/>
</Menu.Group>
<Menu.Group>
<Menu.ItemToggleModalRedux
id='accountSettings'
ariaLabel='Profile'
modalId={ModalIdentifiers.USER_SETTINGS}
dialogType={UserSettingsModal}
dialogProps={{isContentProductSettings: false}}
text={this.props.intl.formatMessage({id: 'navbar_dropdown.profileSettings', defaultMessage: 'Profile'})}
icon={
<AccountOutlineIcon
size={16}
color={'rgba(var(--center-channel-color-rgb), 0.56)'}
/>
}
>
{this.props.showCompleteYourProfileTour && (
<div
onClick={this.handleCompleteYourProfileTask}
className={'account-settings-complete'}
>
<CompleteYourProfileTour/>
</div>
)}
</Menu.ItemToggleModalRedux>
</Menu.Group>
<Menu.Group>
<span className={'logout__icon'}>
<Menu.ItemAction
id='logout'
onClick={this.handleEmitUserLoggedOutEvent}
text={this.props.intl.formatMessage({id: 'navbar_dropdown.logout', defaultMessage: 'Log Out'})}
icon={
<ExitToAppIcon
size={16}
color={'rgba(var(--center-channel-color-rgb), 0.56)'}
/>
}
/>
</span>
</Menu.Group>
</Menu>
</MenuWrapper>
);
};
}
export default injectIntl(StatusDropdown);

Просмотреть файл

@@ -0,0 +1,32 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`UserAccountNameMenuItem should not break if no props are passed 1`] = `
<div>
<li
aria-haspopup="true"
class="MuiButtonBase-root-JvZdr dKFJFs MuiButtonBase-root MuiMenuItem-root MuiMenuItem-gutters MuiMenuItem-root-dXqYNm kIRdVO MuiMenuItem-root MuiMenuItem-gutters sc-gswNZR cUFZeQ userAccountMenu_nameMenuItem"
role="menuitem"
tabindex="-1"
>
<div
class="leading-element"
>
<img
alt="user profile image"
aria-hidden="true"
class="Avatar Avatar-lg"
loading="lazy"
/>
</div>
<div
class="label-elements"
>
<h2
class="userAccountMenu_nameMenuItem_primaryLabel"
>
@undefined
</h2>
</div>
</li>
</div>
`;

Просмотреть файл

@@ -0,0 +1,61 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {ConnectedProps} from 'react-redux';
import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';
import type {Dispatch} from 'redux';
import {Client4} from 'mattermost-redux/client';
import {Preferences} from 'mattermost-redux/constants';
import {get} from 'mattermost-redux/selectors/entities/preferences';
import {getCurrentTimezone} from 'mattermost-redux/selectors/entities/timezone';
import {getCurrentUser, getStatusForUserId} from 'mattermost-redux/selectors/entities/users';
import {openModal} from 'actions/views/modals';
import {makeGetCustomStatus, isCustomStatusExpired, isCustomStatusEnabled} from 'selectors/views/custom_status';
import type {GlobalState} from 'types/store';
import UserAccountMenu from './user_account_menu';
function makeMapStateToProps() {
const getCustomStatus = makeGetCustomStatus();
return function mapStateToProps(state: GlobalState) {
const currentUser = getCurrentUser(state);
const userId = currentUser?.id;
const username = currentUser?.username;
const userFirstName = currentUser?.first_name;
const userLastName = currentUser?.last_name;
const customStatus = getCustomStatus(state, userId);
return {
userId,
username,
userFirstName,
userLastName,
profilePicture: Client4.getProfilePictureUrl(userId, currentUser?.last_picture_update),
autoResetPref: get(state, Preferences.CATEGORY_AUTO_RESET_MANUAL_STATUS, userId, ''),
status: getStatusForUserId(state, userId),
customStatus,
isCustomStatusExpired: isCustomStatusExpired(state, customStatus),
isCustomStatusEnabled: isCustomStatusEnabled(state),
timezone: getCurrentTimezone(state),
};
};
}
function mapDispatchToProps(dispatch: Dispatch) {
return {
actions: bindActionCreators({
openModal,
}, dispatch),
};
}
const connector = connect(makeMapStateToProps, mapDispatchToProps);
export type PropsFromRedux = ConnectedProps<typeof connector>;
export default connector(UserAccountMenu);

Просмотреть файл

@@ -0,0 +1,80 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useMemo} from 'react';
import {FormattedMessage} from 'react-intl';
import {useDispatch} from 'react-redux';
import {CheckIcon, ClockIcon} from '@mattermost/compass-icons/components';
import type {UserProfile} from '@mattermost/types/users';
import {setStatus} from 'mattermost-redux/actions/users';
import {openModal} from 'actions/views/modals';
import * as Menu from 'components/menu';
import ResetStatusModal from 'components/reset_status_modal';
import {ModalIdentifiers, UserStatuses} from 'utils/constants';
interface Props {
userId: UserProfile['id'];
shouldConfirmBeforeStatusChange: boolean;
isStatusAway: boolean;
}
export default function UserAccountAwayMenuItem(props: Props) {
const dispatch = useDispatch();
function handleClick() {
if (props.shouldConfirmBeforeStatusChange) {
dispatch(openModal({
modalId: ModalIdentifiers.RESET_STATUS,
dialogType: ResetStatusModal,
dialogProps: {
newStatus: UserStatuses.AWAY,
},
}));
} else {
dispatch(setStatus({
user_id: props.userId,
status: UserStatuses.AWAY,
}));
}
}
const trailingElement = useMemo(() => {
if (props.isStatusAway) {
return (
<CheckIcon
size={16}
className='userAccountMenu_menuItemTrailingCheckIcon'
aria-hidden='true'
/>
);
}
return null;
}, [props.isStatusAway]);
return (
<Menu.Item
leadingElement={
<ClockIcon
size='18'
className='userAccountMenu_awayMenuItem_icon'
aria-hidden='true'
/>
}
labels={
<FormattedMessage
id='userAccountMenu.awayMenuItem.label'
defaultMessage='Away'
/>
}
trailingElements={trailingElement}
aria-checked={props.isStatusAway}
onClick={handleClick}
/>
);
}

Просмотреть файл

@@ -0,0 +1,140 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import classNames from 'classnames';
import type {MouseEvent, TouchEvent, KeyboardEvent, ReactNode} from 'react';
import React from 'react';
import {FormattedMessage, useIntl} from 'react-intl';
import {useDispatch, useSelector} from 'react-redux';
import {PulsatingDot} from '@mattermost/components';
import {type UserCustomStatus, CustomStatusDuration} from '@mattermost/types/users';
import {unsetCustomStatus} from 'mattermost-redux/actions/users';
import {showStatusDropdownPulsatingDot} from 'selectors/views/custom_status';
import CustomStatusEmoji from 'components/custom_status/custom_status_emoji';
import CustomStatusText from 'components/custom_status/custom_status_text';
import ExpiryTime from 'components/custom_status/expiry_time';
import * as Menu from 'components/menu';
import WithTooltip from 'components/with_tooltip';
interface Props {
timezone?: string;
customStatus?: UserCustomStatus;
openCustomStatusModal: (event: MouseEvent<HTMLLIElement> | KeyboardEvent<HTMLLIElement>) => void;
}
export default function UserAccountSetCustomStatusMenuItem(props: Props) {
const {formatMessage, formatTime, formatDate} = useIntl();
const dispatch = useDispatch();
const showPulsatingDot = useSelector(showStatusDropdownPulsatingDot);
function handleClear(event: MouseEvent<HTMLElement> | TouchEvent) {
event.stopPropagation();
dispatch(unsetCustomStatus());
}
const hasStatusWithText = Boolean(props.customStatus?.text?.length);
const hasStatusWithExpiry = props.customStatus?.duration !== CustomStatusDuration.DONT_CLEAR && props.customStatus?.expires_at;
const hasStatusWithNoExpiry = props.customStatus?.duration === CustomStatusDuration.DONT_CLEAR;
let label;
let ariaDescription: string;
if (hasStatusWithText && hasStatusWithExpiry) {
label = (
<>
<CustomStatusText
text={props.customStatus?.text}
/>
<ExpiryTime
time={props.customStatus?.expires_at ?? ''}
timezone={props.timezone}
withinBrackets={true}
/>
</>
);
ariaDescription = formatMessage({
id: 'userAccountMenu.setCustomStatusMenuItem.hasStatusWithTextAndExpiry.ariaDescription',
defaultMessage: 'Status is "{text}" and expires at {time}. Set a custom status.',
}, {
text: props.customStatus?.text,
time: `${formatTime(props.customStatus?.expires_at)} ${formatDate(props.customStatus?.expires_at)}`,
});
} else if (hasStatusWithText && hasStatusWithNoExpiry) {
label = (
<CustomStatusText
text={props.customStatus?.text}
/>
);
ariaDescription = formatMessage({
id: 'userAccountMenu.setCustomStatusMenuItem.hasStatusWithTextAndNoExpiry.ariaDescription',
defaultMessage: 'Status is "{text}". Set a custom status.',
}, {
text: props.customStatus?.text,
});
} else if (!hasStatusWithText && hasStatusWithExpiry) {
label = (
<ExpiryTime
time={props.customStatus?.expires_at ?? ''}
timezone={props.timezone}
withinBrackets={true}
/>
);
ariaDescription = formatMessage({
id: 'userAccountMenu.setCustomStatusMenuItem.hasStatusWithExpiryAndNoText.ariaDescription',
defaultMessage: 'Status expires at {time}. Set a custom status.',
}, {
time: `${formatTime(props.customStatus?.expires_at)} ${formatDate(props.customStatus?.expires_at)}`,
});
} else {
label = (
<FormattedMessage
id='userAccountMenu.setCustomStatusMenuItem.noStatusTextSet'
defaultMessage='Set custom status text'
/>
);
ariaDescription = formatMessage({
id: 'userAccountMenu.setCustomStatusMenuItem.noStatusTextSet',
defaultMessage: 'Set custom status text',
});
}
let trailingElement: ReactNode | null = null;
if (showPulsatingDot) {
trailingElement = <PulsatingDot/>;
} else if (hasStatusWithText || hasStatusWithExpiry || hasStatusWithNoExpiry) {
trailingElement = (
<WithTooltip
title={formatMessage({id: 'userAccountMenu.setCustomStatusMenuItem.clearTooltip', defaultMessage: 'Clear custom status'})}
>
<i
className='icon icon-close-circle userAccountMenu_menuItemTrailingIconClear'
aria-hidden='true'
onClick={handleClear}
/>
</WithTooltip>
);
}
return (
<Menu.Item
className={classNames('userAccountMenu_customStatusMenuItem', {
hasTrailingElement: Boolean(trailingElement),
})}
leadingElement={
<CustomStatusEmoji
showTooltip={false}
emojiStyle={{marginLeft: 0}}
emojiSize={16}
/>
}
labels={label}
trailingElements={trailingElement}
aria-description={ariaDescription}
onClick={props.openCustomStatusModal}
/>
);
}

Просмотреть файл

@@ -0,0 +1,305 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import moment from 'moment';
import React, {useMemo} from 'react';
import type {MouseEvent, KeyboardEvent} from 'react';
import {FormattedDate, FormattedMessage, FormattedTime, useIntl} from 'react-intl';
import {useDispatch, useSelector} from 'react-redux';
import {CheckIcon, ChevronRightIcon, MinusCircleIcon} from '@mattermost/compass-icons/components';
import type {UserProfile} from '@mattermost/types/users';
import {setStatus} from 'mattermost-redux/actions/users';
import {getBool} from 'mattermost-redux/selectors/entities/preferences';
import {getDndEndTimeForUserId} from 'mattermost-redux/selectors/entities/users';
import {openModal} from 'actions/views/modals';
import DndCustomTimePicker from 'components/dnd_custom_time_picker_modal';
import * as Menu from 'components/menu';
import ResetStatusModal from 'components/reset_status_modal';
import {ModalIdentifiers, Preferences, UserStatuses} from 'utils/constants';
import {getCurrentMomentForTimezone, getBrowserTimezone, getCurrentDateTimeForTimezone} from 'utils/timezone';
import type {GlobalState} from 'types/store';
interface Props {
userId: UserProfile['id'];
timezone?: string;
shouldConfirmBeforeStatusChange: boolean;
isStatusDnd: boolean;
}
export default function UserAccountDndMenuItem(props: Props) {
const {formatMessage} = useIntl();
const dispatch = useDispatch();
const dndEndTime = useSelector((state: GlobalState) => getDndEndTimeForUserId(state, props.userId));
const isMilitaryTime = useSelector((state: GlobalState) => getBool(state, Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.USE_MILITARY_TIME, false));
const tomorrow9AMDateObject = getCurrentMomentForTimezone(props.timezone).add(1, 'day').set({hour: 9, minute: 0}).toDate();
function openCustomTimePicker() {
if (props.shouldConfirmBeforeStatusChange) {
dispatch(openModal({
modalId: ModalIdentifiers.RESET_STATUS,
dialogType: ResetStatusModal,
dialogProps: {
newStatus: UserStatuses.DND,
},
}));
} else {
dispatch(openModal({
modalId: ModalIdentifiers.DND_CUSTOM_TIME_PICKER,
dialogType: DndCustomTimePicker,
dialogProps: {
currentDate: props.timezone ? getCurrentDateTimeForTimezone(props.timezone) : new Date(),
},
}));
}
}
function handleSubMenuItemClick(event: MouseEvent<HTMLLIElement> | KeyboardEvent<HTMLLIElement>) {
if (props.shouldConfirmBeforeStatusChange) {
dispatch(openModal({
modalId: ModalIdentifiers.RESET_STATUS,
dialogType: ResetStatusModal,
dialogProps: {
newStatus: UserStatuses.DND,
},
}));
return;
}
const {currentTarget: {id}} = event;
const currentDate = getCurrentMomentForTimezone(props.timezone);
let endTime = currentDate;
switch (id) {
case DND_SUB_MENU_ITEMS_IDS.DO_NOT_CLEAR:
endTime = moment(0);
break;
case DND_SUB_MENU_ITEMS_IDS.THIRTY_MINUTES:
// add 30 minutes in current time
endTime = currentDate.add(30, 'minutes');
break;
case DND_SUB_MENU_ITEMS_IDS.ONE_HOUR:
// add 1 hour in current time
endTime = currentDate.add(1, 'hour');
break;
case DND_SUB_MENU_ITEMS_IDS.TWO_HOURS:
// add 2 hours in current time
endTime = currentDate.add(2, 'hours');
break;
case DND_SUB_MENU_ITEMS_IDS.TOMORROW:
// set to next day 9 in the morning
endTime = currentDate.add(1, 'day').set({hour: 9, minute: 0});
break;
}
dispatch(setStatus({
user_id: props.userId,
status: UserStatuses.DND,
dnd_end_time: endTime.utc().unix(),
}));
}
const trailingElement = useMemo(() => {
if (props.isStatusDnd) {
return (
<>
<CheckIcon
size={16}
className='userAccountMenu_menuItemTrailingCheckIcon'
/>
<ChevronRightIcon size={16}/>
</>
);
}
return (
<ChevronRightIcon size={16}/>
);
}, [props.isStatusDnd]);
// This function has dual purpose, first it returns the secondary label for the menu item and
// second it returns the part of the aria label for the menu item.
function getSecondaryLabel(isStatusDnd: boolean, dndEndTime?: number, timezone?: string) {
if (!isStatusDnd) {
return formatMessage({
id: 'userAccountMenu.dndMenuItem.secondaryLabel',
defaultMessage: 'Disables all notifications',
});
}
// When DND is set to not clear
if (!dndEndTime || dndEndTime === 0) {
return formatMessage({
id: 'userAccountMenu.dndMenuItem.secondaryLabel.doNotClear',
defaultMessage: 'Until indefinitely',
});
}
const tz = timezone || getBrowserTimezone();
const currentTime = moment().tz(tz);
const endTime = moment.unix(dndEndTime).tz(tz);
const diffDays = endTime.clone().startOf('day').diff(currentTime.clone().startOf('day'), 'days');
if (diffDays === 0) {
return formatMessage({
id: 'userAccountMenu.dndMenuItem.secondaryLabel.untilTodaySomeTime',
defaultMessage: 'Until {time}',
}, {
time: endTime.format('h:mm A'),
});
} else if (diffDays === 1) {
return formatMessage({
id: 'userAccountMenu.dndMenuItem.secondaryLabel.untilTomorrowSomeTime',
defaultMessage: 'Until tomorrow {time}',
}, {
time: endTime.format('h:mm A'),
});
}
return formatMessage({
id: 'userAccountMenu.dndMenuItem.secondaryLabel.untilLaterSomeTime',
defaultMessage: 'Until {time}',
}, {
time: endTime.format('lll'),
});
}
return (
<Menu.SubMenu
id='userAccountMenu.dndMenuItem'
menuId='userAccountMenu.dndSubMenu'
menuAriaDescribedBy='userAccountMenu_dndSubMenuTitle'
leadingElement={
<MinusCircleIcon
size='18'
className='userAccountMenu_dndMenuItem_icon'
/>
}
labels={
<>
<FormattedMessage
id='userAccountMenu.dndMenuItem.primaryLabel'
defaultMessage='Do not disturb'
/>
<span>
{getSecondaryLabel(props.isStatusDnd, dndEndTime, props.timezone)}
</span>
</>
}
role='menuitemradio' // Prevents menu item from closing, not a recommended solution
aria-checked={props.isStatusDnd}
trailingElements={trailingElement}
>
<h5
id='userAccountMenu_dndSubMenuTitle'
className='userAccountMenu_dndMenuItem_subMenuTitle'
aria-hidden={true}
>
{formatMessage({
id: 'userAccountMenu.dndSubMenu.title',
defaultMessage: 'Clear after:',
})}
</h5>
<Menu.Item
id={DND_SUB_MENU_ITEMS_IDS.DO_NOT_CLEAR}
labels={
<FormattedMessage
id='userAccountMenu.dndSubMenuItem.doNotClear'
defaultMessage="Don't clear"
/>
}
onClick={handleSubMenuItemClick}
/>
<Menu.Item
id={DND_SUB_MENU_ITEMS_IDS.THIRTY_MINUTES}
labels={
<FormattedMessage
id='userAccountMenu.dndSubMenuItem.30Minutes'
defaultMessage='30 mins'
/>
}
onClick={handleSubMenuItemClick}
/>
<Menu.Item
id={DND_SUB_MENU_ITEMS_IDS.ONE_HOUR}
labels={
<FormattedMessage
id='userAccountMenu.dndSubMenuItem.1Hour'
defaultMessage='1 hour'
/>
}
onClick={handleSubMenuItemClick}
/>
<Menu.Item
id={DND_SUB_MENU_ITEMS_IDS.TWO_HOURS}
labels={
<FormattedMessage
id='userAccountMenu.dndSubMenuItem.2Hours'
defaultMessage='2 hours'
/>
}
onClick={handleSubMenuItemClick}
/>
<Menu.Item
id={DND_SUB_MENU_ITEMS_IDS.TOMORROW}
labels={
<FormattedMessage
id='userAccountMenu.dndSubMenuItem.tomorrow'
defaultMessage='Tomorrow'
/>
}
trailingElements={
<FormattedMessage
id='userAccountMenu.dndSubMenuItem.tomorrowsDateTime'
defaultMessage='{shortDay}, {shortTime}'
values={{
shortDay: (
<FormattedDate
value={tomorrow9AMDateObject}
weekday='short'
timeZone={props.timezone}
/>
),
shortTime: (
<FormattedTime
value={tomorrow9AMDateObject}
timeStyle='short'
hour12={!isMilitaryTime}
timeZone={props.timezone}
/>
),
}}
/>
}
onClick={handleSubMenuItemClick}
/>
<Menu.Item
labels={
<FormattedMessage
id='userAccountMenu.dndSubMenuItem.custom'
defaultMessage='Choose date and time'
/>
}
onClick={openCustomTimePicker}
/>
</Menu.SubMenu>
);
}
const DND_SUB_MENU_ITEMS_IDS = {
DO_NOT_CLEAR: 'userAccountMenu.dndSubMenuItem.doNotClear',
THIRTY_MINUTES: 'userAccountMenu.dndSubMenuItem.30Minutes',
ONE_HOUR: 'userAccountMenu.dndSubMenuItem.1Hour',
TWO_HOURS: 'userAccountMenu.dndSubMenuItem.2Hours',
TOMORROW: 'userAccountMenu.dndSubMenuItem.tomorrow',
};

Просмотреть файл

@@ -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';
import {ExitToAppIcon} from '@mattermost/compass-icons/components';
import {emitUserLoggedOutEvent} from 'actions/global_actions';
import * as Menu from 'components/menu';
export default function UserAccountLogoutMenuItem() {
function handleClick() {
emitUserLoggedOutEvent();
}
return (
<Menu.Item
leadingElement={
<ExitToAppIcon
size={18}
aria-hidden='true'
/>
}
labels={
<FormattedMessage
id='userAccountMenu.logoutMenuItem.label'
defaultMessage='Log out'
/>
}
onClick={handleClick}
/>
);
}

Просмотреть файл

@@ -0,0 +1,172 @@
@import "utils/_mixins";
#userAccountMenu {
.userAccountMenu_nameMenuItem {
align-items: center;
.leading-element {
width: unset;
height: unset;
margin-inline-end: 12px;
}
.label-elements {
gap: 0;
}
span.userAccountMenu_nameMenuItem_primaryLabel {
max-width: 150px;
margin: 0;
font-family: 'Metropolis', sans-serif;
font-size: 16px;
font-weight: 600;
line-height: 22px;
@include textEllipsis;
}
span.userAccountMenu_nameMenuItem_secondaryLabel {
max-width: 150px;
font-size: 14px;
line-height: 18px;
@include textEllipsis;
}
}
.userAccountMenu_setCustomStatusMenuItem {
.userAccountMenu_setCustomStatusMenuItem_icon > svg {
fill: rgba(var(--center-channel-color-rgb), 0.75);
}
// This aligns the icon in the center, emoji icon is wrapped in a span
// thats why we need to align the span as well
> .leading-element, .userAccountMenu_setCustomStatusMenuItem_icon {
display: flex;
align-items: center;
}
.label-elements > span {
color: rgba(var(--center-channel-color-rgb), 0.75);
}
}
.userAccountMenu_customStatusMenuItem {
&.hasTrailingElement {
.label-elements {
> span {
max-width: 156px;
}
}
}
.label-elements {
> span {
max-width: 196px;
@include textEllipsis;
}
}
}
.userAccountMenu_menuItemTrailingCheckIcon {
color: var(--button-bg);
font-size: 16px;
}
.userAccountMenu_menuItemTrailingIconClear {
font-size: 16px;
}
.userAccountMenu_profileMenuItem_tourWrapper {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
}
}
.userAccountMenu_dndMenuItem_subMenuTitle {
padding: 6px 20px;
margin: 0;
color: var(--center-channel-color);
font-weight: bold;
pointer-events: none;
}
.userAccountMenu_menuButton {
position: relative;
display: flex;
height: auto;
align-items: center;
align-self: center;
padding: 4px 8px;
border: none;
border-radius: 4px;
background: transparent;
margin-inline-start: 8px;
&:hover, &[aria-expanded="true"] {
background: var(--sidebar-text-08);
.userStatusIconWrapper {
background-image: linear-gradient(
0deg,
rgba(var(--global-header-text-rgb), 0.08),
rgba(var(--global-header-text-rgb), 0.08)
);
}
}
&.withCustomStatus {
background: rgba(var(--global-header-text-rgb), 0.16);
.userStatusIconWrapper {
background-image: linear-gradient(
0deg,
rgba(var(--global-header-text-rgb), 0.16),
rgba(var(--global-header-text-rgb), 0.16)
);
}
}
.userStatusIconWrapper {
position: absolute;
top: auto;
right: 3px;
bottom: 1px;
display: inline-flex;
width: 16px;
height: 16px;
align-items: center;
justify-content: center;
border-radius: 100px;
margin: 0;
background-color: var(--global-header-background);
color: var(--global-header-text);
line-height: 0;
visibility: visible;
&:hover {
cursor: pointer;
}
svg {
top: 0;
}
}
}
.userAccountMenu_onlineMenuItem_icon {
color: var(--online-indicator);
}
.userAccountMenu_awayMenuItem_icon {
color: var(--away-indicator);
}
.userAccountMenu_dndMenuItem_icon {
color: var(--dnd-indicator);
}
.userAccountMenu_offlineMenuItem_icon {
color: rgba(141, 147, 165, 0.56);
}

Просмотреть файл

@@ -0,0 +1,133 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import classNames from 'classnames';
import React from 'react';
import type {MouseEvent, KeyboardEvent} from 'react';
import {useIntl} from 'react-intl';
import CustomStatusModal from 'components/custom_status/custom_status_modal';
import * as Menu from 'components/menu';
import {ModalIdentifiers, UserStatuses} from 'utils/constants';
import UserAccountAwayMenuItem from './user_account_away_menuitem';
import UserAccountCustomStatusMenuItem from './user_account_custom_status_menuitem';
import UserAccountDndMenuItem from './user_account_dnd_menuitem';
import UserAccountLogoutMenuItem from './user_account_logout_menuitem';
import UserAccountMenuButton from './user_account_menuButton';
import UserAccountNameMenuItem from './user_account_name_menuitem';
import UserAccountOfflineMenuItem from './user_account_offline_menuitem';
import UserAccountOnlineMenuItem from './user_account_online_menuitem';
import UserAccountOutOfOfficeMenuItem from './user_account_out_of_office_menuitem';
import UserAccountProfileMenuItem from './user_account_profile_menuitem';
import UserAccountSetCustomStatusMenuItem from './user_account_set_custom_status_menuitem';
import type {PropsFromRedux} from './index';
import './user_account_menu.scss';
type Props = PropsFromRedux;
export default function UserAccountMenu(props: Props) {
const {formatMessage} = useIntl();
function openCustomStatusModal(event: MouseEvent<HTMLElement> | KeyboardEvent<HTMLElement>) {
event.stopPropagation();
props.actions.openModal({
modalId: ModalIdentifiers.CUSTOM_STATUS,
dialogType: CustomStatusModal,
});
}
const isCustomStatusSet = !props.isCustomStatusExpired && props.customStatus && (props.customStatus.text?.length > 0 || props.customStatus.emoji?.length > 0);
const shouldConfirmBeforeStatusChange = props.autoResetPref === '' && props.status === UserStatuses.OUT_OF_OFFICE;
return (
<Menu.Container
menuButton={{
id: 'userAccountMenuButton',
class: classNames('userAccountMenu_menuButton', {
withCustomStatus: isCustomStatusSet,
}),
'aria-label': formatMessage({id: 'userAccountMenu.menuButton.ariaLabel', defaultMessage: 'User\'s account menu'}),
'aria-describedby': 'userAccountMenuButtonDescribedBy',
children: (
<UserAccountMenuButton
profilePicture={props.profilePicture}
openCustomStatusModal={openCustomStatusModal}
status={props.status}
/>
),
}}
menu={{
id: 'userAccountMenu',
width: '264px',
}}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'right',
}}
transformOrigin={{
vertical: 'top',
horizontal: 'right',
}}
>
<UserAccountNameMenuItem
profilePicture={props.profilePicture}
/>
<Menu.Separator/>
{props.status === UserStatuses.OUT_OF_OFFICE && (
<UserAccountOutOfOfficeMenuItem
userId={props.userId}
shouldConfirmBeforeStatusChange={shouldConfirmBeforeStatusChange}
/>
)}
{props.status === UserStatuses.OUT_OF_OFFICE && (
<Menu.Separator/>
)}
{props.isCustomStatusEnabled && !isCustomStatusSet && (
<UserAccountSetCustomStatusMenuItem
openCustomStatusModal={openCustomStatusModal}
/>
)}
{props.isCustomStatusEnabled && isCustomStatusSet && (
<UserAccountCustomStatusMenuItem
timezone={props.timezone}
customStatus={props.customStatus}
openCustomStatusModal={openCustomStatusModal}
/>
)}
{props.isCustomStatusEnabled && (
<Menu.Separator/>
)}
<UserAccountOnlineMenuItem
userId={props.userId}
shouldConfirmBeforeStatusChange={shouldConfirmBeforeStatusChange}
isStatusOnline={props.status === UserStatuses.ONLINE}
/>
<UserAccountAwayMenuItem
userId={props.userId}
shouldConfirmBeforeStatusChange={shouldConfirmBeforeStatusChange}
isStatusAway={props.status === UserStatuses.AWAY}
/>
<UserAccountDndMenuItem
userId={props.userId}
shouldConfirmBeforeStatusChange={shouldConfirmBeforeStatusChange}
timezone={props.timezone}
isStatusDnd={props.status === UserStatuses.DND}
/>
<UserAccountOfflineMenuItem
userId={props.userId}
shouldConfirmBeforeStatusChange={shouldConfirmBeforeStatusChange}
isStatusOffline={props.status === UserStatuses.OFFLINE}
/>
<Menu.Separator/>
<UserAccountProfileMenuItem
userId={props.userId}
/>
<Menu.Separator/>
<UserAccountLogoutMenuItem/>
</Menu.Container>
);
}

Просмотреть файл

@@ -0,0 +1,139 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useMemo} from 'react';
import type {MouseEvent, KeyboardEvent} from 'react';
import {defineMessages, useIntl} from 'react-intl';
import {CheckCircleIcon, ClockIcon, MinusCircleIcon, RadioboxBlankIcon} from '@mattermost/compass-icons/components';
import CustomStatusEmoji from 'components/custom_status/custom_status_emoji';
import Avatar from 'components/widgets/users/avatar/avatar';
import {UserStatuses} from 'utils/constants';
interface Props {
profilePicture?: string;
openCustomStatusModal: ((event: MouseEvent<HTMLElement> | KeyboardEvent<HTMLElement>) => void);
status?: string;
}
export default function UserAccountMenuButton({
profilePicture,
openCustomStatusModal,
status,
}: Props) {
const {formatMessage} = useIntl();
const statusIcon = useMemo(() => {
if (status && status === UserStatuses.ONLINE) {
return (
<CheckCircleIcon
size='16'
className='userAccountMenu_onlineMenuItem_icon'
/>
);
}
if (status && status === UserStatuses.AWAY) {
return (
<ClockIcon
size='16'
className='userAccountMenu_awayMenuItem_icon'
/>
);
}
if (status && status === UserStatuses.DND) {
return (
<MinusCircleIcon
size='16'
className='userAccountMenu_dndMenuItem_icon'
/>
);
}
// Defaults to offline
return (
<RadioboxBlankIcon
size='16'
className='userAccountMenu_offlineMenuItem_icon'
/>
);
}, [status]);
return (
<>
<CustomStatusEmoji
showTooltip={true}
emojiStyle={{marginRight: '6px'}}
aria-hidden={true}
onClick={openCustomStatusModal}
/>
<Avatar
size='sm'
url={profilePicture}
aria-hidden={true}
/>
<div
className='userStatusIconWrapper'
aria-hidden={true}
>
{statusIcon}
</div>
<p
id='userAccountMenuButtonDescribedBy'
className='sr-only'
>
{formatMessage(getMenuButtonAriaDescription(status))}
</p>
</>
);
}
const ariaDescriptionsDefineMessages = defineMessages({
outOfOffice: {
id: 'userAccountMenu.menuButton.ariaDescription.ooo',
defaultMessage: 'Status is "Out of office".',
},
online: {
id: 'userAccountMenu.menuButton.ariaDescription.online',
defaultMessage: 'Status is "Online".',
},
away: {
id: 'userAccountMenu.menuButton.ariaDescription.away',
defaultMessage: 'Status is "Away".',
},
dnd: {
id: 'userAccountMenu.menuButton.ariaDescription.dnd',
defaultMessage: 'Status is "Do not disturb".',
},
offline: {
id: 'userAccountMenu.menuButton.ariaDescription.offline',
defaultMessage: 'Status is "Offline".',
},
});
function getMenuButtonAriaDescription(status?: string) {
let ariaLabel;
switch (status) {
case UserStatuses.OUT_OF_OFFICE:
ariaLabel = ariaDescriptionsDefineMessages.outOfOffice;
break;
case UserStatuses.ONLINE:
ariaLabel = ariaDescriptionsDefineMessages.online;
break;
case UserStatuses.AWAY:
ariaLabel = ariaDescriptionsDefineMessages.away;
break;
case UserStatuses.DND:
ariaLabel = ariaDescriptionsDefineMessages.dnd;
break;
case UserStatuses.OFFLINE:
default:
ariaLabel = ariaDescriptionsDefineMessages.offline;
break;
}
return ariaLabel;
}

Просмотреть файл

@@ -0,0 +1,92 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import * as reactRedux from 'react-redux';
import {renderWithContext, screen, userEvent} from 'tests/react_testing_utils';
import {TestHelper} from 'utils/test_helper';
import type {GlobalState} from 'types/store';
import UserAccountNameMenuItem from './user_account_name_menuitem';
jest.mock('react-redux', () => ({
...jest.requireActual('react-redux') as typeof import('react-redux'),
useDispatch: jest.fn(),
}));
jest.mock('actions/views/modals', () => ({
openModal: jest.fn(),
}));
describe('UserAccountNameMenuItem', () => {
const user = TestHelper.getUserMock({
first_name: 'sampleFirstName',
last_name: 'sampleLastName',
username: 'sampleUsername',
});
let initialState: GlobalState;
beforeEach(() => {
initialState = {
entities: {
users: {
profiles: {
[user.id]: user,
},
currentUserId: user.id,
},
},
} as GlobalState;
});
afterEach(() => {
jest.resetAllMocks();
});
test('should render with both first and last name along with username', () => {
renderWithContext(<UserAccountNameMenuItem/>, initialState);
expect(screen.getByText('sampleFirstName sampleLastName')).toBeInTheDocument();
expect(screen.getByText('@sampleUsername')).toBeInTheDocument();
});
test('should render with only username', () => {
const state = {
entities: {
users: {
profiles: {
[user.id]: {
id: user.id,
username: user.username,
},
},
currentUserId: user.id,
},
},
} as GlobalState;
renderWithContext(<UserAccountNameMenuItem/>, state);
expect(screen.queryByText('sampleFirstName sampleLastName')).not.toBeInTheDocument();
expect(screen.getByText('@sampleUsername')).toBeInTheDocument();
});
test('should try to open user settings modal', async () => {
jest.spyOn(reactRedux, 'useDispatch').mockReturnValue(jest.fn());
renderWithContext(<UserAccountNameMenuItem/>, initialState);
userEvent.click(screen.getByRole('menuitem'));
expect(reactRedux.useDispatch).toHaveBeenCalledTimes(1);
});
test('should not break if no props are passed', () => {
const {container} = renderWithContext(<UserAccountNameMenuItem/>);
expect(container).toMatchSnapshot();
});
});

Просмотреть файл

@@ -0,0 +1,78 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {useDispatch, useSelector} from 'react-redux';
import {getCurrentUser} from 'mattermost-redux/selectors/entities/users';
import {openModal} from 'actions/views/modals';
import * as Menu from 'components/menu';
import UserSettingsModal from 'components/user_settings/modal';
import Avatar from 'components/widgets/users/avatar/avatar';
import {ModalIdentifiers} from 'utils/constants';
interface Props extends Menu.FirstMenuItemProps {
profilePicture?: string;
}
export default function UserAccountNameMenuItem({profilePicture, ...rest}: Props) {
const dispatch = useDispatch();
const currentUser = useSelector(getCurrentUser);
function handleClick() {
dispatch(openModal({
modalId: ModalIdentifiers.USER_SETTINGS,
dialogType: UserSettingsModal,
dialogProps: {isContentProductSettings: false},
}));
}
function getLabel() {
if (
currentUser?.first_name?.length > 0 ||
currentUser?.last_name?.length > 0
) {
const name = `${currentUser?.first_name} ${currentUser?.last_name}`?.trim();
return (
<>
<span className='userAccountMenu_nameMenuItem_primaryLabel'>
{name}
</span>
<span className='userAccountMenu_nameMenuItem_secondaryLabel'>
{'@' + currentUser?.username}
</span>
</>
);
}
const username = `@${currentUser?.username}`?.trim();
return (
<h2 className='userAccountMenu_nameMenuItem_primaryLabel'>
{username}
</h2>
);
}
return (
<Menu.Item
className='userAccountMenu_nameMenuItem'
leadingElement={
<Avatar
size='lg'
url={profilePicture}
aria-hidden='true'
/>
}
labels={getLabel()}
onClick={handleClick}
aria-haspopup={true}
{...rest}
/>
);
}

Просмотреть файл

@@ -0,0 +1,80 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useMemo} from 'react';
import {FormattedMessage} from 'react-intl';
import {useDispatch} from 'react-redux';
import {CheckIcon, RadioboxBlankIcon} from '@mattermost/compass-icons/components';
import type {UserProfile} from '@mattermost/types/users';
import {setStatus} from 'mattermost-redux/actions/users';
import {openModal} from 'actions/views/modals';
import * as Menu from 'components/menu';
import ResetStatusModal from 'components/reset_status_modal';
import {ModalIdentifiers, UserStatuses} from 'utils/constants';
interface Props {
userId: UserProfile['id'];
shouldConfirmBeforeStatusChange: boolean;
isStatusOffline: boolean;
}
export default function UserAccountOfflineMenuItem(props: Props) {
const dispatch = useDispatch();
function handleClick() {
if (props.shouldConfirmBeforeStatusChange) {
dispatch(openModal({
modalId: ModalIdentifiers.RESET_STATUS,
dialogType: ResetStatusModal,
dialogProps: {
newStatus: UserStatuses.OFFLINE,
},
}));
} else {
dispatch(setStatus({
user_id: props.userId,
status: UserStatuses.OFFLINE,
}));
}
}
const trailingElement = useMemo(() => {
if (props.isStatusOffline) {
return (
<CheckIcon
size={16}
className='userAccountMenu_menuItemTrailingCheckIcon'
aria-hidden='true'
/>
);
}
return null;
}, [props.isStatusOffline]);
return (
<Menu.Item
leadingElement={
<RadioboxBlankIcon
size='18'
className='userAccountMenu_offlineMenuItem_icon'
aria-hidden='true'
/>
}
labels={
<FormattedMessage
id='userAccountMenu.offlineMenuItem.label'
defaultMessage='Offline'
/>
}
trailingElements={trailingElement}
aria-checked={props.isStatusOffline}
onClick={handleClick}
/>
);
}

Просмотреть файл

@@ -0,0 +1,80 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useMemo} from 'react';
import {FormattedMessage} from 'react-intl';
import {useDispatch} from 'react-redux';
import {CheckCircleIcon, CheckIcon} from '@mattermost/compass-icons/components';
import type {UserProfile} from '@mattermost/types/users';
import {setStatus} from 'mattermost-redux/actions/users';
import {openModal} from 'actions/views/modals';
import * as Menu from 'components/menu';
import ResetStatusModal from 'components/reset_status_modal';
import {UserStatuses, ModalIdentifiers} from 'utils/constants';
interface Props {
userId: UserProfile['id'];
shouldConfirmBeforeStatusChange: boolean;
isStatusOnline: boolean;
}
export default function UserAccountOnlineMenuItem(props: Props) {
const dispatch = useDispatch();
function handleClick() {
if (props.shouldConfirmBeforeStatusChange) {
dispatch(openModal({
modalId: ModalIdentifiers.RESET_STATUS,
dialogType: ResetStatusModal,
dialogProps: {
newStatus: UserStatuses.ONLINE,
},
}));
} else {
dispatch(setStatus({
user_id: props.userId,
status: UserStatuses.ONLINE,
}));
}
}
const trailingElement = useMemo(() => {
if (props.isStatusOnline) {
return (
<CheckIcon
size={16}
className='userAccountMenu_menuItemTrailingCheckIcon'
aria-hidden='true'
/>
);
}
return null;
}, [props.isStatusOnline]);
return (
<Menu.Item
leadingElement={
<CheckCircleIcon
size='18'
className='userAccountMenu_onlineMenuItem_icon'
aria-hidden='true'
/>
}
labels={
<FormattedMessage
id='userAccountMenu.onlineMenuItem.label'
defaultMessage='Online'
/>
}
trailingElements={trailingElement}
aria-checked={props.isStatusOnline}
onClick={handleClick}
/>
);
}

Просмотреть файл

@@ -0,0 +1,57 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import * as modalActions from 'actions/views/modals';
import {renderWithContext, screen, userEvent} from 'tests/react_testing_utils';
import {WindowSizes} from 'utils/constants';
import {TestHelper} from 'utils/test_helper';
import UserAccountOutOfOfficeMenuItem, {type Props} from './user_account_out_of_office_menuitem';
describe('UserAccountOutOfOfficeMenuItem', () => {
let defaultProps: Props;
beforeEach(() => {
defaultProps = {
userId: TestHelper.getUserMock().id,
shouldConfirmBeforeStatusChange: false,
};
});
test('should not render if status is not out of office', () => {
const props = {...defaultProps, isStatusOutOfOffice: false};
renderWithContext(<UserAccountOutOfOfficeMenuItem {...props}/>);
expect(screen.queryByText('Out of office')).not.toBeInTheDocument();
});
test('should only render when status is out of office', () => {
renderWithContext(<UserAccountOutOfOfficeMenuItem {...defaultProps}/>);
expect(screen.getAllByText(/Out of office/).length).toBe(1);
expect(screen.getAllByText(/Automatic replies are enabled/).length).toBe(1);
});
test('should open reset status modal when confirming before status change option is true', async () => {
jest.spyOn(modalActions, 'openModal');
// Work around since in mobile menu's onClick get executed immediately and not so on non-mobile
// see handleClick func of webapp/channels/src/components/menu/menu_item.tsx
const initialState = {
views: {
browser: {
windowSize: WindowSizes.MOBILE_VIEW,
},
},
};
const props = {...defaultProps, shouldConfirmBeforeStatusChange: true};
renderWithContext(<UserAccountOutOfOfficeMenuItem {...props}/>, initialState);
userEvent.click(screen.getByRole('menuitem'));
expect(modalActions.openModal).toHaveBeenCalledTimes(1);
});
});

Просмотреть файл

@@ -0,0 +1,76 @@
// 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 {useDispatch} from 'react-redux';
import {CancelIcon, CheckIcon} from '@mattermost/compass-icons/components';
import type {UserProfile} from '@mattermost/types/users';
import {setStatus} from 'mattermost-redux/actions/users';
import {openModal} from 'actions/views/modals';
import * as Menu from 'components/menu';
import ResetStatusModal from 'components/reset_status_modal';
import {UserStatuses, ModalIdentifiers} from 'utils/constants';
export interface Props {
userId: UserProfile['id'];
shouldConfirmBeforeStatusChange: boolean;
}
export default function UserAccountOutOfOfficeMenuItem(props: Props) {
const dispatch = useDispatch();
function handleClick() {
if (props.shouldConfirmBeforeStatusChange) {
dispatch(openModal({
modalId: ModalIdentifiers.RESET_STATUS,
dialogType: ResetStatusModal,
dialogProps: {
newStatus: UserStatuses.ONLINE,
},
}));
} else {
dispatch(setStatus({
user_id: props.userId,
status: UserStatuses.ONLINE,
}));
}
}
return (
<Menu.Item
leadingElement={
<CancelIcon
size={18}
aria-hidden='true'
/>
}
labels={
<>
<FormattedMessage
id='userAccountMenu.oooMenuItem.primaryLabel'
defaultMessage='Out of office'
/>
<FormattedMessage
id='userAccountMenu.oooMenuItem.secondaryLabel'
defaultMessage='Automatic replies are enabled'
/>
</>
}
trailingElements={
<CheckIcon
size={16}
className='userAccountMenu_menuItemTrailingCheckIcon'
aria-hidden='true'
/>
}
aria-checked='true'
onClick={handleClick}
/>
);
}

Просмотреть файл

@@ -0,0 +1,80 @@
// 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 {useDispatch, useSelector} from 'react-redux';
import {AccountOutlineIcon} from '@mattermost/compass-icons/components';
import type {UserProfile} from '@mattermost/types/users';
import {savePreferences} from 'mattermost-redux/actions/preferences';
import {getInt} from 'mattermost-redux/selectors/entities/preferences';
import {openModal} from 'actions/views/modals';
import * as Menu from 'components/menu';
import {OnboardingTaskCategory, OnboardingTasksName, TaskNameMapToSteps, CompleteYourProfileTour} from 'components/onboarding_tasks';
import UserSettingsModal from 'components/user_settings/modal';
import {ModalIdentifiers} from 'utils/constants';
import type {GlobalState} from 'types/store';
interface Props {
userId: UserProfile['id'];
}
export default function UserAccountProfileMenuItem(props: Props) {
const dispatch = useDispatch();
const onboardingTaskStep = useSelector((state: GlobalState) => getInt(state, OnboardingTaskCategory, OnboardingTasksName.COMPLETE_YOUR_PROFILE, 0));
const isCompleteYourProfileTaskPending = onboardingTaskStep === TaskNameMapToSteps[OnboardingTasksName.COMPLETE_YOUR_PROFILE].STARTED;
function handleClick() {
dispatch(openModal({
modalId: ModalIdentifiers.USER_SETTINGS,
dialogType: UserSettingsModal,
dialogProps: {isContentProductSettings: false},
}));
}
function handleTourClick() {
const taskName = OnboardingTasksName.COMPLETE_YOUR_PROFILE;
const steps = TaskNameMapToSteps[taskName];
dispatch(savePreferences(props.userId, [{
user_id: props.userId,
category: OnboardingTaskCategory,
name: taskName,
value: steps.FINISHED.toString(),
}]));
}
return (
<Menu.Item
leadingElement={
<AccountOutlineIcon
size={18}
aria-hidden='true'
/>
}
labels={
<FormattedMessage
id='userAccountMenu.profileMenuItem.label'
defaultMessage='Profile'
/>
}
trailingElements={isCompleteYourProfileTaskPending && (
<div
onClick={handleTourClick}
className='userAccountMenu_profileMenuItem_tourWrapper'
>
<CompleteYourProfileTour/>
</div>
)}
aria-haspopup={true}
onClick={handleClick}
/>
);
}

Просмотреть файл

@@ -0,0 +1,35 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {MouseEvent, KeyboardEvent} from 'react';
import React from 'react';
import {FormattedMessage} from 'react-intl';
import * as Menu from 'components/menu';
import EmojiIcon from 'components/widgets/icons/emoji_icon';
interface Props {
openCustomStatusModal: (event: MouseEvent<HTMLLIElement> | KeyboardEvent<HTMLLIElement>) => void;
}
export default function UserAccountSetCustomStatusMenuItem(props: Props) {
return (
<Menu.Item
className='userAccountMenu_setCustomStatusMenuItem'
leadingElement={
<EmojiIcon
className='userAccountMenu_setCustomStatusMenuItem_icon'
aria-hidden='true'
/>
}
labels={
<FormattedMessage
id='userAccountMenu.setCustomStatusMenuItem.noStatusSet'
defaultMessage='Set custom status'
/>
}
aria-haspopup={true}
onClick={props.openCustomStatusModal}
/>
);
}

Просмотреть файл

@@ -55,7 +55,6 @@ const ViewUserGroupListItem = (props: Props) => {
className='group-member-row'
>
<span className='status-wrapper'>
<Avatar
username={user.username}
size={'sm'}

Просмотреть файл

@@ -3556,7 +3556,6 @@
"custom_status.expiry_dropdown.today": "Today",
"custom_status.expiry.time_picker.title": "Time",
"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",
@@ -5259,29 +5258,6 @@
"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": "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",
"status_dropdown.dnd_sub_menu_item.two_hours": "2 hours",
"status_dropdown.menuAriaLabel": "set status",
"status_dropdown.profile_button_label": "Select to open profile and status menu.",
"status_dropdown.profile_button_label.away": "Current status: Away. Select to open profile and status menu.",
"status_dropdown.profile_button_label.dnd": "Current status: Do not disturb. Select to open profile and status menu.",
"status_dropdown.profile_button_label.offline": "Current status: Offline. Select to open profile and status menu.",
"status_dropdown.profile_button_label.online": "Current status: Online. Select to open profile and status menu.",
"status_dropdown.profile_button_label.ooo": "Current status: Out of office. Select to open profile and status menu.",
"status_dropdown.set_away": "Away",
"status_dropdown.set_custom": "Set a custom status",
"status_dropdown.set_custom_text": "Set custom status text...",
"status_dropdown.set_dnd": "Do not disturb",
"status_dropdown.set_dnd.extra": "Disables all notifications",
"status_dropdown.set_offline": "Offline",
"status_dropdown.set_online": "Online",
"status_dropdown.set_ooo": "Out of office",
"status_dropdown.set_ooo.extra": "Automatic replies are enabled",
"success_modal.return_to_workspace": "Return to workspace",
"success_modal.subtitle": "Your final bill will be prorated. Your workspace now has {plan} limits.",
"suggestion.all": "All channel types",
@@ -5935,6 +5911,39 @@
"user.settings.tokens.tokenId": "Token ID: ",
"user.settings.tokens.tokenLoading": "Loading...",
"user.settings.tokens.userAccessTokensNone": "No personal access tokens.",
"userAccountMenu.awayMenuItem.label": "Away",
"userAccountMenu.dndMenuItem.primaryLabel": "Do not disturb",
"userAccountMenu.dndMenuItem.secondaryLabel": "Disables all notifications",
"userAccountMenu.dndMenuItem.secondaryLabel.doNotClear": "Until indefinitely",
"userAccountMenu.dndMenuItem.secondaryLabel.untilLaterSomeTime": "Until {time}",
"userAccountMenu.dndMenuItem.secondaryLabel.untilTodaySomeTime": "Until {time}",
"userAccountMenu.dndMenuItem.secondaryLabel.untilTomorrowSomeTime": "Until tomorrow {time}",
"userAccountMenu.dndSubMenu.title": "Clear after:",
"userAccountMenu.dndSubMenuItem.1Hour": "1 hour",
"userAccountMenu.dndSubMenuItem.2Hours": "2 hours",
"userAccountMenu.dndSubMenuItem.30Minutes": "30 mins",
"userAccountMenu.dndSubMenuItem.custom": "Choose date and time",
"userAccountMenu.dndSubMenuItem.doNotClear": "Don't clear",
"userAccountMenu.dndSubMenuItem.tomorrow": "Tomorrow",
"userAccountMenu.dndSubMenuItem.tomorrowsDateTime": "{shortDay}, {shortTime}",
"userAccountMenu.logoutMenuItem.label": "Log out",
"userAccountMenu.menuButton.ariaDescription.away": "Status is \"Away\".",
"userAccountMenu.menuButton.ariaDescription.dnd": "Status is \"Do not disturb\".",
"userAccountMenu.menuButton.ariaDescription.offline": "Status is \"Offline\".",
"userAccountMenu.menuButton.ariaDescription.online": "Status is \"Online\".",
"userAccountMenu.menuButton.ariaDescription.ooo": "Status is \"Out of office\".",
"userAccountMenu.menuButton.ariaLabel": "User's account menu",
"userAccountMenu.offlineMenuItem.label": "Offline",
"userAccountMenu.onlineMenuItem.label": "Online",
"userAccountMenu.oooMenuItem.primaryLabel": "Out of office",
"userAccountMenu.oooMenuItem.secondaryLabel": "Automatic replies are enabled",
"userAccountMenu.profileMenuItem.label": "Profile",
"userAccountMenu.setCustomStatusMenuItem.clearTooltip": "Clear custom status",
"userAccountMenu.setCustomStatusMenuItem.hasStatusWithExpiryAndNoText.ariaDescription": "Status expires at {time}. Set a custom status.",
"userAccountMenu.setCustomStatusMenuItem.hasStatusWithTextAndExpiry.ariaDescription": "Status is \"{text}\" and expires at {time}. Set a custom status.",
"userAccountMenu.setCustomStatusMenuItem.hasStatusWithTextAndNoExpiry.ariaDescription": "Status is \"{text}\". Set a custom status.",
"userAccountMenu.setCustomStatusMenuItem.noStatusSet": "Set custom status",
"userAccountMenu.setCustomStatusMenuItem.noStatusTextSet": "Set custom status text",
"userGuideHelp.askTheCommunity": "Ask the community",
"userGuideHelp.helpResources": "Help resources",
"userGuideHelp.keyboardShortcuts": "Keyboard shortcuts",

Просмотреть файл

@@ -25,7 +25,6 @@ import rhs from './rhs';
import rhsSuppressed from './rhs_suppressed';
import search from './search';
import settings from './settings';
import statusDropdown from './status_dropdown';
import system from './system';
import textbox from './textbox';
import threads from './threads';
@@ -50,7 +49,6 @@ export default combineReducers({
marketplace,
textbox,
channelSidebar,
statusDropdown,
addChannelDropdown,
addChannelCtaDropdown,
onboardingTasks,

Просмотреть файл

@@ -1,26 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {combineReducers} from 'redux';
import {UserTypes} from 'mattermost-redux/action_types';
import {ActionTypes} from 'utils/constants';
import type {MMAction} from 'types/store';
export function isOpen(state = false, action: MMAction) {
switch (action.type) {
case ActionTypes.STATUS_DROPDOWN_TOGGLE:
return action.open;
case UserTypes.LOGOUT_SUCCESS:
return false;
default:
return state;
}
}
export default combineReducers({
isOpen,
});

Просмотреть файл

@@ -41,5 +41,8 @@ $elevation-6: var(--elevation-6);
// Central place to manage z-index values for the app, providing a consistent way to manage stacking order.
$z-index-popover: 1070;
$z-index-user-group-popover: 1060;
$z-index-tour-tips: 1058;
$z-index-menu: 1054;
$z-index-modal: 1050;
$z-index-global-header: 99;
$z-index-channel-header: 15;

Просмотреть файл

@@ -1,19 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {setStatusDropdown} from 'actions/views/status_dropdown';
import {isStatusDropdownOpen} from 'selectors/views/status_dropdown';
import configureStore from 'store';
describe('status_dropdown selector', () => {
it('should return the isOpen value from the state', async () => {
const store = await configureStore();
expect(isStatusDropdownOpen(store.getState())).toBeFalsy();
});
it('should return true if statusDropdown in explicitly opened', async () => {
const store = await configureStore();
await store.dispatch(setStatusDropdown(true));
expect(isStatusDropdownOpen(store.getState())).toBeTruthy();
});
});

Просмотреть файл

@@ -1,8 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {GlobalState} from 'types/store';
export function isStatusDropdownOpen(state: GlobalState) {
return state.views.statusDropdown.isOpen;
}

Просмотреть файл

@@ -194,10 +194,6 @@ export type ViewsState = {
lastSelectedChannel: string;
};
statusDropdown: {
isOpen: boolean;
};
addChannelDropdown: {
isOpen: boolean;
};

Просмотреть файл

@@ -250,7 +250,6 @@ export const ActionTypes = keyMirror({
INCREMENT_EMOJI_PICKER_PAGE: null,
SET_RECENT_SKIN: null,
STATUS_DROPDOWN_TOGGLE: null,
ADD_CHANNEL_DROPDOWN_TOGGLE: null,
ADD_CHANNEL_CTA_DROPDOWN_TOGGLE: null,

Просмотреть файл

@@ -33,3 +33,20 @@ export function isKeyPressed(event: React.KeyboardEvent | KeyboardEvent, key: [s
// used for different language keyboards to detect the position of keys
return event.keyCode === key[1];
}
export const MenuButtonIds = {
userAccountMenu: 'userAccountMenuButton',
} as const;
/**
* Since the menu component requires actual interaction with the button
* of the menu for opening the menus, we trigger it from here
*/
export function clickOnMenuButton(buttonId: string) {
const menuButton = document.getElementById(buttonId);
if (!menuButton) {
return;
}
menuButton.click();
}

Просмотреть файл

@@ -35,3 +35,37 @@
animation: pulse2 2s ease 0s infinite;
}
}
@keyframes pulse1 {
0% {
opacity: 1;
transform: scale(1);
}
80% {
opacity: 0;
transform: scale(2.5);
}
100% {
opacity: 0;
transform: scale(2.5);
}
}
@keyframes pulse2 {
0% {
opacity: 1;
transform: scale(1);
}
30% {
opacity: 1;
transform: scale(1);
}
100% {
opacity: 0;
transform: scale(2.5);
}
}