MM-50252_Refresh UI of the marketplace to improve first impressions (#22698)

Этот коммит содержится в:
Julian Mondragón
2023-04-03 12:10:39 -05:00
коммит произвёл GitHub
родитель 69657be9f2
Коммит 22bc41effa
43 изменённых файлов: 1890 добавлений и 1652 удалений

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

@@ -35,7 +35,7 @@ import KeyboardShortcutsModal from 'components/keyboard_shortcuts/keyboard_short
import {GlobalState} from 'types/store';
import {t} from 'utils/i18n';
import MarketplaceModal from 'components/plugin_marketplace';
import MarketplaceModal from 'components/plugin_marketplace/marketplace_modal';
import WorkTemplateModal from 'components/work_templates';
import {haveICurrentTeamPermission} from 'mattermost-redux/selectors/entities/roles';
import {Permissions} from 'mattermost-redux/constants';

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

@@ -59,7 +59,7 @@ exports[`components/GenericModal should match snapshot for base case 1`] = `
</h1>
</div>
<div
className="GenericModal__body"
className="GenericModal__body padding"
/>
</ModalBody>
</div>
@@ -126,11 +126,12 @@ exports[`components/GenericModal should match snapshot with both buttons 1`] = `
</h1>
</div>
<div
className="GenericModal__body"
className="GenericModal__body padding"
/>
</ModalBody>
<ModalFooter
bsClass="modal-footer"
className=""
componentClass="div"
>
<button
@@ -218,11 +219,12 @@ exports[`components/GenericModal should match snapshot with disabled confirm but
</h1>
</div>
<div
className="GenericModal__body"
className="GenericModal__body padding"
/>
</ModalBody>
<ModalFooter
bsClass="modal-footer"
className=""
componentClass="div"
>
<button

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

@@ -19,8 +19,7 @@ import {Locations, Constants, ModalIdentifiers} from 'utils/constants';
import Permissions from 'mattermost-redux/constants/permissions';
import {ActionsTutorialTip} from 'components/actions_menu/actions_menu_tutorial_tip';
import {ModalData} from 'types/actions';
import MarketplaceModal from 'components/plugin_marketplace';
import {OpenedFromType} from 'components/plugin_marketplace/marketplace_modal';
import MarketplaceModal, {OpenedFromType} from 'components/plugin_marketplace/marketplace_modal';
import OverlayTrigger from 'components/overlay_trigger';
import * as PostUtils from 'utils/post_utils';
import * as Utils from 'utils/utils';

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

@@ -10,7 +10,7 @@ import Icon from '@mattermost/compass-components/foundations/icon';
import {openModal} from 'actions/views/modals';
import MarketplaceModal from 'components/plugin_marketplace';
import MarketplaceModal from 'components/plugin_marketplace/marketplace_modal';
import OverlayTrigger from 'components/overlay_trigger';
import {Constants, ModalIdentifiers} from 'utils/constants';

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

@@ -10,7 +10,7 @@ import {uniqWith} from 'lodash';
import {Post} from '@mattermost/types/posts';
import {MarketplacePlugin} from '@mattermost/types/marketplace';
import MarketplaceModal from 'components/plugin_marketplace';
import MarketplaceModal from 'components/plugin_marketplace/marketplace_modal';
import Markdown from 'components/markdown';
import {getUsers} from 'mattermost-redux/selectors/entities/users';

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

@@ -4,6 +4,7 @@ exports[`components/drafts/draft_actions/delete_draft_modal should have called o
<GenericModal
autoCloseOnCancelButton={true}
autoCloseOnConfirmButton={true}
bodyPadding={true}
compassDesign={true}
confirmButtonText="Yes, delete"
enforceFocus={true}
@@ -45,6 +46,7 @@ exports[`components/drafts/draft_actions/delete_draft_modal should have called o
<GenericModal
autoCloseOnCancelButton={true}
autoCloseOnConfirmButton={true}
bodyPadding={true}
compassDesign={true}
confirmButtonText="Yes, delete"
enforceFocus={true}

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

@@ -4,6 +4,7 @@ exports[`components/drafts/draft_actions/send_draft_modal should have called onC
<GenericModal
autoCloseOnCancelButton={true}
autoCloseOnConfirmButton={true}
bodyPadding={true}
compassDesign={true}
confirmButtonText="Yes, send now"
enforceFocus={true}
@@ -44,6 +45,7 @@ exports[`components/drafts/draft_actions/send_draft_modal should have called onE
<GenericModal
autoCloseOnCancelButton={true}
autoCloseOnConfirmButton={true}
bodyPadding={true}
compassDesign={true}
confirmButtonText="Yes, send now"
enforceFocus={true}

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

@@ -104,6 +104,10 @@
padding: 12px 24px 24px 24px;
border: none;
border-radius: 4px;
&.divider {
border-top: 1px solid rgba(63, 67, 80, 0.08);
}
}
&.GenericModal__compassDesign {
@@ -112,10 +116,11 @@
border-radius: 12px;
.modal-header {
flex-direction: column;
padding: 26px 32px 7px;
.close {
top: 20px;
top: 22px;
right: 18px;
width: 40px;
height: 40px;
@@ -133,7 +138,13 @@
}
.GenericModal__header {
display: flex;
flex: 1;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 0;
margin-right: 32px;
border-top-left-radius: 12px;
border-top-right-radius: 12px;
@@ -145,10 +156,12 @@
}
.modal-body {
padding: 15px 32px 0;
.GenericModal__body {
padding: 0;
&.padding {
padding: 15px 32px 0;
}
}
}

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

@@ -11,5 +11,8 @@ import './generic_modal.scss';
// find them there, so we've had to redefine them here
t('generic_modal.cancel');
t('generic_modal.confirm');
t('footer_pagination.count');
t('footer_pagination.prev');
t('footer_pagination.next');
export default GenericModal;

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

@@ -152,14 +152,7 @@ exports[`components/global/product_switcher_menu should match snapshot with id 1
"openedFrom": "product_menu",
}
}
dialogType={
Object {
"$$typeof": Symbol(react.memo),
"WrappedComponent": [Function],
"compare": null,
"type": [Function],
}
}
dialogType={[Function]}
icon={
<Icon
glyph="view-grid-plus-outline"
@@ -360,14 +353,7 @@ exports[`components/global/product_switcher_menu should match snapshot with most
"openedFrom": "product_menu",
}
}
dialogType={
Object {
"$$typeof": Symbol(react.memo),
"WrappedComponent": [Function],
"compare": null,
"type": [Function],
}
}
dialogType={[Function]}
icon={
<Icon
glyph="view-grid-plus-outline"
@@ -688,14 +674,7 @@ exports[`components/global/product_switcher_menu should show integrations should
"openedFrom": "product_menu",
}
}
dialogType={
Object {
"$$typeof": Symbol(react.memo),
"WrappedComponent": [Function],
"compare": null,
"type": [Function],
}
}
dialogType={[Function]}
icon={
<Icon
glyph="view-grid-plus-outline"

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

@@ -11,7 +11,7 @@ import {Permissions} from 'mattermost-redux/constants';
import AboutBuildModal from 'components/about_build_modal';
import SystemPermissionGate from 'components/permissions_gates/system_permission_gate';
import TeamPermissionGate from 'components/permissions_gates/team_permission_gate';
import MarketplaceModal from 'components/plugin_marketplace';
import MarketplaceModal from 'components/plugin_marketplace/marketplace_modal';
import Menu from 'components/widgets/menu/menu';
import RestrictedIndicator from 'components/widgets/menu/menu_items/restricted_indicator';
import {VisitSystemConsoleTour} from 'components/onboarding_tasks';

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

@@ -4,6 +4,7 @@ exports[`components/new_channel_modal should match snapshot 1`] = `
<GenericModal
autoCloseOnCancelButton={true}
autoCloseOnConfirmButton={false}
bodyPadding={true}
cancelButtonText="Cancel"
className="new-channel-modal"
compassDesign={true}

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

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

@@ -1,58 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {connect} from 'react-redux';
import {bindActionCreators, Dispatch, ActionCreatorsMapObject} from 'redux';
import {GenericAction, ActionFunc} from 'mattermost-redux/types/actions';
import {GlobalState} from 'types/store';
import {getListing, getInstalledListing} from 'selectors/views/marketplace';
import {setFirstAdminVisitMarketplaceStatus} from 'mattermost-redux/actions/general';
import {getPluginStatuses} from 'mattermost-redux/actions/admin';
import {getFirstAdminVisitMarketplaceStatus} from 'mattermost-redux/selectors/entities/general';
import {makeAsyncComponent} from 'components/async_load';
import {isModalOpen} from 'selectors/views/modals';
import {ModalIdentifiers} from 'utils/constants';
import {getSiteURL} from 'utils/url';
import {closeModal} from 'actions/views/modals';
import {fetchListing, filterListing} from 'actions/marketplace';
const MarketplaceModal = makeAsyncComponent('MarketplaceModal', React.lazy(() => import('./marketplace_modal')));
function mapStateToProps(state: GlobalState) {
return {
show: isModalOpen(state, ModalIdentifiers.PLUGIN_MARKETPLACE),
listing: getListing(state),
installedListing: getInstalledListing(state),
siteURL: getSiteURL(),
pluginStatuses: state.entities.admin.pluginStatuses,
firstAdminVisitMarketplaceStatus: getFirstAdminVisitMarketplaceStatus(state),
};
}
type Actions = {
closeModal(): void;
fetchListing(localOnly?: boolean): Promise<{error?: Error}>;
filterListing(filter: string): Promise<{error?: Error}>;
setFirstAdminVisitMarketplaceStatus(): Promise<void>;
getPluginStatuses(): Promise<void>;
}
function mapDispatchToProps(dispatch: Dispatch<GenericAction>) {
return {
actions: bindActionCreators<ActionCreatorsMapObject<ActionFunc | GenericAction>, Actions>({
closeModal: () => closeModal(ModalIdentifiers.PLUGIN_MARKETPLACE),
fetchListing,
filterListing,
setFirstAdminVisitMarketplaceStatus,
getPluginStatuses,
}, dispatch),
};
}
export default connect(mapStateToProps, mapDispatchToProps)(MarketplaceModal);

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

@@ -11,7 +11,7 @@ exports[`components/MarketplaceItemApp MarketplaceItem should render 1`] = `
}
button={
<button
className="btn btn-primary"
className="app-install always-show-enabled"
disabled={false}
onClick={[Function]}
>
@@ -50,7 +50,7 @@ exports[`components/MarketplaceItemApp MarketplaceItem should render installed a
}
button={
<button
className="btn btn-outline"
className="app-installed"
disabled={true}
>
<Memo(MemoizedFormattedMessage)
@@ -83,7 +83,7 @@ exports[`components/MarketplaceItemApp MarketplaceItem should render with empty
}
button={
<button
className="btn btn-primary"
className="app-install always-show-enabled"
disabled={false}
onClick={[Function]}
>
@@ -123,7 +123,7 @@ exports[`components/MarketplaceItemApp MarketplaceItem should render with icon 1
}
button={
<button
className="btn btn-primary"
className="app-install always-show-enabled"
disabled={false}
onClick={[Function]}
>
@@ -164,7 +164,7 @@ exports[`components/MarketplaceItemApp MarketplaceItem should render with no hom
}
button={
<button
className="btn btn-primary"
className="app-install always-show-enabled"
disabled={false}
onClick={[Function]}
>
@@ -202,7 +202,7 @@ exports[`components/MarketplaceItemApp MarketplaceItem should render with no plu
}
button={
<button
className="btn btn-primary"
className="app-install always-show-enabled"
disabled={false}
onClick={[Function]}
>
@@ -240,7 +240,7 @@ exports[`components/MarketplaceItemApp MarketplaceItem should render with one la
}
button={
<button
className="btn btn-primary"
className="app-install always-show-enabled"
disabled={false}
onClick={[Function]}
>
@@ -288,7 +288,7 @@ exports[`components/MarketplaceItemApp MarketplaceItem should render with server
}
button={
<button
className="btn btn-primary"
className="app-install always-show-enabled"
disabled={false}
onClick={[Function]}
>
@@ -328,7 +328,7 @@ exports[`components/MarketplaceItemApp MarketplaceItem should render with two la
}
button={
<button
className="btn btn-primary"
className="app-install always-show-enabled"
disabled={false}
onClick={[Function]}
>
@@ -381,7 +381,7 @@ exports[`components/MarketplaceItemApp MarketplaceItem when installing 1`] = `
}
button={
<button
className="btn btn-primary"
className="app-install always-show-enabled"
disabled={false}
onClick={[Function]}
>

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

@@ -50,7 +50,7 @@ export default class MarketplaceItemApp extends React.PureComponent <Marketplace
if (this.props.installed && !this.props.installing && !this.props.error) {
return (
<button
className='btn btn-outline'
className='app-installed'
disabled={true}
>
<FormattedMessage
@@ -81,7 +81,7 @@ export default class MarketplaceItemApp extends React.PureComponent <Marketplace
return (
<button
onClick={this.onInstall}
className='btn btn-primary'
className='app-install always-show-enabled'
disabled={this.props.installing}
>
<LoadingWrapper

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

@@ -11,7 +11,7 @@ exports[`components/MarketplaceItemPlugin MarketplaceItem should render 1`] = `
}
button={
<button
className="btn btn-primary"
className="plugin-install always-show-enabled"
disabled={false}
onClick={[Function]}
>
@@ -78,7 +78,7 @@ exports[`components/MarketplaceItemPlugin MarketplaceItem should render installe
to="/admin_console/plugins/plugin_id"
>
<button
className="btn btn-outline"
className="plugin-configure"
onClick={[Function]}
>
<Memo(MemoizedFormattedMessage)
@@ -137,7 +137,7 @@ exports[`components/MarketplaceItemPlugin MarketplaceItem should render with emp
}
button={
<button
className="btn btn-primary"
className="plugin-install always-show-enabled"
disabled={false}
onClick={[Function]}
>
@@ -202,7 +202,7 @@ exports[`components/MarketplaceItemPlugin MarketplaceItem should render with no
}
button={
<button
className="btn btn-primary"
className="plugin-install always-show-enabled"
disabled={false}
onClick={[Function]}
>
@@ -265,7 +265,7 @@ exports[`components/MarketplaceItemPlugin MarketplaceItem should render with no
}
button={
<button
className="btn btn-primary"
className="plugin-install always-show-enabled"
disabled={false}
onClick={[Function]}
>
@@ -328,7 +328,7 @@ exports[`components/MarketplaceItemPlugin MarketplaceItem should render with no
}
button={
<button
className="btn btn-primary"
className="plugin-install always-show-enabled"
disabled={false}
onClick={[Function]}
>
@@ -390,7 +390,7 @@ exports[`components/MarketplaceItemPlugin MarketplaceItem should render with one
}
button={
<button
className="btn btn-primary"
className="plugin-install always-show-enabled"
disabled={false}
onClick={[Function]}
>
@@ -463,7 +463,7 @@ exports[`components/MarketplaceItemPlugin MarketplaceItem should render with plu
}
button={
<button
className="btn btn-primary"
className="plugin-install always-show-enabled"
disabled={false}
onClick={[Function]}
>
@@ -540,7 +540,7 @@ exports[`components/MarketplaceItemPlugin MarketplaceItem should render with ser
}
button={
<button
className="btn btn-primary"
className="plugin-install always-show-enabled"
disabled={false}
onClick={[Function]}
>
@@ -605,7 +605,7 @@ exports[`components/MarketplaceItemPlugin MarketplaceItem should render with two
}
button={
<button
className="btn btn-primary"
className="plugin-install always-show-enabled"
disabled={false}
onClick={[Function]}
>
@@ -686,7 +686,7 @@ exports[`components/MarketplaceItemPlugin MarketplaceItem should render with upd
to="/admin_console/plugins/plugin_id"
>
<button
className="btn btn-outline"
className="plugin-configure"
onClick={[Function]}
>
<Memo(MemoizedFormattedMessage)
@@ -751,7 +751,7 @@ exports[`components/MarketplaceItemPlugin MarketplaceItem should render with upd
to="/admin_console/plugins/plugin_id"
>
<button
className="btn btn-outline"
className="plugin-configure"
onClick={[Function]}
>
<Memo(MemoizedFormattedMessage)

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

@@ -294,7 +294,7 @@ export default class MarketplaceItemPlugin extends React.PureComponent <Marketpl
>
<button
onClick={this.onConfigure}
className='btn btn-outline'
className='plugin-configure'
>
<FormattedMessage
id='marketplace_modal.list.configure'
@@ -325,7 +325,7 @@ export default class MarketplaceItemPlugin extends React.PureComponent <Marketpl
return (
<button
onClick={this.onInstall}
className='btn btn-primary'
className='plugin-install always-show-enabled'
disabled={this.props.installing}
>
<LoadingWrapper

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

@@ -1,150 +1,14 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/marketplace/marketplace_list should render with multiple plugins 1`] = `
exports[`components/marketplace/marketplace_list should render default 1`] = `
<div
className="more-modal__list"
className="no_plugins"
>
<Connect(MarketplaceItemPlugin)
description="This plugin sends quarterly user satisfaction surveys to gather feedback and help improve Mattermost"
homepageUrl="https://github.com/mattermost/mattermost-plugin-nps"
id="com.mattermost.nps"
installedVersion=""
key="com.mattermost.nps"
name="User Satisfaction Surveys"
version="1.0.3"
<PluginIcon
className="icon__plugin"
/>
<Connect(MarketplaceItemPlugin)
description="This plugin sends quarterly user satisfaction surveys to gather feedback and help improve Mattermost"
homepageUrl="https://github.com/mattermost/mattermost-plugin-nps"
id="com.mattermost.nps"
installedVersion=""
key="com.mattermost.nps"
name="User Satisfaction Surveys"
version="1.0.3"
/>
<Connect(MarketplaceItemPlugin)
description="This plugin sends quarterly user satisfaction surveys to gather feedback and help improve Mattermost"
homepageUrl="https://github.com/mattermost/mattermost-plugin-nps"
id="com.mattermost.nps"
installedVersion=""
key="com.mattermost.nps"
name="User Satisfaction Surveys"
version="1.0.3"
/>
<Connect(MarketplaceItemPlugin)
description="This plugin sends quarterly user satisfaction surveys to gather feedback and help improve Mattermost"
homepageUrl="https://github.com/mattermost/mattermost-plugin-nps"
id="com.mattermost.nps"
installedVersion=""
key="com.mattermost.nps"
name="User Satisfaction Surveys"
version="1.0.3"
/>
<Connect(MarketplaceItemPlugin)
description="This plugin sends quarterly user satisfaction surveys to gather feedback and help improve Mattermost"
homepageUrl="https://github.com/mattermost/mattermost-plugin-nps"
id="com.mattermost.nps"
installedVersion=""
key="com.mattermost.nps"
name="User Satisfaction Surveys"
version="1.0.3"
/>
<Connect(MarketplaceItemPlugin)
description="This plugin sends quarterly user satisfaction surveys to gather feedback and help improve Mattermost"
homepageUrl="https://github.com/mattermost/mattermost-plugin-nps"
id="com.mattermost.nps"
installedVersion=""
key="com.mattermost.nps"
name="User Satisfaction Surveys"
version="1.0.3"
/>
<Connect(MarketplaceItemPlugin)
description="This plugin sends quarterly user satisfaction surveys to gather feedback and help improve Mattermost"
homepageUrl="https://github.com/mattermost/mattermost-plugin-nps"
id="com.mattermost.nps"
installedVersion=""
key="com.mattermost.nps"
name="User Satisfaction Surveys"
version="1.0.3"
/>
<Connect(MarketplaceItemPlugin)
description="This plugin sends quarterly user satisfaction surveys to gather feedback and help improve Mattermost"
homepageUrl="https://github.com/mattermost/mattermost-plugin-nps"
id="com.mattermost.nps"
installedVersion=""
key="com.mattermost.nps"
name="User Satisfaction Surveys"
version="1.0.3"
/>
<Connect(MarketplaceItemPlugin)
description="This plugin sends quarterly user satisfaction surveys to gather feedback and help improve Mattermost"
homepageUrl="https://github.com/mattermost/mattermost-plugin-nps"
id="com.mattermost.nps"
installedVersion=""
key="com.mattermost.nps"
name="User Satisfaction Surveys"
version="1.0.3"
/>
<Connect(MarketplaceItemPlugin)
description="This plugin sends quarterly user satisfaction surveys to gather feedback and help improve Mattermost"
homepageUrl="https://github.com/mattermost/mattermost-plugin-nps"
id="com.mattermost.nps"
installedVersion=""
key="com.mattermost.nps"
name="User Satisfaction Surveys"
version="1.0.3"
/>
<Connect(MarketplaceItemPlugin)
description="This plugin sends quarterly user satisfaction surveys to gather feedback and help improve Mattermost"
homepageUrl="https://github.com/mattermost/mattermost-plugin-nps"
id="com.mattermost.nps"
installedVersion=""
key="com.mattermost.nps"
name="User Satisfaction Surveys"
version="1.0.3"
/>
<Connect(MarketplaceItemPlugin)
description="This plugin sends quarterly user satisfaction surveys to gather feedback and help improve Mattermost"
homepageUrl="https://github.com/mattermost/mattermost-plugin-nps"
id="com.mattermost.nps"
installedVersion=""
key="com.mattermost.nps"
name="User Satisfaction Surveys"
version="1.0.3"
/>
<Connect(MarketplaceItemPlugin)
description="This plugin sends quarterly user satisfaction surveys to gather feedback and help improve Mattermost"
homepageUrl="https://github.com/mattermost/mattermost-plugin-nps"
id="com.mattermost.nps"
installedVersion=""
key="com.mattermost.nps"
name="User Satisfaction Surveys"
version="1.0.3"
/>
<Connect(MarketplaceItemPlugin)
description="This plugin sends quarterly user satisfaction surveys to gather feedback and help improve Mattermost"
homepageUrl="https://github.com/mattermost/mattermost-plugin-nps"
id="com.mattermost.nps"
installedVersion=""
key="com.mattermost.nps"
name="User Satisfaction Surveys"
version="1.0.3"
/>
<Connect(MarketplaceItemPlugin)
description="This plugin sends quarterly user satisfaction surveys to gather feedback and help improve Mattermost"
homepageUrl="https://github.com/mattermost/mattermost-plugin-nps"
id="com.mattermost.nps"
installedVersion=""
key="com.mattermost.nps"
name="User Satisfaction Surveys"
version="1.0.3"
/>
<Connect(NavigationRow)
maximumPerPage={15}
onNextPageButtonClick={[Function]}
onPreviousPageButtonClick={[Function]}
page={0}
total={17}
<div
className="no_plugins__message"
/>
</div>
`;

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

@@ -8,8 +8,7 @@ import {AuthorType, MarketplacePlugin, ReleaseStage} from '@mattermost/types/mar
import MarketplaceItem from '../marketplace_item/marketplace_item_plugin';
import MarketplaceList from './marketplace_list';
import NavigationRow from './navigation_row';
import MarketplaceList, {ITEMS_PER_PAGE} from './marketplace_list';
describe('components/marketplace/marketplace_list', () => {
const samplePlugin: MarketplacePlugin = {
@@ -28,8 +27,20 @@ describe('components/marketplace/marketplace_list', () => {
installed_version: '',
};
it('should render with multiple plugins', () => {
const wrapper = shallow<MarketplaceList>(
it('should render default', () => {
const wrapper = shallow(
<MarketplaceList
listing={[]}
page={0}
noResultsMessage=''
/>,
);
expect(wrapper).toMatchSnapshot();
});
it('should render page with ITEMS_PER_PAGE plugins', () => {
const wrapper = shallow(
<MarketplaceList
listing={[
samplePlugin, samplePlugin, samplePlugin, samplePlugin, samplePlugin,
@@ -37,28 +48,29 @@ describe('components/marketplace/marketplace_list', () => {
samplePlugin, samplePlugin, samplePlugin, samplePlugin, samplePlugin,
samplePlugin, samplePlugin,
]}
page={0}
noResultsMessage=''
/>,
);
expect(wrapper).toMatchSnapshot();
expect(wrapper.state().page).toEqual(0);
expect(wrapper.find(MarketplaceItem)).toHaveLength(15);
expect(wrapper.find(NavigationRow)).toHaveLength(1);
expect(wrapper.find(NavigationRow).props().page).toEqual(0);
expect(wrapper.find(NavigationRow).props().total).toEqual(17);
expect(wrapper.find(NavigationRow).props().maximumPerPage).toEqual(15);
expect(wrapper.find(MarketplaceItem)).toHaveLength(ITEMS_PER_PAGE);
});
it('should set page to 0 when list of plugins changed', () => {
const wrapper = shallow<MarketplaceList>(
it('should render no results', () => {
const wrapper = shallow(
<MarketplaceList
listing={[samplePlugin, samplePlugin]}
listing={[]}
page={0}
noResultsMessage='No plugins available'
noResultsAction={{
label: 'action',
onClick: jest.fn(),
}}
/>,
);
wrapper.setState({page: 10});
wrapper.setProps({listing: [samplePlugin]});
expect(wrapper.state().page).toEqual(0);
expect(wrapper.find('.icon__plugin').length).toEqual(1);
expect(wrapper.find('.no_plugins__message').length).toEqual(1);
expect(wrapper.find('.no_plugins__action').length).toEqual(1);
});
});

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

@@ -1,107 +1,116 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import React, {useCallback, useMemo} from 'react';
import {useIntl} from 'react-intl';
import type {MarketplaceApp, MarketplacePlugin} from '@mattermost/types/marketplace';
import {isPlugin, getName} from 'mattermost-redux/utils/marketplace';
import PluginIcon from 'components/widgets/icons/plugin_icon';
import MarketplaceItemPlugin from '../marketplace_item/marketplace_item_plugin';
import MarketplaceItemApp from '../marketplace_item/marketplace_item_app';
import NavigationRow from './navigation_row';
const ITEMS_PER_PAGE = 15;
export const ITEMS_PER_PAGE = 15;
type MarketplaceListProps = {
listing: Array<MarketplacePlugin | MarketplaceApp>;
};
type MarketplaceListState = {
page: number;
noResultsMessage: string;
noResultsAction?: {
label: string;
onClick: () => void;
};
filter?: string;
listRef?: React.RefObject<HTMLDivElement>;
};
export default class MarketplaceList extends React.PureComponent <MarketplaceListProps, MarketplaceListState> {
static getDerivedStateFromProps(props: MarketplaceListProps, state: MarketplaceListState): MarketplaceListState | null {
if (state.page > 0 && props.listing.length < ITEMS_PER_PAGE) {
return {page: 0};
const MarketplaceList = ({
listing,
page,
noResultsMessage,
noResultsAction,
filter,
listRef,
}: MarketplaceListProps) => {
const {formatMessage} = useIntl();
const pageItems = useMemo(() => {
if (listing.length === 0) {
return [];
}
return null;
}
constructor(props: MarketplaceListProps) {
super(props);
this.state = {
page: 0,
};
}
nextPage = (): void => {
this.setState((state) => ({
page: state.page + 1,
}));
};
previousPage = (): void => {
this.setState((state) => ({
page: state.page - 1,
}));
};
render(): JSX.Element {
const pageStart = this.state.page * ITEMS_PER_PAGE;
const pageStart = page * ITEMS_PER_PAGE;
const pageEnd = pageStart + ITEMS_PER_PAGE;
this.props.listing.sort((a, b) => {
return getName(a).localeCompare(getName(b));
});
return [...listing].
sort((a, b) => getName(a).localeCompare(getName(b))).
slice(pageStart, pageEnd).
map((i) => (
isPlugin(i) ? (
<MarketplaceItemPlugin
key={i.manifest.id}
id={i.manifest.id}
name={i.manifest.name}
description={i.manifest.description}
version={i.manifest.version}
homepageUrl={i.homepage_url}
releaseNotesUrl={i.release_notes_url}
labels={i.labels}
iconData={i.icon_data}
installedVersion={i.installed_version}
/>
) : (
<MarketplaceItemApp
key={i.manifest.app_id}
id={i.manifest.app_id}
name={i.manifest.display_name}
description={i.manifest.description}
homepageUrl={i.manifest.homepage_url}
iconURL={i.icon_url}
installed={i.installed}
labels={i.labels}
/>
)
));
}, [listing, page]);
const itemsToDisplay = this.props.listing.slice(pageStart, pageEnd);
const getNoResultsMessage = useCallback(() => (
filter ? (
formatMessage(
{id: 'marketplace_modal_list.no_plugins_filter', defaultMessage: 'No results for "{filter}"'},
{filter},
)
) : (
noResultsMessage
)
), [filter, noResultsMessage]);
return (
<div className='more-modal__list'>
{itemsToDisplay.map((i) => {
if (isPlugin(i)) {
return (
<MarketplaceItemPlugin
key={i.manifest.id}
id={i.manifest.id}
name={i.manifest.name}
description={i.manifest.description}
version={i.manifest.version}
homepageUrl={i.homepage_url}
releaseNotesUrl={i.release_notes_url}
labels={i.labels}
iconData={i.icon_data}
installedVersion={i.installed_version}
/>
);
}
return (
<MarketplaceItemApp
key={i.manifest.app_id}
id={i.manifest.app_id}
name={i.manifest.display_name}
description={i.manifest.description}
homepageUrl={i.manifest.homepage_url}
iconURL={i.icon_url}
installed={i.installed}
labels={i.labels}
/>
);
})
}
<NavigationRow
page={this.state.page}
total={this.props.listing.length}
maximumPerPage={ITEMS_PER_PAGE}
onNextPageButtonClick={this.nextPage}
onPreviousPageButtonClick={this.previousPage}
/>
return (listing.length === 0 ? (
<div className='no_plugins'>
<PluginIcon className='icon__plugin'/>
<div className='no_plugins__message'>
{getNoResultsMessage()}
</div>
);
}
}
{noResultsAction && (
<button
className='no_plugins__action'
onClick={noResultsAction.onClick}
data-testid='Install-Plugins-button'
>
{noResultsAction.label}
</button>
)}
</div>
) : (
<div
ref={listRef}
className='more-modal__list'
>
{pageItems}
</div>
));
};
export default MarketplaceList;

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

@@ -1,157 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/marketplace/navigation_row should not render any buttons 1`] = `
<div
className="navigation-row"
>
<div
className="col-xs-2"
/>
<div
className="col-xs-8 count"
style={
Object {
"color": "rgba(255,255,255,0.6)",
}
}
>
<MemoizedFormattedMessage
defaultMessage="{startCount, number} - {endCount, number} {total, plural, one {plugin} other {plugins}} of {total, number} total"
id="marketplace_list.count_total_page"
values={
Object {
"endCount": 15,
"startCount": 1,
"total": 15,
}
}
/>
</div>
<div
className="col-xs-2"
/>
</div>
`;
exports[`components/marketplace/navigation_row should render next and previous buttons 1`] = `
<div
className="navigation-row"
>
<div
className="col-xs-2"
>
<NavigationButton
defaultMessage="Previous"
messageId="more_channels.prev"
onClick={[MockFunction]}
/>
</div>
<div
className="col-xs-8 count"
style={
Object {
"color": "rgba(255,255,255,0.6)",
}
}
>
<MemoizedFormattedMessage
defaultMessage="{startCount, number} - {endCount, number} {total, plural, one {plugin} other {plugins}} of {total, number} total"
id="marketplace_list.count_total_page"
values={
Object {
"endCount": 30,
"startCount": 16,
"total": 32,
}
}
/>
</div>
<div
className="col-xs-2"
>
<NavigationButton
defaultMessage="Next"
messageId="more_channels.next"
onClick={[MockFunction]}
/>
</div>
</div>
`;
exports[`components/marketplace/navigation_row should render only next button 1`] = `
<div
className="navigation-row"
>
<div
className="col-xs-2"
/>
<div
className="col-xs-8 count"
style={
Object {
"color": "rgba(255,255,255,0.6)",
}
}
>
<MemoizedFormattedMessage
defaultMessage="{startCount, number} - {endCount, number} {total, plural, one {plugin} other {plugins}} of {total, number} total"
id="marketplace_list.count_total_page"
values={
Object {
"endCount": 15,
"startCount": 1,
"total": 32,
}
}
/>
</div>
<div
className="col-xs-2"
>
<NavigationButton
defaultMessage="Next"
messageId="more_channels.next"
onClick={[MockFunction]}
/>
</div>
</div>
`;
exports[`components/marketplace/navigation_row should render only previous button 1`] = `
<div
className="navigation-row"
>
<div
className="col-xs-2"
>
<NavigationButton
defaultMessage="Previous"
messageId="more_channels.prev"
onClick={[MockFunction]}
/>
</div>
<div
className="col-xs-8 count"
style={
Object {
"color": "rgba(255,255,255,0.6)",
}
}
>
<MemoizedFormattedMessage
defaultMessage="{startCount, number} - {endCount, number} {total, plural, one {plugin} other {plugins}} of {total, number} total"
id="marketplace_list.count_total_page"
values={
Object {
"endCount": 32,
"startCount": 31,
"total": 32,
}
}
/>
</div>
<div
className="col-xs-2"
/>
</div>
`;

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

@@ -1,17 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {connect} from 'react-redux';
import {getTheme} from 'mattermost-redux/selectors/entities/preferences';
import {GlobalState} from '@mattermost/types/store';
import NavigationRow from './navigation_row';
function mapStateToProps(state: GlobalState) {
return {
theme: getTheme(state),
};
}
export default connect(mapStateToProps)(NavigationRow);

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

@@ -1,33 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {FormattedMessage} from 'react-intl';
type NavigationButtonProps = {
onClick: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
messageId: string;
defaultMessage: string;
};
export default class NavigationButton extends React.PureComponent <NavigationButtonProps> {
onClick = (event: React.MouseEvent<HTMLButtonElement, MouseEvent>): void => {
event.preventDefault();
this.props.onClick(event);
};
render(): JSX.Element {
const {onClick, messageId, defaultMessage} = this.props;
return (
<button
className='btn btn-link'
onClick={onClick}
>
<FormattedMessage
id={messageId}
defaultMessage={defaultMessage}
/>
</button>
);
}
}

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

@@ -1,73 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {shallow} from 'enzyme';
import {Theme} from 'mattermost-redux/selectors/entities/preferences';
import NavigationRow, {NavigationRowProps} from './navigation_row';
describe('components/marketplace/navigation_row', () => {
const baseProps: NavigationRowProps = {
page: 0,
total: 32,
maximumPerPage: 15,
onNextPageButtonClick: jest.fn(),
onPreviousPageButtonClick: jest.fn(),
theme: {centerChannelColor: '#fff'} as Theme,
};
it('should render only next button', () => {
const wrapper = shallow<NavigationRow>(
<NavigationRow {...baseProps}/>,
);
expect(wrapper).toMatchSnapshot();
expect(wrapper.find('NavigationButton')).toHaveLength(1);
wrapper.find('NavigationButton').simulate('click', {preventDefault: jest.fn});
expect(wrapper.instance().props.onNextPageButtonClick).toHaveBeenCalledTimes(1);
expect(wrapper.instance().props.onPreviousPageButtonClick).toHaveBeenCalledTimes(0);
});
it('should render next and previous buttons', () => {
const props = {...baseProps, page: 1};
const wrapper = shallow<NavigationRow>(
<NavigationRow{...props}/>,
);
expect(wrapper).toMatchSnapshot();
expect(wrapper.find('NavigationButton')).toHaveLength(2);
wrapper.find('NavigationButton').at(0).simulate('click', {preventDefault: jest.fn});
wrapper.find('NavigationButton').at(1).simulate('click', {preventDefault: jest.fn});
expect(wrapper.instance().props.onNextPageButtonClick).toHaveBeenCalledTimes(1);
expect(wrapper.instance().props.onPreviousPageButtonClick).toHaveBeenCalledTimes(1);
});
it('should render only previous button', () => {
const props = {...baseProps, page: 2};
const wrapper = shallow<NavigationRow>(
<NavigationRow{...props}/>,
);
expect(wrapper).toMatchSnapshot();
expect(wrapper.find('NavigationButton')).toHaveLength(1);
wrapper.find('NavigationButton').simulate('click', {preventDefault: jest.fn});
expect(wrapper.instance().props.onNextPageButtonClick).toHaveBeenCalledTimes(0);
expect(wrapper.instance().props.onPreviousPageButtonClick).toHaveBeenCalledTimes(1);
});
it('should not render any buttons', () => {
const props = {...baseProps, page: 0, total: 15};
const wrapper = shallow<NavigationRow>(
<NavigationRow{...props}/>,
);
expect(wrapper).toMatchSnapshot();
expect(wrapper.find('NavigationButton')).toHaveLength(0);
});
});

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

@@ -1,87 +0,0 @@
// 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 {Theme} from 'mattermost-redux/selectors/entities/preferences';
import {changeOpacity, makeStyleFromTheme} from 'mattermost-redux/utils/theme_utils';
import NavigationButton from './navigation_button';
export type NavigationRowProps = {
page: number;
total: number;
maximumPerPage: number;
onNextPageButtonClick: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
onPreviousPageButtonClick: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
theme: Theme;
};
export default class NavigationRow extends React.PureComponent <NavigationRowProps> {
canShowNextButton = (): boolean => {
const {page, maximumPerPage, total} = this.props;
const totalPages = Math.trunc((total - 1) / maximumPerPage);
return totalPages > page;
};
renderCount = (): JSX.Element => {
const {page, total, maximumPerPage} = this.props;
const startCount = page * maximumPerPage;
const endCount = Math.min(startCount + maximumPerPage, total);
return (
<FormattedMessage
id='marketplace_list.count_total_page'
defaultMessage='{startCount, number} - {endCount, number} {total, plural, one {plugin} other {plugins}} of {total, number} total'
values={{
startCount: startCount + 1,
endCount,
total,
}}
/>
);
};
render(): JSX.Element {
const style = getStyle(this.props.theme);
return (
<div className='navigation-row'>
<div className='col-xs-2'>
{(this.props.page > 0) && (
<NavigationButton
onClick={this.props.onPreviousPageButtonClick}
messageId={'more_channels.prev'}
defaultMessage={'Previous'}
/>
)}
</div>
<div
className='col-xs-8 count'
style={style.count}
>
{this.renderCount()}
</div>
<div className='col-xs-2'>
{this.canShowNextButton() && (
<NavigationButton
onClick={this.props.onNextPageButtonClick}
messageId={'more_channels.next'}
defaultMessage={'Next'}
/>
)}
</div>
</div>
);
}
}
const getStyle = makeStyleFromTheme((theme) => {
return {
count: {
color: changeOpacity(theme.centerChannelColor, 0.6),
},
};
});

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

@@ -1,21 +1,11 @@
@import 'utils/variables';
@import 'utils/mixins';
.modal-marketplace {
display: flex;
width: 100%;
height: 100%;
flex-direction: column;
align-items: center;
padding-top: 50px;
color: var(--center-channel-color);
font-size: 16px;
@media (max-width: 768px) {
padding-right: 15px;
padding-left: 15px;
}
.marketplace-modal {
width: 800px;
div.navigation-row {
overflow: unset;
margin-top: 10px;
div {
@@ -29,7 +19,7 @@
}
div.count {
padding-top: 8px;
padding: 9px 15px;
}
}
@@ -45,33 +35,47 @@
}
.nav-tabs {
font-size: 14px;
padding: 0 32px;
margin: 0 0 8px;
> li {
> a {
padding: 10px 16px;
li {
margin-right: 0;
a {
padding: 13px 12px;
border: none;
background: transparent;
color: rgba(var(--center-channel-color-rgb), 0.64);
font-size: 14px;
font-weight: 600;
line-height: 20px;
transition: all 0.15s ease;
&:hover,
&:active,
&:focus {
&:focus,
&:focus-within {
border: none;
background: transparent;
border-radius: none;
color: var(--center-channel-color);
}
}
&.active > a {
color: var(--center-channel-color);
&.active {
border-bottom: 2px solid var(--denim-button-bg);
a {
color: var(--denim-button-bg);
}
}
&:not(:first-child) {
margin-left: 8px;
}
}
}
h1 {
margin: 8px 0 24px;
font-size: 28px;
}
h2 {
font-weight: 300;
@@ -81,8 +85,14 @@
}
.more-modal__list {
height: 390px;
margin: 0 6px 8px 12px;
overflow-y: scroll;
.more-modal__row {
align-items: normal;
min-height: 80px;
padding: 16px 20px;
border-bottom: none;
.marketplace__tag {
margin-left: 6px;
@@ -94,25 +104,91 @@
margin: 10px 10px 0 0;
font-size: 0.9em;
}
.more-modal__details {
padding-left: 16px;
.more-modal__row--link {
color: var(--center-channel-color);
font-size: 16px;
font-weight: 600;
line-height: 24px;
}
.more-modal__description {
margin: 2px 0 0;
color: rgba(var(--center-channel-color-rgb), 0.64);
font-size: 14px;
font-weight: 400;
line-height: 20px;
}
}
.more-modal__actions {
padding-left: 16px;
margin: 0;
.plugin-configure,
.app-installed {
@include secondary-button;
@include button-medium;
}
.plugin-install,
.app-install {
@include primary-button;
@include button-medium;
}
a {
&:hover,
&:focus {
text-decoration: none;
}
}
}
&:hover,
&:focus,
&:focus-within {
background-color: rgba(var(--center-channel-color-rgb), 0.08);
}
}
.more-modal__description {
margin: 2px 0 0;
font-size: 0.9em;
}
.icon__plugin {
display: flex;
height: 48px;
flex: 0 0 48px;
align-items: center;
justify-content: center;
background-color: $white;
border-radius: 50%;
padding-bottom: 80px;
svg,
img {
width: 48px;
height: 48px;
}
svg {
fill: var(--button-bg);
}
img {
border-radius: 4px;
}
}
}
.search_input {
width: 720px;
height: 40px;
flex: 1;
margin-top: 28px;
margin-right: 16px;
margin-bottom: 20px;
margin-left: 16px;
box-shadow: none;
.marketplace-modal-search {
padding: 24px 0 0;
.search_input {
width: 100%;
border: 0 !important;
border-radius: 0 !important;
box-shadow: none;
}
}
.btn {
@@ -130,8 +206,9 @@
.tabs {
display: flex;
width: 720px;
width: 100%;
flex-direction: column;
margin-top: 12px;
}
.subtitle {
@@ -160,67 +237,41 @@
}
}
.icon__plugin {
.no_plugins {
display: flex;
height: 42px;
flex: 0 0 42px;
height: 390px;
flex-flow: column;
align-items: center;
justify-content: center;
margin-right: 4px;
border-radius: 50%;
margin-bottom: 8px;
svg {
width: 32px;
height: 32px;
fill: var(--button-bg);
&__message {
margin-top: 20px;
color: var(--center-channel-color);
font-size: 20px;
font-weight: 600;
line-height: 28px;
}
}
.icon__plugin--background {
padding: 6px;
background-color: $white;
&__action {
@include primary-button;
@include button-medium;
svg {
width: 24px;
height: 24px;
margin-top: 30px;
}
}
.no_plugins_div {
text-align: center;
.icon__plugin {
svg {
fill: var(--button-bg);
}
}
}
.item_error {
background-color: rgba(var(--error-text-rgb), 0.08);
background-color: rgba(var(--error-text-color-rgb), 0.08);
}
.error_text {
color: var(--error-text);
opacity: 1;
}
}
.error-bar {
position: fixed;
z-index: 8;
top: 0;
overflow: hidden;
width: 100%;
min-height: $announcement-bar-height;
max-height: $announcement-bar-height;
padding: 5px 30px;
background-color: var(--center-channel-bg);
color: var(--error-text);
.error-bar__content {
position: absolute;
top: 0;
left: 0;
display: flex;
width: 100%;
height: 100%;
align-items: center;
justify-content: center;
background-color: rgba(var(--error-text-rgb), 0.12);
.loading {
height: 390px;
}
}

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

@@ -5,19 +5,21 @@ import React from 'react';
import {shallow} from 'enzyme';
import {AuthorType, MarketplacePlugin, ReleaseStage} from '@mattermost/types/marketplace';
import type {PluginStatusRedux} from '@mattermost/types/plugins';
import {trackEvent} from 'actions/telemetry_actions.jsx';
import {ActionFunc} from 'mattermost-redux/types/actions';
import MarketplaceModal, {AllListing, InstalledListing, MarketplaceModalProps} from './marketplace_modal';
import {GlobalState} from 'types/store';
import {ModalIdentifiers} from 'utils/constants';
jest.mock('actions/telemetry_actions.jsx', () => {
const original = jest.requireActual('actions/telemetry_actions.jsx');
return {
...original,
trackEvent: jest.fn(),
};
});
import MarketplaceModal, {OpenedFromType} from './marketplace_modal';
let mockState: GlobalState;
jest.mock('react-redux', () => ({
...jest.requireActual('react-redux') as typeof import('react-redux'),
useSelector: (selector: (state: typeof mockState) => unknown) => selector(mockState),
useDispatch: jest.fn(() => (action: ActionFunc) => action),
}));
describe('components/marketplace/', () => {
const samplePlugin: MarketplacePlugin = {
@@ -52,161 +54,106 @@ describe('components/marketplace/', () => {
installed_version: '1.0.3',
};
describe('AllListing', () => {
it('should render with no plugins', () => {
const wrapper = shallow(
<AllListing listing={[]}/>,
);
expect(wrapper).toMatchSnapshot();
});
const defaultProps = {
openedFrom: 'actions_menu' as OpenedFromType,
};
it('should render with one plugin', () => {
const wrapper = shallow(
<AllListing listing={[samplePlugin]}/>,
);
expect(wrapper).toMatchSnapshot();
});
it('should render with plugins', () => {
const wrapper = shallow(
<AllListing listing={[samplePlugin, sampleInstalledPlugin]}/>,
);
expect(wrapper).toMatchSnapshot();
});
});
describe('InstalledPlugins', () => {
const baseProps = {
changeTab: jest.fn(),
};
it('should render with no plugins', () => {
const wrapper = shallow(
<InstalledListing
{...baseProps}
installedItems={[]}
/>,
);
expect(wrapper).toMatchSnapshot();
});
it('should render with one plugin', () => {
const wrapper = shallow(
<InstalledListing
{...baseProps}
installedItems={[sampleInstalledPlugin]}
/>,
);
expect(wrapper).toMatchSnapshot();
});
it('should render with multiple plugins', () => {
const wrapper = shallow(
<InstalledListing
{...baseProps}
installedItems={[sampleInstalledPlugin, sampleInstalledPlugin]}
/>,
);
expect(wrapper).toMatchSnapshot();
});
});
describe('MarketplaceModal', () => {
const baseProps: MarketplaceModalProps = {
show: true,
listing: [samplePlugin],
installedListing: [],
pluginStatuses: {},
siteURL: 'http://example.com',
firstAdminVisitMarketplaceStatus: false,
openedFrom: 'actions_menu',
actions: {
closeModal: jest.fn(),
fetchListing: jest.fn(() => {
return Promise.resolve({});
}),
filterListing: jest.fn(() => {
return Promise.resolve({});
}),
setFirstAdminVisitMarketplaceStatus: jest.fn(),
getPluginStatuses: jest.fn(),
beforeEach(() => {
mockState = {
views: {
modals: {
modalState: {
[ModalIdentifiers.PLUGIN_MARKETPLACE]: {
open: true,
},
},
},
marketplace: {
plugins: [],
apps: [],
},
},
};
entities: {
general: {
firstAdminCompleteSetup: false,
},
admin: {
pluginStatuses: {},
},
},
} as unknown as GlobalState;
});
test('should render with no plugins installed', () => {
const wrapper = shallow<MarketplaceModal>(
<MarketplaceModal {...baseProps}/>,
);
expect(wrapper).toMatchSnapshot();
});
test('should render default', () => {
const wrapper = shallow(
<MarketplaceModal {...defaultProps}/>,
);
test('should render with plugins installed', () => {
const props = {
...baseProps,
plugins: [
...baseProps.listing,
sampleInstalledPlugin,
],
installedListing: [
sampleInstalledPlugin,
],
};
expect(wrapper.shallow()).toMatchSnapshot();
});
const wrapper = shallow<MarketplaceModal>(
<MarketplaceModal {...props}/>,
);
test('should render with no plugins available', () => {
const setState = jest.fn();
const useStateSpy = jest.spyOn(React, 'useState');
useStateSpy.mockImplementationOnce(() => [false, setState]);
expect(wrapper).toMatchSnapshot();
});
const wrapper = shallow(
<MarketplaceModal {...defaultProps}/>,
);
test('should fetch plugins when plugin status is changed', () => {
const fetchListing = baseProps.actions.fetchListing;
const wrapper = shallow<MarketplaceModal>(<MarketplaceModal {...baseProps}/>);
wrapper.update();
expect(fetchListing).toBeCalledTimes(1);
wrapper.setProps({...baseProps});
expect(fetchListing).toBeCalledTimes(1);
expect(wrapper.shallow()).toMatchSnapshot();
});
const status = {
id: 'test',
} as PluginStatusRedux;
wrapper.setProps({...baseProps, pluginStatuses: {test: status}});
expect(fetchListing).toBeCalledTimes(2);
});
test('should render with plugins available', () => {
const setState = jest.fn();
const useStateSpy = jest.spyOn(React, 'useState');
useStateSpy.mockImplementationOnce(() => [false, setState]);
test('should render with error banner', () => {
const wrapper = shallow<MarketplaceModal>(
<MarketplaceModal {...baseProps}/>,
);
mockState.views.marketplace.plugins = [
samplePlugin,
];
wrapper.setState({serverError: {name: 'some.error', message: 'Error test'}});
const wrapper = shallow(
<MarketplaceModal {...defaultProps}/>,
);
expect(wrapper).toMatchSnapshot();
});
wrapper.update();
test('Should call for track event when searching', () => {
const wrapper = shallow<MarketplaceModal>(
<MarketplaceModal {...baseProps}/>,
);
expect(wrapper.shallow()).toMatchSnapshot();
});
wrapper.setState({filter: 'nps'});
wrapper.instance().doSearch();
test('should render with plugins installed', () => {
const setState = jest.fn();
const useStateSpy = jest.spyOn(React, 'useState');
useStateSpy.mockImplementationOnce(() => [false, setState]);
expect(trackEvent).toHaveBeenCalledWith('plugins', 'ui_marketplace_opened', {from: 'actions_menu'});
expect(trackEvent).toHaveBeenCalledWith('plugins', 'ui_marketplace_search', {filter: 'nps'});
});
mockState.views.marketplace.plugins = [
samplePlugin,
sampleInstalledPlugin,
];
test('Should call for opened track event on mount', () => {
const openedFrom = 'actions_menu';
const wrapper = shallow(
<MarketplaceModal {...defaultProps}/>,
);
shallow<MarketplaceModal>(
<MarketplaceModal
{...baseProps}
openedFrom={openedFrom}
/>,
);
wrapper.update();
expect(trackEvent).toHaveBeenCalledWith('plugins', 'ui_marketplace_opened', {from: openedFrom});
});
expect(wrapper.shallow()).toMatchSnapshot();
});
test('should render with error banner', () => {
const setState = jest.fn();
const useStateSpy = jest.spyOn(React, 'useState');
useStateSpy.mockImplementation(() => [true, setState]);
const wrapper = shallow(
<MarketplaceModal {...defaultProps}/>,
);
wrapper.update();
expect(wrapper.shallow()).toMatchSnapshot();
});
});

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

@@ -1,283 +1,269 @@
// 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 debounce from 'lodash/debounce';
import React, {useCallback, useEffect, useRef, useState} from 'react';
import {Tabs, Tab, SelectCallback} from 'react-bootstrap';
import {useIntl} from 'react-intl';
import {useDispatch, useSelector} from 'react-redux';
import {Link} from 'react-router-dom';
import debounce from 'lodash/debounce';
import {PluginStatusRedux} from '@mattermost/types/plugins';
import type {MarketplaceApp, MarketplacePlugin} from '@mattermost/types/marketplace';
import {MagnifyIcon} from '@mattermost/compass-icons/components';
import FullScreenModal from 'components/widgets/modals/full_screen_modal';
import RootPortal from 'components/root_portal';
import QuickInput from 'components/quick_input';
import LocalizedInput from 'components/localized_input/localized_input';
import PluginIcon from 'components/widgets/icons/plugin_icon';
import LoadingScreen from 'components/loading_screen';
import FormattedMarkdownMessage from 'components/formatted_markdown_message';
import {FooterPagination} from '@mattermost/components';
import {getPluginStatuses} from 'mattermost-redux/actions/admin';
import {setFirstAdminVisitMarketplaceStatus} from 'mattermost-redux/actions/general';
import {getFirstAdminVisitMarketplaceStatus} from 'mattermost-redux/selectors/entities/general';
import {ActionResult} from 'mattermost-redux/types/actions';
import {fetchListing, filterListing} from 'actions/marketplace';
import {trackEvent} from 'actions/telemetry_actions.jsx';
import {t} from 'utils/i18n';
import {localizeMessage} from 'utils/utils';
import {closeModal} from 'actions/views/modals';
import GenericModal from 'components/generic_modal';
import LoadingScreen from 'components/loading_screen';
import Input, {SIZE} from 'components/widgets/inputs/input/input';
import {getListing, getInstalledListing} from 'selectors/views/marketplace';
import {isModalOpen} from 'selectors/views/modals';
import {GlobalState} from 'types/store';
import {ModalIdentifiers} from 'utils/constants';
import './marketplace_modal.scss';
import MarketplaceList from './marketplace_list/marketplace_list';
import MarketplaceList, {ITEMS_PER_PAGE} from './marketplace_list/marketplace_list';
const MarketplaceTabs = {
ALL_LISTING: 'allListing',
ALL_LISTING: 'all',
INSTALLED_LISTING: 'installed',
};
const SEARCH_TIMEOUT_MILLISECONDS = 200;
const linkConsole = (msg: string) => (
<Link to='/admin_console/plugins/plugin_management'>
{msg}
</Link>
);
export type OpenedFromType = 'actions_menu' | 'app_bar' | 'channel_header' | 'command' | 'open_plugin_install_post' | 'product_menu';
type AllListingProps = {
listing: Array<MarketplacePlugin | MarketplaceApp>;
};
// AllListing renders the contents of the all listing tab.
export const AllListing = ({listing}: AllListingProps): JSX.Element => {
if (listing.length === 0) {
return (
<div className='no_plugins_div'>
<br/>
<PluginIcon className='icon__plugin'/>
<div className='mt-3 light'>
<FormattedMessage
id='marketplace_modal.no_plugins'
defaultMessage='There are no plugins available at this time.'
/>
</div>
</div>
);
}
return <MarketplaceList listing={listing}/>;
};
type InstalledListingProps = {
installedItems: Array<MarketplacePlugin | MarketplaceApp>;
changeTab: SelectCallback;
};
// InstalledListing renders the contents of the installed listing tab.
export const InstalledListing = ({installedItems, changeTab}: InstalledListingProps): JSX.Element => {
if (installedItems.length === 0) {
return (
<div className='no_plugins_div'>
<br/>
<PluginIcon className='icon__plugin'/>
<div className='mt-3 light'>
<FormattedMessage
id='marketplace_modal.no_plugins_installed'
defaultMessage='You do not have any plugins installed.'
/>
</div>
<button
className='mt-5 style--none color--link'
onClick={() => changeTab(MarketplaceTabs.ALL_LISTING)}
data-testid='Install-Plugins-button'
>
<FormattedMessage
id='marketplace_modal.install_plugins'
defaultMessage='Install Plugins'
/>
</button>
</div>
);
}
return <MarketplaceList listing={installedItems}/>;
};
export type MarketplaceModalProps = {
show: boolean;
listing: Array<MarketplacePlugin | MarketplaceApp>;
installedListing: Array<MarketplacePlugin | MarketplaceApp>;
siteURL: string;
pluginStatuses?: Record<string, PluginStatusRedux>;
firstAdminVisitMarketplaceStatus: boolean;
type MarketplaceModalProps = {
openedFrom: OpenedFromType;
actions: {
closeModal: () => void;
fetchListing(localOnly?: boolean): Promise<{error?: Error}>;
filterListing(filter: string): Promise<{error?: Error}>;
setFirstAdminVisitMarketplaceStatus(): Promise<void>;
getPluginStatuses(): Promise<void>;
};
};
type MarketplaceModalState = {
tabKey: unknown;
loading: boolean;
serverError?: Error;
filter: string;
};
// MarketplaceModal is the marketplace modal.
export default class MarketplaceModal extends React.PureComponent<MarketplaceModalProps, MarketplaceModalState> {
private filterRef: React.RefObject<HTMLInputElement>;
constructor(props: MarketplaceModalProps) {
super(props);
this.state = {
tabKey: MarketplaceTabs.ALL_LISTING,
loading: true,
serverError: undefined,
filter: '',
};
this.filterRef = React.createRef();
}
componentDidMount(): void {
trackEvent('plugins', 'ui_marketplace_opened', {from: this.props.openedFrom});
this.fetchListing();
this.props.actions.getPluginStatuses();
if (!this.props.firstAdminVisitMarketplaceStatus) {
trackEvent('plugins', 'ui_first_admin_visit_marketplace_status');
this.props.actions.setFirstAdminVisitMarketplaceStatus();
}
this.filterRef.current?.focus();
}
componentDidUpdate(prevProps: MarketplaceModalProps): void {
// Automatically refresh the component when a plugin is installed or uninstalled.
if (this.props.pluginStatuses !== prevProps.pluginStatuses) {
this.fetchListing();
}
}
fetchListing = async (): Promise<void> => {
const {error} = await this.props.actions.fetchListing();
this.setState({loading: false, serverError: error});
}
close = (): void => {
trackEvent('plugins', 'ui_marketplace_closed');
this.props.actions.closeModal();
}
changeTab: SelectCallback = (tabKey: any): void => {
this.setState({tabKey});
}
onInput = (): void => {
if (this.filterRef.current) {
this.setState({filter: this.filterRef.current.value});
this.debouncedSearch();
}
}
handleClearSearch = (): void => {
if (this.filterRef.current) {
this.filterRef.current.value = '';
this.setState({filter: this.filterRef.current.value}, this.doSearch);
}
}
doSearch = async (): Promise<void> => {
trackEvent('plugins', 'ui_marketplace_search', {filter: this.state.filter});
const {error} = await this.props.actions.filterListing(this.state.filter);
this.setState({serverError: error});
}
debouncedSearch = debounce(this.doSearch, SEARCH_TIMEOUT_MILLISECONDS);
render(): JSX.Element {
const input = (
<div className='filter-row filter-row--full'>
<div className='col-sm-12'>
<QuickInput
id='searchMarketplaceTextbox'
ref={this.filterRef}
className='form-control filter-textbox search_input'
placeholder={{id: t('marketplace_modal.search'), defaultMessage: 'Search Marketplace'}}
inputComponent={LocalizedInput}
onInput={this.onInput}
value={this.state.filter}
clearable={true}
onClear={this.handleClearSearch}
/>
</div>
</div>
);
let errorBanner = null;
if (this.state.serverError) {
errorBanner = (
<div
className='error-bar'
id='error_bar'
>
<div className='error-bar__content'>
<FormattedMarkdownMessage
id='app.plugin.marketplace_plugins.app_error'
defaultMessage='Error connecting to the marketplace server. Please check your settings in the [System Console]({siteURL}/admin_console/plugins/plugin_management).'
values={{siteURL: this.props.siteURL}}
/>
</div>
</div>
);
}
return (
<RootPortal>
<FullScreenModal
show={this.props.show}
onClose={this.close}
ariaLabel={localizeMessage('marketplace_modal.title', 'Marketplace')}
>
{errorBanner}
<div
className='modal-marketplace'
id='modal_marketplace'
>
<h1>
<strong>
<FormattedMessage
id='marketplace_modal.title'
defaultMessage='Marketplace'
/>
</strong>
</h1>
{input}
<Tabs
id='marketplaceTabs'
className='tabs'
defaultActiveKey={MarketplaceTabs.ALL_LISTING}
activeKey={this.state.tabKey}
onSelect={this.changeTab}
unmountOnExit={true}
>
<Tab
eventKey={MarketplaceTabs.ALL_LISTING}
title={localizeMessage('marketplace_modal.tabs.all_listing', 'All')}
>
{this.state.loading ? <LoadingScreen/> : <AllListing listing={this.props.listing}/>}
</Tab>
<Tab
eventKey={MarketplaceTabs.INSTALLED_LISTING}
title={localizeMessage('marketplace_modal.tabs.installed_listing', 'Installed') + ` (${this.props.installedListing.length})`}
>
<InstalledListing
installedItems={this.props.installedListing}
changeTab={this.changeTab}
/>
</Tab>
</Tabs>
</div>
</FullScreenModal>
</RootPortal>
);
}
}
const MarketplaceModal = ({
openedFrom,
}: MarketplaceModalProps) => {
const dispatch = useDispatch();
const {formatMessage} = useIntl();
const listRef = useRef<HTMLDivElement>(null);
const show = useSelector((state: GlobalState) => isModalOpen(state, ModalIdentifiers.PLUGIN_MARKETPLACE));
const listing = useSelector(getListing);
const installedListing = useSelector(getInstalledListing);
const pluginStatuses = useSelector((state: GlobalState) => state.entities.admin.pluginStatuses);
const hasFirstAdminVisitedMarketplace = useSelector(getFirstAdminVisitMarketplaceStatus);
const [tabKey, setTabKey] = useState(MarketplaceTabs.ALL_LISTING);
const [filter, setFilter] = useState('');
const [page, setPage] = useState(0);
const [hasLoaded, setHasLoaded] = useState(false);
const [loading, setLoading] = React.useState(true);
const [serverError, setServerError] = React.useState(false);
const doFetchListing = useCallback(async () => {
const {error} = await dispatch(fetchListing()) as ActionResult;
if (error) {
setServerError(true);
}
setLoading(false);
}, []);
const doSearch = useCallback(async () => {
trackEvent('plugins', 'ui_marketplace_search', {filter});
const {error} = await dispatch(filterListing(filter)) as ActionResult;
if (error) {
setServerError(true);
}
}, [filter]);
const debouncedSearch = debounce(doSearch, SEARCH_TIMEOUT_MILLISECONDS);
useEffect(() => {
async function doFetch() {
await dispatch(getPluginStatuses());
await doFetchListing();
setHasLoaded(true);
}
trackEvent('plugins', 'ui_marketplace_opened', {from: openedFrom});
if (!hasFirstAdminVisitedMarketplace) {
trackEvent('plugins', 'ui_first_admin_visit_marketplace_status');
dispatch(setFirstAdminVisitMarketplaceStatus());
}
doFetch();
}, []);
useEffect(() => {
if (hasLoaded) {
doFetchListing();
}
}, [pluginStatuses]);
useEffect(() => {
if (hasLoaded) {
debouncedSearch();
setPage(0);
}
}, [filter]);
const scrollListToTop = useCallback(() => {
if (listRef.current) {
listRef.current.scrollTop = 0;
}
}, []);
const handleOnClose = () => {
trackEvent('plugins', 'ui_marketplace_closed');
dispatch(closeModal(ModalIdentifiers.PLUGIN_MARKETPLACE));
};
const handleChangeTab: SelectCallback = useCallback((tabKey) => {
setTabKey(tabKey);
setPage(0);
scrollListToTop();
}, [scrollListToTop]);
const handleOnChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
setFilter(event.target.value);
}, []);
const handleOnClear = useCallback(() => {
setFilter('');
}, []);
const handleOnNextPage = useCallback(() => {
setPage(page + 1);
scrollListToTop();
}, [page, scrollListToTop]);
const handleOnPreviousPage = useCallback(() => {
setPage(page - 1);
scrollListToTop();
}, [page, scrollListToTop]);
const handleNoResultsButtonClick = useCallback(() => {
handleChangeTab(MarketplaceTabs.ALL_LISTING);
}, [handleChangeTab]);
const getHeaderInput = useCallback(() => (
<Input
id='searchMarketplaceTextbox'
name='searchMarketplaceTextbox'
containerClassName='marketplace-modal-search'
inputClassName='search_input'
type='text'
inputSize={SIZE.LARGE}
inputPrefix={<MagnifyIcon size={24}/>}
placeholder={formatMessage({id: 'marketplace_modal.search', defaultMessage: 'Search marketplace'})}
useLegend={false}
autoFocus={true}
clearable={true}
value={filter}
onChange={handleOnChange}
onClear={handleOnClear}
/>
), [filter, handleOnChange, handleOnClear]);
const getFooterContent = useCallback(() => (
<FooterPagination
page={page}
total={tabKey === MarketplaceTabs.ALL_LISTING ? listing.length : installedListing.length}
itemsPerPage={ITEMS_PER_PAGE}
onNextPage={handleOnNextPage}
onPreviousPage={handleOnPreviousPage}
/>
), [installedListing.length, listing.length, page, handleOnNextPage, handleOnPreviousPage, tabKey]);
return (
<GenericModal
id='marketplace-modal'
className='marketplace-modal'
modalHeaderText={formatMessage({id: 'marketplace_modal.title', defaultMessage: 'App Marketplace'})}
ariaLabel={formatMessage({id: 'marketplace_modal.title', defaultMessage: 'App Marketplace'})}
errorText={serverError ? (
formatMessage(
{
id: 'marketplace_modal.app_error',
defaultMessage: 'Error connecting to the marketplace server. Please check your settings in the <linkConsole>System Console</linkConsole>.',
},
{linkConsole},
)
) : undefined}
show={show}
compassDesign={true}
bodyPadding={false}
footerDivider={true}
onExited={handleOnClose}
footerContent={getFooterContent()}
headerInput={getHeaderInput()}
>
<Tabs
id='marketplaceTabs'
className='tabs'
defaultActiveKey={MarketplaceTabs.ALL_LISTING}
activeKey={tabKey}
onSelect={handleChangeTab}
unmountOnExit={true}
>
<Tab
eventKey={MarketplaceTabs.ALL_LISTING}
title={formatMessage({id: 'marketplace_modal.tabs.all_listing', defaultMessage: 'All'})}
>
{loading ? (
<LoadingScreen className='loading'/>
) : (
<MarketplaceList
listRef={listRef}
listing={listing}
page={page}
filter={filter}
noResultsMessage={formatMessage({id: 'marketplace_modal.no_plugins', defaultMessage: 'No plugins found'})}
/>
)}
</Tab>
<Tab
eventKey={MarketplaceTabs.INSTALLED_LISTING}
title={formatMessage(
{id: 'marketplace_modal.tabs.installed_listing', defaultMessage: 'Installed ({count})'},
{count: installedListing.length},
)}
>
<MarketplaceList
listRef={listRef}
listing={installedListing}
page={page}
filter={filter}
noResultsMessage={formatMessage({
id: 'marketplace_modal.no_plugins_installed',
defaultMessage: 'No plugins installed found',
})}
noResultsAction={{
label: formatMessage({id: 'marketplace_modal.install_plugins', defaultMessage: 'Install plugins'}),
onClick: handleNoResultsButtonClick,
}}
/>
</Tab>
</Tabs>
</GenericModal>
);
};
export default MarketplaceModal;

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

@@ -4,6 +4,7 @@ exports[`ProductNoticesModal Match snapshot for single notice 1`] = `
<GenericModal
autoCloseOnCancelButton={false}
autoCloseOnConfirmButton={true}
bodyPadding={true}
cancelButtonText={null}
className="productNotices"
confirmButtonText={
@@ -44,6 +45,7 @@ exports[`ProductNoticesModal Match snapshot for user notice 1`] = `
<GenericModal
autoCloseOnCancelButton={false}
autoCloseOnConfirmButton={true}
bodyPadding={true}
cancelButtonText={
<React.Fragment>
<PreviousIcon
@@ -122,6 +124,7 @@ exports[`ProductNoticesModal Should match snapshot for system admin notice 1`] =
<GenericModal
autoCloseOnCancelButton={false}
autoCloseOnConfirmButton={false}
bodyPadding={true}
cancelButtonText={null}
className="productNotices"
confirmButtonText={

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

@@ -0,0 +1,26 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/widgets/inputs/Input should match snapshot 1`] = `
<div
className="Input_container"
>
<fieldset
className="Input_fieldset"
>
<legend
className="Input_legend"
/>
<div
className="Input_wrapper"
>
<input
className="Input form-control medium"
id="input_"
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
/>
</div>
</fieldset>
</div>
`;

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

@@ -49,11 +49,16 @@
.Input_wrapper {
display: flex;
flex: 1;
align-items: center;
padding: 0 16px;
margin: 2px 0;
color: rgba(var(--center-channel-color-rgb), 0.56);
font-size: 14px;
line-height: 20px;
> :not(:first-child) {
margin-left: 8px;
}
}
.Input_limit-exceeded {
@@ -196,4 +201,10 @@
background-color: rgba(var(--center-channel-color-rgb), 0.04);
}
}
.Input__clear {
display: flex;
color: rgba(var(--center-channel-color), 0.68);
cursor: pointer;
}
}

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

@@ -0,0 +1,48 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {mount, shallow} from 'enzyme';
import React from 'react';
import OverlayTrigger from 'components/overlay_trigger';
import Input from './input';
describe('components/widgets/inputs/Input', () => {
test('should match snapshot', () => {
const wrapper = shallow(
<Input/>,
);
expect(wrapper).toMatchSnapshot();
});
test('should render with clearable enabled', () => {
const value = 'value';
const clearableTooltipText = 'tooltip text';
const onClear = jest.fn();
const wrapper = shallow(
<Input
value={value}
clearable={true}
clearableTooltipText={clearableTooltipText}
onClear={onClear}
/>,
);
const clear = wrapper.find('.Input__clear');
expect(clear.length).toEqual(1);
expect(wrapper.find('CloseCircleIcon').length).toEqual(1);
const tooltip = wrapper.find(OverlayTrigger);
expect(tooltip.length).toEqual(1);
const overlay = mount(tooltip.prop('overlay'));
expect(overlay.text()).toEqual(clearableTooltipText);
clear.first().simulate('mousedown');
expect(onClear).toHaveBeenCalledTimes(1);
});
});

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

@@ -3,10 +3,14 @@
import React, {useState, useEffect} from 'react';
import {useIntl} from 'react-intl';
import classNames from 'classnames';
import {ItemStatus} from 'utils/constants';
import {CloseCircleIcon} from '@mattermost/compass-icons/components';
import OverlayTrigger from 'components/overlay_trigger';
import Tooltip from 'components/tooltip';
import Constants, {ItemStatus} from 'utils/constants';
import './input.scss';
@@ -32,6 +36,9 @@ interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
useLegend?: boolean;
customMessage?: CustomMessageInputType;
inputSize?: SIZE;
clearable?: boolean;
clearableTooltipText?: string;
onClear?: () => void;
}
const Input = React.forwardRef((
@@ -56,9 +63,12 @@ const Input = React.forwardRef((
maxLength,
inputSize = SIZE.MEDIUM,
disabled,
clearable,
clearableTooltipText,
onFocus,
onBlur,
onChange,
onClear,
...otherProps
}: InputProps,
ref?: React.Ref<HTMLInputElement>,
@@ -109,6 +119,12 @@ const Input = React.forwardRef((
}
};
const handleOnClear = () => {
if (onClear) {
onClear();
}
};
const validateInput = () => {
if (!required || (value !== null && value !== '')) {
return;
@@ -121,6 +137,26 @@ const Input = React.forwardRef((
const error = customInputLabel?.type === 'error';
const limitExceeded = limit && value && !Array.isArray(value) ? value.toString().length - limit : 0;
const clearButton = value && clearable ? (
<div
className='Input__clear'
onMouseDown={handleOnClear}
onTouchEnd={handleOnClear}
>
<OverlayTrigger
delayShow={Constants.OVERLAY_TIME_DELAY}
placement='bottom'
overlay={(
<Tooltip id={'InputClearTooltip'}>
{clearableTooltipText || formatMessage({id: 'widget.input.clear', defaultMessage: 'Clear'})}
</Tooltip>
)}
>
<CloseCircleIcon size={18}/>
</OverlayTrigger>
</div>
) : null;
return (
<div className={classNames('Input_container', containerClassName, {disabled})}>
<fieldset
@@ -157,6 +193,7 @@ const Input = React.forwardRef((
</span>
)}
{inputSuffix}
{clearButton}
</div>
{addon}
</fieldset>

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

@@ -2652,7 +2652,6 @@
"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_to": "{username} updated the channel purpose to: {new}",
"app.plugin.marketplace_plugins.app_error": "Error connecting to the marketplace server. Please check your settings in the [System Console]({siteURL}/admin_console/plugins/plugin_management).",
"apps.error": "Error: {error}",
"apps.error.command.field_missing": "Required fields missing: `{fieldName}`.",
"apps.error.command.same_channel": "Channel repeated for field `{fieldName}`: `{option}`.",
@@ -3441,6 +3440,9 @@
"filtered_user_list.userStatus": "User Status:",
"flag_post.flag": "Save",
"flag_post.unflag": "Remove from Saved",
"footer_pagination.count": "Showing {startCount, number}-{endCount, number} of {total, number}",
"footer_pagination.next": "Next",
"footer_pagination.prev": "Previous",
"forward_post_button.label": "Forward",
"forward_post_modal.button.cancel": "Cancel",
"forward_post_modal.button.forward": "Forward",
@@ -4085,8 +4087,9 @@
"mark_all_threads_as_read_modal.title": "Mark all your threads as read?",
"marketplace_command.disabled": "The marketplace is disabled. Please contact your System Administrator for details.",
"marketplace_command.no_permission": "You do not have the appropriate permissions to access the marketplace.",
"marketplace_list.count_total_page": "{startCount, number} - {endCount, number} {total, plural, one {plugin} other {plugins}} of {total, number} total",
"marketplace_modal.install_plugins": "Install Plugins",
"marketplace_modal_list.no_plugins_filter": "No results for \"{filter}\"",
"marketplace_modal.app_error": "Error connecting to the marketplace server. Please check your settings in the <linkConsole>System Console</linkConsole>.",
"marketplace_modal.install_plugins": "Install plugins",
"marketplace_modal.installing": "Installing...",
"marketplace_modal.list.configure": "Configure",
"marketplace_modal.list.configure.plugin": "Configure {plugin}",
@@ -4103,12 +4106,12 @@
"marketplace_modal.list.update_confirmation.message.warning_major_version": "This update may contain breaking changes.",
"marketplace_modal.list.update_confirmation.message.warning_major_version_with_release_notes": "This update may contain breaking changes. Consult the [release notes](!{releaseNotesUrl}) before upgrading.",
"marketplace_modal.list.update_confirmation.title": "Confirm Plugin Update",
"marketplace_modal.no_plugins": "There are no plugins available at this time.",
"marketplace_modal.no_plugins_installed": "You do not have any plugins installed.",
"marketplace_modal.search": "Search Marketplace",
"marketplace_modal.no_plugins": "No plugins found",
"marketplace_modal.no_plugins_installed": "No plugins installed found",
"marketplace_modal.search": "Search marketplace",
"marketplace_modal.tabs.all_listing": "All",
"marketplace_modal.tabs.installed_listing": "Installed",
"marketplace_modal.title": "Marketplace",
"marketplace_modal.tabs.installed_listing": "Installed ({count})",
"marketplace_modal.title": "App Marketplace",
"members_popover.button.message": "message",
"menu.cloudFree.enterpriseTrialDescription": "Your trial is active until {trialEndDay}. Discover our top Enterprise features. <openModalLink>Learn more</openModalLink>",
"menu.cloudFree.enterpriseTrialTitle": "Enterprise Trial",
@@ -5676,6 +5679,7 @@
"welcome_post_renderer.user_message.first_paragraph": "Mattermost is an open source platform for secure communication, collaboration, and orchestration of work across tools and teams.",
"welcome_post_renderer.user_message.second_paragraph": "Here is a list of commands to use to try and get familiar with the platform.",
"welcome_post_renderer.user_message.title": "Welcome to Mattermost! :rocket:",
"widget.input.clear": "Clear",
"widget.input.required": "This field is required",
"widget.passwordInput.createPassword": "Choose a Password",
"widget.passwordInput.password": "Password",

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

@@ -114,9 +114,13 @@
display: flex;
font-size: 18px;
&::before {
&:first-child::before {
margin: 0 7px 0 0;
}
&:last-child::before {
margin: 0 0 0 7px;
}
}
}
@@ -146,7 +150,7 @@
outline: none;
}
&:disabled {
&:disabled:not(.always-show-enabled) {
background: rgba(var(--center-channel-color-rgb), 0.08);
color: rgba(var(--center-channel-color-rgb), 0.32);
cursor: not-allowed;

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

@@ -0,0 +1,41 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/GenericModal/FooterPagination should render default 1`] = `
<div
className="footer-pagination"
>
<div
className="footer-pagination__legend"
/>
<div
className="footer-pagination__button-container"
>
<button
className="footer-pagination__button-container__button disabled"
disabled={true}
onClick={[MockFunction]}
type="button"
>
<ChevronLeftIcon
size={16}
/>
<span>
Previous
</span>
</button>
<button
className="footer-pagination__button-container__button disabled"
disabled={true}
onClick={[MockFunction]}
type="button"
>
<span>
Next
</span>
<ChevronRightIcon
size={16}
/>
</button>
</div>
</div>
`;

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

@@ -0,0 +1,41 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
@import '../../../../../channels/src/sass/utils/mixins';
.footer-pagination {
display: flex;
flex: 1;
flex-direction: row;
align-items: center;
&__legend {
display: flex;
flex: 1;
align-items: center;
justify-content: flex-start;
color: rgba(var(--center-channel-color-rgb), 0.64);
font-size: 12px;
font-weight: 600;
line-height: 16px;
}
&__button-container {
display: flex;
align-items: center;
justify-content: center;
&__button {
@include tertiary-button;
@include button-small;
&:not(:first-child) {
margin-left: 8px;
}
> :not(:first-child) {
margin-left: 5px;
}
}
}
}

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

@@ -0,0 +1,88 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {shallow} from 'enzyme';
import {FooterPagination} from './';
describe('components/GenericModal/FooterPagination', () => {
const baseProps = {
page: 0,
total: 0,
itemsPerPage: 0,
onNextPage: jest.fn(),
onPreviousPage: jest.fn(),
};
test('should render default', () => {
const wrapper = shallow(
<FooterPagination {...baseProps}/>,
);
expect(wrapper).toMatchSnapshot();
});
test('should render pagination legend', () => {
const wrapper = shallow(
<FooterPagination
{...baseProps}
page={0}
total={17}
itemsPerPage={10}
/>,
);
const legend = wrapper.find('.footer-pagination__legend');
expect(legend.length).toEqual(1);
expect(legend.at(0).text()).toEqual('Showing 1-10 of 17');
});
test('should render pagination buttons', () => {
const wrapper = shallow(
<FooterPagination
{...baseProps}
page={1}
total={30}
itemsPerPage={10}
/>,
);
const buttons = wrapper.find('.footer-pagination__button-container__button');
expect(buttons.length).toEqual(2);
expect(buttons.at(0).text()).toEqual('<ChevronLeftIcon />Previous');
expect(buttons.at(1).text()).toEqual('Next<ChevronRightIcon />');
});
test('should handle pagination buttons', async () => {
const onPreviousPage = jest.fn();
const onNextPage = jest.fn();
const wrapper = shallow(
<FooterPagination
page={1}
total={30}
itemsPerPage={10}
onPreviousPage={onPreviousPage}
onNextPage={onNextPage}
/>,
);
const buttons = wrapper.find('.footer-pagination__button-container__button');
const prevButton = buttons.at(0);
const nextButton = buttons.at(1);
expect(prevButton.hasClass('disabled')).toBeFalsy();
expect(nextButton.hasClass('disabled')).toBeFalsy();
nextButton.simulate('click');
expect(onNextPage).toHaveBeenCalledTimes(1);
prevButton.simulate('click');
expect(onPreviousPage).toHaveBeenCalledTimes(1);
});
});

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

@@ -0,0 +1,93 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import classNames from 'classnames';
import React from 'react';
import {useIntl} from 'react-intl';
import {ChevronLeftIcon, ChevronRightIcon} from '@mattermost/compass-icons/components';
import './footer_pagination.scss';
const BUTTON_ICON_SIZE = 16;
type FooterPaginationProps = {
page: number;
total: number;
itemsPerPage: number;
onNextPage: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
onPreviousPage: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
};
export const FooterPagination = ({
page,
total,
itemsPerPage,
onNextPage,
onPreviousPage,
}: FooterPaginationProps) => {
const {formatMessage} = useIntl();
const startCount = page * itemsPerPage;
const endCount = Math.min(startCount + itemsPerPage, total);
const totalPages = Math.trunc((total - 1) / itemsPerPage);
const prevDisabled = page <= 0;
const nextDisabled = page >= totalPages;
return (
<div className='footer-pagination'>
<div className='footer-pagination__legend'>
{Boolean(total) && (
formatMessage(
{
id: 'footer_pagination.count',
defaultMessage: 'Showing {startCount, number}-{endCount, number} of {total, number}',
},
{
startCount: startCount + 1,
endCount,
total,
},
)
)}
</div>
<div className='footer-pagination__button-container'>
<button
type='button'
className={classNames(
'footer-pagination__button-container__button',
{disabled: prevDisabled},
)}
onClick={onPreviousPage}
disabled={prevDisabled}
>
<ChevronLeftIcon size={BUTTON_ICON_SIZE}/>
<span>
{formatMessage({
id: 'footer_pagination.prev',
defaultMessage: 'Previous',
})}
</span>
</button>
<button
type='button'
className={classNames(
'footer-pagination__button-container__button',
{disabled: nextDisabled},
)}
onClick={onNextPage}
disabled={nextDisabled}
>
<span>
{formatMessage({
id: 'footer_pagination.next',
defaultMessage: 'Next',
})}
</span>
<ChevronRightIcon size={BUTTON_ICON_SIZE}/>
</button>
</div>
</div>
);
};

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

@@ -0,0 +1,4 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export * from './footer_pagination';

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

@@ -34,13 +34,17 @@ export type Props = {
enforceFocus?: boolean;
container?: React.ReactNode | React.ReactNodeArray;
ariaLabel?: string;
errorText?: string;
errorText?: string | React.ReactNode;
compassDesign?: boolean;
backdrop?: boolean;
backdropClassName?: string;
tabIndex?: number;
children: React.ReactNode;
keyboardEscape?: boolean;
headerInput?: React.ReactNode;
bodyPadding?: boolean;
footerContent?: React.ReactNode;
footerDivider?: boolean;
};
type State = {
@@ -56,6 +60,7 @@ export class GenericModal extends React.PureComponent<Props, State> {
autoCloseOnConfirmButton: true,
enforceFocus: true,
keyboardEscape: true,
bodyPadding: true,
};
constructor(props: Props) {
@@ -195,7 +200,12 @@ export class GenericModal extends React.PureComponent<Props, State> {
className='GenericModal__wrapper-enter-key-press-catcher'
>
<Modal.Header closeButton={true}>
{this.props.compassDesign && headerText}
{this.props.compassDesign && (
<>
{headerText}
{this.props.headerInput}
</>
)}
</Modal.Header>
<Modal.Body>
{this.props.compassDesign ? (
@@ -208,14 +218,22 @@ export class GenericModal extends React.PureComponent<Props, State> {
) : (
headerText
)}
<div className='GenericModal__body'>
<div className={classNames('GenericModal__body', {padding: this.props.bodyPadding})}>
{this.props.children}
</div>
</Modal.Body>
{(cancelButton || confirmButton) && <Modal.Footer>
{cancelButton}
{confirmButton}
</Modal.Footer>}
{(cancelButton || confirmButton || this.props.footerContent) && (
<Modal.Footer className={classNames({divider: this.props.footerDivider})}>
{(cancelButton || confirmButton) ? (
<>
{cancelButton}
{confirmButton}
</>
) : (
this.props.footerContent
)}
</Modal.Footer>
)}
</div>
</FocusTrap>
</Modal>

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

@@ -7,6 +7,7 @@ export type {CircleSkeletonLoaderProps, RectangleSkeletonLoaderProps} from './sk
export type {Props as FocusTrapProps} from './focus_trap';
// components
export * from './generic_modal/footer_content';
export {GenericModal} from './generic_modal/generic_modal';
export {CircleSkeletonLoader, RectangleSkeletonLoader} from './skeleton_loader';
export * from './tour_tip';