MM-50087_Add marketplace button to apps bar (#22653)

Этот коммит содержится в:
Julian Mondragón
2023-03-28 10:54:30 -05:00
коммит произвёл GitHub
родитель 6c9ec24fb9
Коммит da7a6825ce
21 изменённых файлов: 485 добавлений и 240 удалений

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

@@ -5,10 +5,10 @@ export function verifyPluginMarketplaceVisibility(shouldBeVisible) {
cy.uiOpenProductMenu().within(() => { cy.uiOpenProductMenu().within(() => {
if (shouldBeVisible) { if (shouldBeVisible) {
// * Verify Marketplace button should exist // * Verify Marketplace button should exist
cy.findByText('Marketplace').should('exist'); cy.findByText('App Marketplace').should('exist');
} else { } else {
// * Verify Marketplace button should not exist // * Verify Marketplace button should not exist
cy.findByText('Marketplace').should('not.exist'); cy.findByText('App Marketplace').should('not.exist');
} }
}); });
} }

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

@@ -6,6 +6,7 @@ import {Client4} from 'mattermost-redux/client';
import * as Channels from 'mattermost-redux/selectors/entities/channels'; import * as Channels from 'mattermost-redux/selectors/entities/channels';
import * as Teams from 'mattermost-redux/selectors/entities/teams'; import * as Teams from 'mattermost-redux/selectors/entities/teams';
import {Permissions} from 'mattermost-redux/constants';
import {AppCallResponseTypes} from 'mattermost-redux/constants/apps'; import {AppCallResponseTypes} from 'mattermost-redux/constants/apps';
import * as GlobalActions from 'actions/global_actions'; import * as GlobalActions from 'actions/global_actions';
@@ -55,7 +56,7 @@ const initialState = {
roles: { roles: {
custom_role: { custom_role: {
permissions: [ permissions: [
'sysconsole_read_plugins', Permissions.SYSCONSOLE_WRITE_PLUGINS,
], ],
}, },
}, },

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

@@ -130,7 +130,7 @@ export function executeCommand(message: string, args: CommandArgs): ActionFunc {
return {data: true}; return {data: true};
case '/marketplace': case '/marketplace':
// check if user has permissions to access the read plugins // check if user has permissions to access the read plugins
if (!haveICurrentTeamPermission(state, Permissions.SYSCONSOLE_READ_PLUGINS)) { if (!haveICurrentTeamPermission(state, Permissions.SYSCONSOLE_WRITE_PLUGINS)) {
return {error: {message: localizeMessage('marketplace_command.no_permission', 'You do not have the appropriate permissions to access the marketplace.')}}; return {error: {message: localizeMessage('marketplace_command.no_permission', 'You do not have the appropriate permissions to access the marketplace.')}};
} }
@@ -139,7 +139,7 @@ export function executeCommand(message: string, args: CommandArgs): ActionFunc {
return {error: {message: localizeMessage('marketplace_command.disabled', 'The marketplace is disabled. Please contact your System Administrator for details.')}}; return {error: {message: localizeMessage('marketplace_command.disabled', 'The marketplace is disabled. Please contact your System Administrator for details.')}};
} }
dispatch(openModal({modalId: ModalIdentifiers.PLUGIN_MARKETPLACE, dialogType: MarketplaceModal})); dispatch(openModal({modalId: ModalIdentifiers.PLUGIN_MARKETPLACE, dialogType: MarketplaceModal, dialogProps: {openedFrom: 'command'}}));
return {data: true}; return {data: true};
case '/templates': { case '/templates': {
const workTemplateEnabled = areWorkTemplatesEnabled(state); const workTemplateEnabled = areWorkTemplatesEnabled(state);

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

@@ -1,6 +1,6 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP // Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/actions_menu/ActionsMenu has actions - end user - should not show actions and app marketplace 1`] = ` exports[`components/actions_menu/ActionsMenu has actions - marketplace disabled or user not having SYSCONSOLE_WRITE_PLUGINS - should not show actions and app marketplace 1`] = `
<MenuWrapper <MenuWrapper
animationComponent={[Function]} animationComponent={[Function]}
className="" className=""
@@ -68,7 +68,7 @@ exports[`components/actions_menu/ActionsMenu has actions - end user - should not
</MenuWrapper> </MenuWrapper>
`; `;
exports[`components/actions_menu/ActionsMenu has actions - sysadmin - should show actions and app marketplace 1`] = ` exports[`components/actions_menu/ActionsMenu has actions - marketplace enabled and user has SYSCONSOLE_WRITE_PLUGINS - should show actions and app marketplace 1`] = `
<MenuWrapper <MenuWrapper
animationComponent={[Function]} animationComponent={[Function]}
className="" className=""

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

@@ -45,6 +45,7 @@ describe('components/actions_menu/ActionsMenu', () => {
handleDismissTip: jest.fn(), handleDismissTip: jest.fn(),
showPulsatingDot: false, showPulsatingDot: false,
location: 'center', location: 'center',
canOpenMarketplace: false,
actions: { actions: {
openModal: jest.fn(), openModal: jest.fn(),
openAppsModal: jest.fn(), openAppsModal: jest.fn(),
@@ -62,27 +63,29 @@ describe('components/actions_menu/ActionsMenu', () => {
wrapper.setProps({ wrapper.setProps({
pluginMenuItems: dropdownComponents, pluginMenuItems: dropdownComponents,
canOpenMarketplace: true,
}); });
expect(wrapper.find('#divider_post_post_id_1_marketplace').exists()).toBe(true); expect(wrapper.find('#divider_post_post_id_1_marketplace').exists()).toBe(true);
}); });
test('has actions - sysadmin - should show actions and app marketplace', () => { test('has actions - marketplace enabled and user has SYSCONSOLE_WRITE_PLUGINS - should show actions and app marketplace', () => {
const wrapper = shallowWithIntl( const wrapper = shallowWithIntl(
<ActionsMenu {...baseProps}/>, <ActionsMenu {...baseProps}/>,
); );
wrapper.setProps({ wrapper.setProps({
pluginMenuItems: dropdownComponents, pluginMenuItems: dropdownComponents,
canOpenMarketplace: true,
}); });
expect(wrapper).toMatchSnapshot(); expect(wrapper).toMatchSnapshot();
}); });
test('has actions - end user - should not show actions and app marketplace', () => { test('has actions - marketplace disabled or user not having SYSCONSOLE_WRITE_PLUGINS - should not show actions and app marketplace', () => {
const wrapper = shallowWithIntl( const wrapper = shallowWithIntl(
<ActionsMenu {...baseProps}/>, <ActionsMenu {...baseProps}/>,
); );
wrapper.setProps({ wrapper.setProps({
pluginMenuItems: dropdownComponents, pluginMenuItems: dropdownComponents,
isSysAdmin: false, canOpenMarketplace: false,
}); });
expect(wrapper).toMatchSnapshot(); expect(wrapper).toMatchSnapshot();
}); });
@@ -91,6 +94,11 @@ describe('components/actions_menu/ActionsMenu', () => {
const wrapper = shallowWithIntl( const wrapper = shallowWithIntl(
<ActionsMenu {...baseProps}/>, <ActionsMenu {...baseProps}/>,
); );
wrapper.setProps({
canOpenMarketplace: true,
});
expect(wrapper).toMatchSnapshot(); expect(wrapper).toMatchSnapshot();
}); });
@@ -116,6 +124,7 @@ describe('components/actions_menu/ActionsMenu', () => {
components: { components: {
[PLUGGABLE_COMPONENT]: dropdownComponents, [PLUGGABLE_COMPONENT]: dropdownComponents,
}, },
canOpenMarketplace: true,
}); });
expect(wrapper.find('#divider_post_post_id_1_marketplace').exists()).toBe(true); expect(wrapper.find('#divider_post_post_id_1_marketplace').exists()).toBe(true);
}); });

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

@@ -20,6 +20,7 @@ import Permissions from 'mattermost-redux/constants/permissions';
import {ActionsTutorialTip} from 'components/actions_menu/actions_menu_tutorial_tip'; import {ActionsTutorialTip} from 'components/actions_menu/actions_menu_tutorial_tip';
import {ModalData} from 'types/actions'; import {ModalData} from 'types/actions';
import MarketplaceModal from 'components/plugin_marketplace'; import MarketplaceModal from 'components/plugin_marketplace';
import {OpenedFromType} from 'components/plugin_marketplace/marketplace_modal';
import OverlayTrigger from 'components/overlay_trigger'; import OverlayTrigger from 'components/overlay_trigger';
import * as PostUtils from 'utils/post_utils'; import * as PostUtils from 'utils/post_utils';
import * as Utils from 'utils/utils'; import * as Utils from 'utils/utils';
@@ -49,6 +50,7 @@ export type Props = {
handleDismissTip: () => void; handleDismissTip: () => void;
showPulsatingDot?: boolean; showPulsatingDot?: boolean;
showTutorialTip: boolean; showTutorialTip: boolean;
canOpenMarketplace: boolean;
/** /**
* Components for overriding provided by plugins * Components for overriding provided by plugins
@@ -145,9 +147,11 @@ export class ActionMenuClass extends React.PureComponent<Props, State> {
} }
handleOpenMarketplace = (): void => { handleOpenMarketplace = (): void => {
const openedFrom: OpenedFromType = 'actions_menu';
const openMarketplaceData = { const openMarketplaceData = {
modalId: ModalIdentifiers.PLUGIN_MARKETPLACE, modalId: ModalIdentifiers.PLUGIN_MARKETPLACE,
dialogType: MarketplaceModal, dialogType: MarketplaceModal,
dialogProps: {openedFrom},
}; };
this.props.actions.openModal(openMarketplaceData); this.props.actions.openModal(openMarketplaceData);
}; };
@@ -341,7 +345,7 @@ export class ActionMenuClass extends React.PureComponent<Props, State> {
const {formatMessage} = this.props.intl; const {formatMessage} = this.props.intl;
let marketPlace = null; let marketPlace = null;
if (this.props.isSysAdmin) { if (this.props.canOpenMarketplace) {
marketPlace = ( marketPlace = (
<React.Fragment key={'marketplace'}> <React.Fragment key={'marketplace'}>
{this.renderDivider('marketplace')} {this.renderDivider('marketplace')}
@@ -363,11 +367,11 @@ export class ActionMenuClass extends React.PureComponent<Props, State> {
const hasPluginItems = Boolean(pluginItems?.length); const hasPluginItems = Boolean(pluginItems?.length);
const hasPluginMenuItems = hasPluginItems || hasApps || hasPluggables; const hasPluginMenuItems = hasPluginItems || hasApps || hasPluggables;
if (!this.props.isSysAdmin && !hasPluginMenuItems) { if (!this.props.canOpenMarketplace && !hasPluginMenuItems) {
return null; return null;
} }
if (hasPluginItems || hasApps || hasPluggables) { if (hasPluginMenuItems) {
const pluggable = ( const pluggable = (
<Pluggable <Pluggable
postId={this.props.post.id} postId={this.props.post.id}

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

@@ -44,6 +44,7 @@ describe('components/actions_menu/ActionsMenu returning empty ("")', () => {
showTutorialTip: false, showTutorialTip: false,
appsEnabled: false, appsEnabled: false,
isSysAdmin: true, isSysAdmin: true,
canOpenMarketplace: false,
}; };
const wrapper = shallow( const wrapper = shallow(

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

@@ -44,6 +44,7 @@ describe('components/actions_menu/ActionsMenu on mobile view', () => {
showTutorialTip: false, showTutorialTip: false,
appsEnabled: false, appsEnabled: false,
isSysAdmin: true, isSysAdmin: true,
canOpenMarketplace: false,
}; };
const wrapper = shallow( const wrapper = shallow(

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

@@ -26,6 +26,10 @@ import {GlobalState} from 'types/store';
import {openModal} from 'actions/views/modals'; import {openModal} from 'actions/views/modals';
import {makeFetchBindings, postEphemeralCallResponseForPost, handleBindingClick, openAppsModal} from 'actions/apps'; import {makeFetchBindings, postEphemeralCallResponseForPost, handleBindingClick, openAppsModal} from 'actions/apps';
import {Permissions} from 'mattermost-redux/constants';
import {isMarketplaceEnabled} from 'mattermost-redux/selectors/entities/general';
import {haveICurrentTeamPermission} from 'mattermost-redux/selectors/entities/roles';
import ActionsMenu from './actions_menu'; import ActionsMenu from './actions_menu';
import {makeGetPostOptionBinding} from './selectors'; import {makeGetPostOptionBinding} from './selectors';
@@ -65,6 +69,10 @@ function mapStateToProps(state: GlobalState, ownProps: Props) {
pluginMenuItems: state.plugins.components.PostDropdownMenu, pluginMenuItems: state.plugins.components.PostDropdownMenu,
teamId: getCurrentTeamId(state), teamId: getCurrentTeamId(state),
isMobileView: getIsMobileView(state), isMobileView: getIsMobileView(state),
canOpenMarketplace: (
isMarketplaceEnabled(state) &&
haveICurrentTeamPermission(state, Permissions.SYSCONSOLE_WRITE_PLUGINS)
),
}; };
} }

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

@@ -1,63 +1,38 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP // Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/app_bar/app_bar should match snapshot on mount 1`] = ` exports[`components/app_bar/app_bar should match snapshot on mount 1`] = `
.c0:last-child,
.c0:first-child {
display: none;
}
<AppBar> <AppBar>
<div <div
className="app-bar" className="app-bar"
> >
<AppBarPluginComponent <div
component={ className="app-bar__top"
Object {
"action": [MockFunction],
"icon": "fallback_component",
"id": "the_component_id",
"pluginId": "playbooks",
"tooltipText": "Playbooks Tooltip",
}
}
key="the_component_id"
> >
<OverlayTrigger <AppBarPluginComponent
defaultOverlayShown={false} component={
delayShow={400} Object {
overlay={ "action": [MockFunction],
<Tooltip "icon": "fallback_component",
bsClass="tooltip" "id": "the_component_id",
id="pluginTooltip-app-bar-icon-playbooks" "pluginId": "playbooks",
placement="right" "tooltipText": "Playbooks Tooltip",
> }
<span>
Playbooks Tooltip
</span>
</Tooltip>
}
placement="left"
trigger={
Array [
"hover",
"focus",
]
} }
key="the_component_id"
> >
<OverlayTrigger <OverlayTrigger
defaultOverlayShown={false} defaultOverlayShown={false}
delayShow={400} delayShow={400}
overlay={ overlay={
<OverlayWrapper <Tooltip
bsClass="tooltip" bsClass="tooltip"
id="pluginTooltip-app-bar-icon-playbooks" id="pluginTooltip-app-bar-icon-playbooks"
intl={null}
placement="right" placement="right"
> >
<span> <span>
Playbooks Tooltip Playbooks Tooltip
</span> </span>
</OverlayWrapper> </Tooltip>
} }
placement="left" placement="left"
trigger={ trigger={
@@ -67,77 +42,73 @@ exports[`components/app_bar/app_bar should match snapshot on mount 1`] = `
] ]
} }
> >
<div <OverlayTrigger
className="app-bar__icon" defaultOverlayShown={false}
id="app-bar-icon-playbooks" delayShow={400}
onBlur={[Function]} overlay={
onClick={[Function]} <OverlayWrapper
onFocus={[Function]} bsClass="tooltip"
onMouseOut={[Function]} id="pluginTooltip-app-bar-icon-playbooks"
onMouseOver={[Function]} intl={null}
placement="right"
>
<span>
Playbooks Tooltip
</span>
</OverlayWrapper>
}
placement="left"
trigger={
Array [
"hover",
"focus",
]
}
> >
<div <div
className="app-bar__old-icon app-bar__icon-inner app-bar__icon-inner--centered" className="app-bar__icon"
id="app-bar-icon-playbooks"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
> >
fallback_component <div
className="app-bar__old-icon app-bar__icon-inner app-bar__icon-inner--centered"
>
fallback_component
</div>
</div> </div>
</div> </OverlayTrigger>
</OverlayTrigger> </OverlayTrigger>
</OverlayTrigger> </AppBarPluginComponent>
</AppBarPluginComponent>
<_StyledHr
className="app-bar__divider"
key="divider"
>
<hr <hr
className="c0 app-bar__divider" className="app-bar__divider"
key="divider"
/> />
</_StyledHr> <AppBarBinding
<AppBarBinding binding={
binding={ Object {
Object { "app_id": "com.mattermost.zendesk",
"app_id": "com.mattermost.zendesk", "label": "Create Subscription",
"label": "Create Subscription", }
}
}
key="com.mattermost.zendesk_Create Subscription"
>
<OverlayTrigger
defaultOverlayShown={false}
delayShow={400}
overlay={
<Tooltip
bsClass="tooltip"
id="tooltip-app-bar-icon-com.mattermost.zendesk"
placement="right"
>
<span>
Create Subscription
</span>
</Tooltip>
}
placement="left"
trigger={
Array [
"hover",
"focus",
]
} }
key="com.mattermost.zendesk_Create Subscription"
> >
<OverlayTrigger <OverlayTrigger
defaultOverlayShown={false} defaultOverlayShown={false}
delayShow={400} delayShow={400}
overlay={ overlay={
<OverlayWrapper <Tooltip
bsClass="tooltip" bsClass="tooltip"
id="tooltip-app-bar-icon-com.mattermost.zendesk" id="tooltip-app-bar-icon-com.mattermost.zendesk"
intl={null}
placement="right" placement="right"
> >
<span> <span>
Create Subscription Create Subscription
</span> </span>
</OverlayWrapper> </Tooltip>
} }
placement="left" placement="left"
trigger={ trigger={
@@ -147,25 +118,49 @@ exports[`components/app_bar/app_bar should match snapshot on mount 1`] = `
] ]
} }
> >
<div <OverlayTrigger
aria-label="Create Subscription" defaultOverlayShown={false}
className="app-bar__icon" delayShow={400}
id="app-bar-icon-com.mattermost.zendesk" overlay={
onBlur={[Function]} <OverlayWrapper
onClick={[Function]} bsClass="tooltip"
onFocus={[Function]} id="tooltip-app-bar-icon-com.mattermost.zendesk"
onMouseOut={[Function]} intl={null}
onMouseOver={[Function]} placement="right"
>
<span>
Create Subscription
</span>
</OverlayWrapper>
}
placement="left"
trigger={
Array [
"hover",
"focus",
]
}
> >
<div <div
className="app-bar__icon-inner" aria-label="Create Subscription"
className="app-bar__icon"
id="app-bar-icon-com.mattermost.zendesk"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
> >
<img /> <div
className="app-bar__icon-inner"
>
<img />
</div>
</div> </div>
</div> </OverlayTrigger>
</OverlayTrigger> </OverlayTrigger>
</OverlayTrigger> </AppBarBinding>
</AppBarBinding> </div>
</div> </div>
</AppBar> </AppBar>
`; `;

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

@@ -1,6 +1,8 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
@import 'utils/mixins';
$app-bar-icon-size: 24px; $app-bar-icon-size: 24px;
$app-bar-width: 48px; $app-bar-width: 48px;
@@ -10,129 +12,143 @@ $app-bar-width: 48px;
display: none; display: none;
} }
position: relative; display: flex;
width: $app-bar-width; min-height: 0;
padding-top: 16px; flex-flow: column;
border-left: solid 1px rgba(var(--center-channel-color-rgb), 0.12);
background-color: var(--center-channel-bg); background-color: var(--center-channel-bg);
-ms-overflow-style: none;
overflow-x: hidden;
overflow-y: scroll;
scrollbar-width: none;
text-align: center;
&::before { &__top {
position: absolute;
top: 0;
display: block;
width: 100%;
height: 100%;
border-left: solid 1px rgba(var(--center-channel-color-rgb), 0.12);
background-color: rgba(var(--center-channel-color-rgb), 0.04);
content: '';
}
.app-bar__icon {
position: relative; position: relative;
// Render App Bar icons on top of the RHS background div display: flex;
//(see `@media screen and (min-width: 769px) > #sidebar-right` in sass/layout/_sidebar-right.scss) width: $app-bar-width;
z-index: 21; flex: 1;
width: 100%; flex-flow: column;
border-left: none; padding-top: 16px;
margin-bottom: 16px; background-color: rgba(var(--center-channel-color-rgb), 0.04);
cursor: pointer; -ms-overflow-style: none;
overflow-x: hidden;
overflow-y: scroll;
scrollbar-width: none;
text-align: center;
&--active { .app-bar__icon {
&::before { position: relative;
position: absolute; // Render App Bar icons on top of the RHS background div
top: 0; //(see `@media screen and (min-width: 769px) > #sidebar-right` in sass/layout/_sidebar-right.scss)
left: 0; z-index: 21;
width: 3px; width: 100%;
height: $app-bar-icon-size; border-left: none;
background-color: var(--sidebar-text-active-border); margin-bottom: 16px;
border-radius: 0 2px 2px 0; cursor: pointer;
content: '';
&--active {
&::before {
position: absolute;
top: 0;
left: 0;
width: 3px;
height: $app-bar-icon-size;
background-color: var(--sidebar-text-active-border);
border-radius: 0 2px 2px 0;
content: '';
}
.app-bar__icon-inner,
span:not(.pulsating_dot) {
// if we want to show a tourtip/pulsating dot in any of the app bar icons, these styles must be ommitted when span.pulsating_dot
box-shadow: 0 0 0 2px var(--sidebar-text-active-border);
&:hover {
box-shadow: 0 0 0 2px rgba(var(--sidebar-text-active-border-rgb), 0.92) !important;
}
}
} }
.app-bar__icon-inner, .app-bar__icon-inner,
span:not(.pulsating_dot) { span:not(.pulsating_dot) {
// if we want to show a tourtip/pulsating dot in any of the app bar icons, these styles must be ommitted when span.pulsating_dot display: block;
box-shadow: 0 0 0 2px var(--sidebar-text-active-border); overflow: hidden;
&:hover {
box-shadow: 0 0 0 2px rgba(var(--sidebar-text-active-border-rgb), 0.92) !important;
}
}
}
.app-bar__icon-inner,
span:not(.pulsating_dot) {
display: block;
overflow: hidden;
width: $app-bar-icon-size;
height: $app-bar-icon-size;
margin: 0 auto;
border-radius: 50%;
line-height: 1;
&:hover {
box-shadow: 0 0 0 2px rgba(var(--center-channel-color-rgb), 0.16);
}
img {
width: $app-bar-icon-size; width: $app-bar-icon-size;
height: $app-bar-icon-size; height: $app-bar-icon-size;
margin: 0 auto;
border-radius: 50%; border-radius: 50%;
} line-height: 1;
}
span:not(.pulsating_dot) { &:hover {
padding: 2px; box-shadow: 0 0 0 2px rgba(var(--center-channel-color-rgb), 0.16);
background-color: white; }
fill: var(--button-bg);
font-size: 14px;
line-height: 20px;
vertical-align: middle;
&.CompassIcon, img {
&.icon-brand-zoom { width: $app-bar-icon-size;
font-size: 20px; height: $app-bar-icon-size;
border-radius: 50%;
&::before {
margin: 0 0 0 0.5px;
} }
} }
}
.app-bar__old-icon { span:not(.pulsating_dot) {
color: rgba(var(--center-channel-color-rgb), 0.56); padding: 2px;
background-color: white;
fill: var(--button-bg);
font-size: 14px;
line-height: 20px;
vertical-align: middle;
&:hover, &.CompassIcon,
&--active { &.icon-brand-zoom {
color: rgba(var(--center-channel-color-rgb), 0.72); font-size: 20px;
&::before {
margin: 0 0 0 0.5px;
}
}
}
.app-bar__old-icon {
color: rgba(var(--center-channel-color-rgb), 0.56);
&:hover,
&--active {
color: rgba(var(--center-channel-color-rgb), 0.72);
}
}
.app-bar__icon-inner--centered {
display: grid;
place-items: center;
} }
} }
.app-bar__icon-inner--centered { .app-bar__divider {
display: grid; width: 28px;
place-items: center; border-top: 1px solid rgba(var(--center-channel-color-rgb), 0.16);
margin-top: 14px;
margin-bottom: 14px;
}
.app-bar__icon.channel-header__icon--active {
background: rgba(var(--button-bg-rgb), 0.08);
color: var(--button-bg);
fill: var(--button-bg);
} }
} }
.app-bar__divider { &__bottom {
width: 28px; display: flex;
border-top: 1px solid rgba(var(--center-channel-color-rgb), 0.16); flex-flow: column;
margin-top: 14px; align-items: center;
margin-bottom: 14px; padding-top: 24px;
} padding-bottom: 36px;
background-color: rgba(var(--center-channel-color-rgb), 0.04);
.app-bar__icon.channel-header__icon--active { .app_bar__marketplace_button {
background: rgba(var(--button-bg-rgb), 0.08); @include icon-button;
color: var(--button-bg); @include icon-button-small-compact;
fill: var(--button-bg); }
} }
} }
// This style is defined outside the .app-bar block above because it doesn't seem to work when defined there // This style is defined outside the .app-bar block above because it doesn't seem to work when defined there
.app-bar::-webkit-scrollbar { .app-bar__top::-webkit-scrollbar {
display: none; display: none;
} }

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

@@ -2,7 +2,7 @@
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import React from 'react'; import React from 'react';
import {mount} from 'enzyme'; import {mount, shallow} from 'enzyme';
import 'jest-styled-components'; import 'jest-styled-components';
import {AppBinding} from '@mattermost/types/apps'; import {AppBinding} from '@mattermost/types/apps';
@@ -10,6 +10,7 @@ import {AppBinding} from '@mattermost/types/apps';
import {PluginComponent} from 'types/store/plugins'; import {PluginComponent} from 'types/store/plugins';
import {GlobalState} from 'types/store'; import {GlobalState} from 'types/store';
import {Permissions} from 'mattermost-redux/constants';
import {AppBindingLocations} from 'mattermost-redux/constants/apps'; import {AppBindingLocations} from 'mattermost-redux/constants/apps';
import AppBar from './app_bar'; import AppBar from './app_bar';
@@ -82,6 +83,21 @@ describe('components/app_bar/app_bar', () => {
myPreferences: { myPreferences: {
}, },
} as any, } as any,
users: {
currentUserId: 'user1',
profiles: {
user1: {
roles: 'system_user',
},
},
} as any,
roles: {
roles: {
system_user: {
permissions: [],
},
},
} as any,
}, },
} as GlobalState; } as GlobalState;
}); });
@@ -134,4 +150,48 @@ describe('components/app_bar/app_bar', () => {
expect(wrapper).toMatchSnapshot(); expect(wrapper).toMatchSnapshot();
}); });
test('should not show marketplace if disabled or user does not have SYSCONSOLE_WRITE_PLUGINS permission', async () => {
mockState.entities.general = {
config: {
EnableAppBar: 'true',
FeatureFlagAppsEnabled: 'true',
EnableMarketplace: 'true',
PluginsEnabled: 'true',
},
} as any;
const wrapper = shallow(
<AppBar/>,
);
expect(wrapper.find('AppBarMarketplace').exists()).toEqual(false);
});
test('should show marketplace if enabled and user has SYSCONSOLE_WRITE_PLUGINS permission', async () => {
mockState.entities.general = {
config: {
EnableAppBar: 'true',
FeatureFlagAppsEnabled: 'true',
EnableMarketplace: 'true',
PluginsEnabled: 'true',
},
} as any;
mockState.entities.roles = {
roles: {
system_user: {
permissions: [
Permissions.SYSCONSOLE_WRITE_PLUGINS,
],
},
},
} as any;
const wrapper = shallow(
<AppBar/>,
);
expect(wrapper.find('AppBarMarketplace').exists()).toEqual(true);
});
}); });

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

@@ -12,8 +12,15 @@ import {getAppBarAppBindings} from 'mattermost-redux/selectors/entities/apps';
import {getAppBarPluginComponents, getChannelHeaderPluginComponents, shouldShowAppBar} from 'selectors/plugins'; import {getAppBarPluginComponents, getChannelHeaderPluginComponents, shouldShowAppBar} from 'selectors/plugins';
import {suitePluginIds} from 'utils/constants'; import {suitePluginIds} from 'utils/constants';
import {Permissions} from 'mattermost-redux/constants';
import {isMarketplaceEnabled} from 'mattermost-redux/selectors/entities/general';
import {haveICurrentTeamPermission} from 'mattermost-redux/selectors/entities/roles';
import {GlobalState} from '@mattermost/types/store';
import AppBarPluginComponent, {isAppBarPluginComponent} from './app_bar_plugin_component'; import AppBarPluginComponent, {isAppBarPluginComponent} from './app_bar_plugin_component';
import AppBarBinding, {isAppBinding} from './app_bar_binding'; import AppBarBinding, {isAppBinding} from './app_bar_binding';
import AppBarMarketplace from './app_bar_marketplace';
import './app_bar.scss'; import './app_bar.scss';
@@ -24,6 +31,10 @@ export default function AppBar() {
const currentProduct = useCurrentProduct(); const currentProduct = useCurrentProduct();
const currentProductId = useCurrentProductId(); const currentProductId = useCurrentProductId();
const enabled = useSelector(shouldShowAppBar); const enabled = useSelector(shouldShowAppBar);
const canOpenMarketplace = useSelector((state: GlobalState) => (
isMarketplaceEnabled(state) &&
haveICurrentTeamPermission(state, Permissions.SYSCONSOLE_WRITE_PLUGINS)
));
if ( if (
!enabled || !enabled ||
@@ -40,11 +51,15 @@ export default function AppBar() {
const items: ReactNode[] = [ const items: ReactNode[] = [
...coreProductComponents, ...coreProductComponents,
divider, getDivider(coreProductComponents.length, (pluginComponents.length + channelHeaderComponents.length + appBarBindings.length)),
...pluginComponents, ...pluginComponents,
...channelHeaderComponents, ...channelHeaderComponents,
...appBarBindings, ...appBarBindings,
].map((x) => { ].map((x) => {
if (!x) {
return x;
}
if (isAppBarPluginComponent(x)) { if (isAppBarPluginComponent(x)) {
if (!inScope(x.supportedProductIds ?? null, currentProductId, currentProduct?.pluginId)) { if (!inScope(x.supportedProductIds ?? null, currentProductId, currentProduct?.pluginId)) {
return null; return null;
@@ -69,26 +84,23 @@ export default function AppBar() {
return x; return x;
}); });
if (!items.some((x) => Boolean(x) && x !== divider)) {
return null;
}
return ( return (
<div className={'app-bar'}> <div className={'app-bar'}>
{items} <div className={'app-bar__top'}>
{items}
</div>
{canOpenMarketplace && (
<div className='app-bar__bottom'>
<AppBarMarketplace/>
</div>
)}
</div> </div>
); );
} }
const divider = ( const getDivider = (beforeCount: number, afterCount: number) => (beforeCount && afterCount ? (
<hr <hr
key='divider' key='divider'
className={'app-bar__divider'} className='app-bar__divider'
// eslint-disable-next-line react/no-unknown-property
css={`
:last-child, :first-child {
display: none;
}
`}
/> />
); ) : null);

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

@@ -0,0 +1,60 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {useDispatch} from 'react-redux';
import {useIntl} from 'react-intl';
import {Tooltip} from 'react-bootstrap';
import Icon from '@mattermost/compass-components/foundations/icon';
import {openModal} from 'actions/views/modals';
import MarketplaceModal from 'components/plugin_marketplace';
import OverlayTrigger from 'components/overlay_trigger';
import {Constants, ModalIdentifiers} from 'utils/constants';
const AppBarMarketplace = () => {
const {formatMessage} = useIntl();
const dispatch = useDispatch();
const handleOpenMarketplace = useCallback(() => {
dispatch(
openModal({
modalId: ModalIdentifiers.PLUGIN_MARKETPLACE,
dialogType: MarketplaceModal,
dialogProps: {openedFrom: 'app_bar'},
}),
);
}, [dispatch]);
const label = formatMessage({id: 'app_bar.marketplace', defaultMessage: 'App Marketplace'});
return (
<OverlayTrigger
trigger={['hover', 'focus']}
delayShow={Constants.OVERLAY_TIME_DELAY}
placement='left'
overlay={(
<Tooltip id='tooltip-app-bar-marketplace'>
<span>{label}</span>
</Tooltip>
)}
>
<button
key='app_bar_marketplace'
className='app_bar__marketplace_button'
aria-label={label}
onClick={handleOpenMarketplace}
>
<Icon
size={16}
glyph={'view-grid-plus-outline'}
/>
</button>
</OverlayTrigger>
);
};
export default AppBarMarketplace;

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

@@ -301,6 +301,7 @@ export default function OpenPluginInstallPost(props: {post: Post}) {
className='color--link' className='color--link'
modalId={ModalIdentifiers.PLUGIN_MARKETPLACE} modalId={ModalIdentifiers.PLUGIN_MARKETPLACE}
dialogType={MarketplaceModal} dialogType={MarketplaceModal}
dialogProps={{openedFrom: 'open_plugin_install_post'}}
> >
{text} {text}
</ToggleModalButton> </ToggleModalButton>

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

@@ -147,6 +147,11 @@ exports[`components/global/product_switcher_menu should match snapshot with id 1
teamId="" teamId=""
> >
<MenuItemToggleModalRedux <MenuItemToggleModalRedux
dialogProps={
Object {
"openedFrom": "product_menu",
}
}
dialogType={ dialogType={
Object { Object {
"$$typeof": Symbol(react.memo), "$$typeof": Symbol(react.memo),
@@ -157,14 +162,14 @@ exports[`components/global/product_switcher_menu should match snapshot with id 1
} }
icon={ icon={
<Icon <Icon
glyph="apps" glyph="view-grid-plus-outline"
size={16} size={16}
/> />
} }
id="marketplaceModal" id="marketplaceModal"
modalId="plugin_marketplace" modalId="plugin_marketplace"
show={false} show={false}
text="Marketplace" text="App Marketplace"
/> />
</Connect(TeamPermissionGate)> </Connect(TeamPermissionGate)>
<MenuItemExternalLink <MenuItemExternalLink
@@ -350,6 +355,11 @@ exports[`components/global/product_switcher_menu should match snapshot with most
teamId="" teamId=""
> >
<MenuItemToggleModalRedux <MenuItemToggleModalRedux
dialogProps={
Object {
"openedFrom": "product_menu",
}
}
dialogType={ dialogType={
Object { Object {
"$$typeof": Symbol(react.memo), "$$typeof": Symbol(react.memo),
@@ -360,14 +370,14 @@ exports[`components/global/product_switcher_menu should match snapshot with most
} }
icon={ icon={
<Icon <Icon
glyph="apps" glyph="view-grid-plus-outline"
size={16} size={16}
/> />
} }
id="marketplaceModal" id="marketplaceModal"
modalId="plugin_marketplace" modalId="plugin_marketplace"
show={true} show={true}
text="Marketplace" text="App Marketplace"
/> />
</Connect(TeamPermissionGate)> </Connect(TeamPermissionGate)>
<MenuItemExternalLink <MenuItemExternalLink
@@ -673,6 +683,11 @@ exports[`components/global/product_switcher_menu should show integrations should
teamId="" teamId=""
> >
<MenuItemToggleModalRedux <MenuItemToggleModalRedux
dialogProps={
Object {
"openedFrom": "product_menu",
}
}
dialogType={ dialogType={
Object { Object {
"$$typeof": Symbol(react.memo), "$$typeof": Symbol(react.memo),
@@ -683,14 +698,14 @@ exports[`components/global/product_switcher_menu should show integrations should
} }
icon={ icon={
<Icon <Icon
glyph="apps" glyph="view-grid-plus-outline"
size={16} size={16}
/> />
} }
id="marketplaceModal" id="marketplaceModal"
modalId="plugin_marketplace" modalId="plugin_marketplace"
show={false} show={false}
text="Marketplace" text="App Marketplace"
/> />
</Connect(TeamPermissionGate)> </Connect(TeamPermissionGate)>
<MenuItemExternalLink <MenuItemExternalLink

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

@@ -213,11 +213,12 @@ const ProductMenuList = (props: Props): JSX.Element | null => {
modalId={ModalIdentifiers.PLUGIN_MARKETPLACE} modalId={ModalIdentifiers.PLUGIN_MARKETPLACE}
show={isMessaging && !isMobile && enablePluginMarketplace} show={isMessaging && !isMobile && enablePluginMarketplace}
dialogType={MarketplaceModal} dialogType={MarketplaceModal}
text={formatMessage({id: 'navbar_dropdown.marketplace', defaultMessage: 'Marketplace'})} dialogProps={{openedFrom: 'product_menu'}}
text={formatMessage({id: 'navbar_dropdown.marketplace', defaultMessage: 'App Marketplace'})}
icon={ icon={
<Icon <Icon
size={16} size={16}
glyph={'apps'} glyph='view-grid-plus-outline'
/> />
} }
/> />

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

@@ -119,6 +119,7 @@ describe('components/marketplace/', () => {
pluginStatuses: {}, pluginStatuses: {},
siteURL: 'http://example.com', siteURL: 'http://example.com',
firstAdminVisitMarketplaceStatus: false, firstAdminVisitMarketplaceStatus: false,
openedFrom: 'actions_menu',
actions: { actions: {
closeModal: jest.fn(), closeModal: jest.fn(),
fetchListing: jest.fn(() => { fetchListing: jest.fn(() => {
@@ -191,8 +192,21 @@ describe('components/marketplace/', () => {
wrapper.setState({filter: 'nps'}); wrapper.setState({filter: 'nps'});
wrapper.instance().doSearch(); wrapper.instance().doSearch();
expect(trackEvent).toHaveBeenCalledWith('plugins', 'ui_marketplace_opened'); expect(trackEvent).toHaveBeenCalledWith('plugins', 'ui_marketplace_opened', {from: 'actions_menu'});
expect(trackEvent).toHaveBeenCalledWith('plugins', 'ui_marketplace_search', {filter: 'nps'}); expect(trackEvent).toHaveBeenCalledWith('plugins', 'ui_marketplace_search', {filter: 'nps'});
}); });
test('Should call for opened track event on mount', () => {
const openedFrom = 'actions_menu';
shallow<MarketplaceModal>(
<MarketplaceModal
{...baseProps}
openedFrom={openedFrom}
/>,
);
expect(trackEvent).toHaveBeenCalledWith('plugins', 'ui_marketplace_opened', {from: openedFrom});
});
}); });
}); });

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

@@ -31,6 +31,8 @@ const MarketplaceTabs = {
const SEARCH_TIMEOUT_MILLISECONDS = 200; const SEARCH_TIMEOUT_MILLISECONDS = 200;
export type OpenedFromType = 'actions_menu' | 'app_bar' | 'channel_header' | 'command' | 'open_plugin_install_post' | 'product_menu';
type AllListingProps = { type AllListingProps = {
listing: Array<MarketplacePlugin | MarketplaceApp>; listing: Array<MarketplacePlugin | MarketplaceApp>;
}; };
@@ -97,6 +99,7 @@ export type MarketplaceModalProps = {
siteURL: string; siteURL: string;
pluginStatuses?: Record<string, PluginStatusRedux>; pluginStatuses?: Record<string, PluginStatusRedux>;
firstAdminVisitMarketplaceStatus: boolean; firstAdminVisitMarketplaceStatus: boolean;
openedFrom: OpenedFromType;
actions: { actions: {
closeModal: () => void; closeModal: () => void;
fetchListing(localOnly?: boolean): Promise<{error?: Error}>; fetchListing(localOnly?: boolean): Promise<{error?: Error}>;
@@ -131,7 +134,7 @@ export default class MarketplaceModal extends React.PureComponent<MarketplaceMod
} }
componentDidMount(): void { componentDidMount(): void {
trackEvent('plugins', 'ui_marketplace_opened'); trackEvent('plugins', 'ui_marketplace_opened', {from: this.props.openedFrom});
this.fetchListing(); this.fetchListing();
this.props.actions.getPluginStatuses(); this.props.actions.getPluginStatuses();

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

@@ -2645,6 +2645,7 @@
"api.team.join_team.post_and_forget": "{username} joined the team.", "api.team.join_team.post_and_forget": "{username} joined the team.",
"api.team.leave.left": "{username} left the team.", "api.team.leave.left": "{username} left the team.",
"api.team.remove_user_from_team.removed": "{removedUsername} was removed from the team.", "api.team.remove_user_from_team.removed": "{removedUsername} was removed from the team.",
"app_bar.marketplace": "App Marketplace",
"app.channel.post_update_channel_purpose_message.removed": "{username} removed the channel purpose (was: {old})", "app.channel.post_update_channel_purpose_message.removed": "{username} removed the channel purpose (was: {old})",
"app.channel.post_update_channel_purpose_message.updated_from": "{username} updated the channel purpose from: {old} to: {new}", "app.channel.post_update_channel_purpose_message.updated_from": "{username} updated the channel purpose from: {old} to: {new}",
"app.channel.post_update_channel_purpose_message.updated_to": "{username} updated the channel purpose to: {new}", "app.channel.post_update_channel_purpose_message.updated_to": "{username} updated the channel purpose to: {new}",
@@ -4222,7 +4223,7 @@
"navbar_dropdown.logout": "Log Out", "navbar_dropdown.logout": "Log Out",
"navbar_dropdown.manageGroups": "Manage Groups", "navbar_dropdown.manageGroups": "Manage Groups",
"navbar_dropdown.manageMembers": "Manage Members", "navbar_dropdown.manageMembers": "Manage Members",
"navbar_dropdown.marketplace": "Marketplace", "navbar_dropdown.marketplace": "App Marketplace",
"navbar_dropdown.menuAriaLabel": "main menu", "navbar_dropdown.menuAriaLabel": "main menu",
"navbar_dropdown.nativeApps": "Download Apps", "navbar_dropdown.nativeApps": "Download Apps",
"navbar_dropdown.profileSettings": "Profile", "navbar_dropdown.profileSettings": "Profile",

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

@@ -262,6 +262,49 @@
} }
} }
@mixin icon-button {
display: flex;
align-items: center;
justify-content: center;
border: 0;
background: none;
border-radius: 4px;
color: rgba(var(--center-channel-color-rgb), 0.56);
&:focus,
&:focus-within {
box-sizing: border-box;
border: 2px solid rgba(var(--denim-button-bg-rgb), 0.32);
box-shadow: none;
outline: none;
}
&:hover {
border: 0;
background: rgba(var(--center-channel-color-rgb), 0.08);
color: rgba(var(--center-channel-color-rgb), 0.72);
}
&:active {
border: 0;
background: rgba(var(--button-bg-rgb), 0.08);
color: var(--button-bg);
}
&:disabled {
background: none;
color: rgba(var(--center-channel-color-rgb), 0.32);
cursor: not-allowed;
}
}
@mixin icon-button-small-compact {
width: 28px;
height: 28px;
padding: 6px;
font-size: 16px;
}
@mixin simple-in-and-out-after($classPrefix, $transition_time: 300ms) { @mixin simple-in-and-out-after($classPrefix, $transition_time: 300ms) {
.#{$classPrefix}--enter-from-after { .#{$classPrefix}--enter-from-after {
&-enter { &-enter {