- {itemsToDisplay.map((i) => {
- if (isPlugin(i)) {
- return (
-
- );
- }
-
- return (
-
- );
- })
- }
-
+ return (listing.length === 0 ? (
+
+
+
+ {getNoResultsMessage()}
- );
- }
-}
+ {noResultsAction && (
+
+ {noResultsAction.label}
+
+ )}
+
+ ) : (
+
+ {pageItems}
+
+ ));
+};
+
+export default MarketplaceList;
diff --git a/webapp/channels/src/components/plugin_marketplace/marketplace_list/navigation_row/__snapshots__/navigation_row.test.tsx.snap b/webapp/channels/src/components/plugin_marketplace/marketplace_list/navigation_row/__snapshots__/navigation_row.test.tsx.snap
deleted file mode 100644
index f08718159d..0000000000
--- a/webapp/channels/src/components/plugin_marketplace/marketplace_list/navigation_row/__snapshots__/navigation_row.test.tsx.snap
+++ /dev/null
@@ -1,157 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`components/marketplace/navigation_row should not render any buttons 1`] = `
-
-`;
-
-exports[`components/marketplace/navigation_row should render next and previous buttons 1`] = `
-
-`;
-
-exports[`components/marketplace/navigation_row should render only next button 1`] = `
-
-`;
-
-exports[`components/marketplace/navigation_row should render only previous button 1`] = `
-
-`;
diff --git a/webapp/channels/src/components/plugin_marketplace/marketplace_list/navigation_row/index.ts b/webapp/channels/src/components/plugin_marketplace/marketplace_list/navigation_row/index.ts
deleted file mode 100644
index 320c07dca9..0000000000
--- a/webapp/channels/src/components/plugin_marketplace/marketplace_list/navigation_row/index.ts
+++ /dev/null
@@ -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);
diff --git a/webapp/channels/src/components/plugin_marketplace/marketplace_list/navigation_row/navigation_button.tsx b/webapp/channels/src/components/plugin_marketplace/marketplace_list/navigation_row/navigation_button.tsx
deleted file mode 100644
index 8433739030..0000000000
--- a/webapp/channels/src/components/plugin_marketplace/marketplace_list/navigation_row/navigation_button.tsx
+++ /dev/null
@@ -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
) => void;
- messageId: string;
- defaultMessage: string;
-};
-
-export default class NavigationButton extends React.PureComponent {
- onClick = (event: React.MouseEvent): void => {
- event.preventDefault();
- this.props.onClick(event);
- };
-
- render(): JSX.Element {
- const {onClick, messageId, defaultMessage} = this.props;
- return (
-
-
-
- );
- }
-}
diff --git a/webapp/channels/src/components/plugin_marketplace/marketplace_list/navigation_row/navigation_row.test.tsx b/webapp/channels/src/components/plugin_marketplace/marketplace_list/navigation_row/navigation_row.test.tsx
deleted file mode 100644
index 4633263c55..0000000000
--- a/webapp/channels/src/components/plugin_marketplace/marketplace_list/navigation_row/navigation_row.test.tsx
+++ /dev/null
@@ -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(
- ,
- );
-
- 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(
- ,
- );
-
- 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(
- ,
- );
- 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(
- ,
- );
- expect(wrapper).toMatchSnapshot();
- expect(wrapper.find('NavigationButton')).toHaveLength(0);
- });
-});
diff --git a/webapp/channels/src/components/plugin_marketplace/marketplace_list/navigation_row/navigation_row.tsx b/webapp/channels/src/components/plugin_marketplace/marketplace_list/navigation_row/navigation_row.tsx
deleted file mode 100644
index 98cff683d0..0000000000
--- a/webapp/channels/src/components/plugin_marketplace/marketplace_list/navigation_row/navigation_row.tsx
+++ /dev/null
@@ -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) => void;
- onPreviousPageButtonClick: (event: React.MouseEvent) => void;
- theme: Theme;
-};
-
-export default class NavigationRow extends React.PureComponent {
- 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 (
-
- );
- };
-
- render(): JSX.Element {
- const style = getStyle(this.props.theme);
-
- return (
-
-
- {(this.props.page > 0) && (
-
- )}
-
-
- {this.renderCount()}
-
-
- {this.canShowNextButton() && (
-
- )}
-
-
- );
- }
-}
-
-const getStyle = makeStyleFromTheme((theme) => {
- return {
- count: {
- color: changeOpacity(theme.centerChannelColor, 0.6),
- },
- };
-});
diff --git a/webapp/channels/src/components/plugin_marketplace/marketplace_modal.scss b/webapp/channels/src/components/plugin_marketplace/marketplace_modal.scss
index f2f3dec629..5a0495c0fc 100644
--- a/webapp/channels/src/components/plugin_marketplace/marketplace_modal.scss
+++ b/webapp/channels/src/components/plugin_marketplace/marketplace_modal.scss
@@ -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;
}
}
diff --git a/webapp/channels/src/components/plugin_marketplace/marketplace_modal.test.tsx b/webapp/channels/src/components/plugin_marketplace/marketplace_modal.test.tsx
index 3e2938603c..c4c2181182 100644
--- a/webapp/channels/src/components/plugin_marketplace/marketplace_modal.test.tsx
+++ b/webapp/channels/src/components/plugin_marketplace/marketplace_modal.test.tsx
@@ -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(
- ,
- );
- expect(wrapper).toMatchSnapshot();
- });
+ const defaultProps = {
+ openedFrom: 'actions_menu' as OpenedFromType,
+ };
- it('should render with one plugin', () => {
- const wrapper = shallow(
- ,
- );
- expect(wrapper).toMatchSnapshot();
- });
-
- it('should render with plugins', () => {
- const wrapper = shallow(
- ,
- );
- expect(wrapper).toMatchSnapshot();
- });
- });
-
- describe('InstalledPlugins', () => {
- const baseProps = {
- changeTab: jest.fn(),
- };
-
- it('should render with no plugins', () => {
- const wrapper = shallow(
- ,
- );
- expect(wrapper).toMatchSnapshot();
- });
-
- it('should render with one plugin', () => {
- const wrapper = shallow(
- ,
- );
- expect(wrapper).toMatchSnapshot();
- });
-
- it('should render with multiple plugins', () => {
- const wrapper = shallow(
- ,
- );
- 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(
- ,
- );
- expect(wrapper).toMatchSnapshot();
- });
+ test('should render default', () => {
+ const wrapper = shallow(
+ ,
+ );
- test('should render with plugins installed', () => {
- const props = {
- ...baseProps,
- plugins: [
- ...baseProps.listing,
- sampleInstalledPlugin,
- ],
- installedListing: [
- sampleInstalledPlugin,
- ],
- };
+ expect(wrapper.shallow()).toMatchSnapshot();
+ });
- const wrapper = shallow(
- ,
- );
+ 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(
+ ,
+ );
- test('should fetch plugins when plugin status is changed', () => {
- const fetchListing = baseProps.actions.fetchListing;
- const wrapper = shallow( );
+ 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(
- ,
- );
+ mockState.views.marketplace.plugins = [
+ samplePlugin,
+ ];
- wrapper.setState({serverError: {name: 'some.error', message: 'Error test'}});
+ const wrapper = shallow(
+ ,
+ );
- expect(wrapper).toMatchSnapshot();
- });
+ wrapper.update();
- test('Should call for track event when searching', () => {
- const wrapper = shallow(
- ,
- );
+ 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(
+ ,
+ );
- shallow(
- ,
- );
+ 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(
+ ,
+ );
+
+ wrapper.update();
+
+ expect(wrapper.shallow()).toMatchSnapshot();
});
});
diff --git a/webapp/channels/src/components/plugin_marketplace/marketplace_modal.tsx b/webapp/channels/src/components/plugin_marketplace/marketplace_modal.tsx
index 59ac7510ba..062727f9e1 100644
--- a/webapp/channels/src/components/plugin_marketplace/marketplace_modal.tsx
+++ b/webapp/channels/src/components/plugin_marketplace/marketplace_modal.tsx
@@ -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) => (
+
+ {msg}
+
+);
+
export type OpenedFromType = 'actions_menu' | 'app_bar' | 'channel_header' | 'command' | 'open_plugin_install_post' | 'product_menu';
-type AllListingProps = {
- listing: Array;
-};
-
-// AllListing renders the contents of the all listing tab.
-export const AllListing = ({listing}: AllListingProps): JSX.Element => {
- if (listing.length === 0) {
- return (
-
- );
- }
-
- return ;
-};
-
-type InstalledListingProps = {
- installedItems: Array;
- changeTab: SelectCallback;
-};
-
-// InstalledListing renders the contents of the installed listing tab.
-export const InstalledListing = ({installedItems, changeTab}: InstalledListingProps): JSX.Element => {
- if (installedItems.length === 0) {
- return (
-
-
-
-
-
-
-
changeTab(MarketplaceTabs.ALL_LISTING)}
- data-testid='Install-Plugins-button'
- >
-
-
-
- );
- }
-
- return ;
-};
-
-export type MarketplaceModalProps = {
- show: boolean;
- listing: Array;
- installedListing: Array;
- siteURL: string;
- pluginStatuses?: Record;
- firstAdminVisitMarketplaceStatus: boolean;
+type MarketplaceModalProps = {
openedFrom: OpenedFromType;
- actions: {
- closeModal: () => void;
- fetchListing(localOnly?: boolean): Promise<{error?: Error}>;
- filterListing(filter: string): Promise<{error?: Error}>;
- setFirstAdminVisitMarketplaceStatus(): Promise;
- getPluginStatuses(): Promise;
- };
-};
-
-type MarketplaceModalState = {
- tabKey: unknown;
- loading: boolean;
- serverError?: Error;
- filter: string;
-};
-
-// MarketplaceModal is the marketplace modal.
-export default class MarketplaceModal extends React.PureComponent {
- private filterRef: React.RefObject;
-
- 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 => {
- 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 => {
- 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 = (
-
- );
-
- let errorBanner = null;
- if (this.state.serverError) {
- errorBanner = (
-
- );
- }
-
- return (
-
-
- {errorBanner}
-
-
-
-
-
-
- {input}
-
-
- {this.state.loading ? : }
-
-
-
-
-
-
-
-
- );
- }
}
+
+const MarketplaceModal = ({
+ openedFrom,
+}: MarketplaceModalProps) => {
+ const dispatch = useDispatch();
+ const {formatMessage} = useIntl();
+ const listRef = useRef(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) => {
+ 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(() => (
+ }
+ 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(() => (
+
+ ), [installedListing.length, listing.length, page, handleOnNextPage, handleOnPreviousPage, tabKey]);
+
+ return (
+ System Console.',
+ },
+ {linkConsole},
+ )
+ ) : undefined}
+ show={show}
+ compassDesign={true}
+ bodyPadding={false}
+ footerDivider={true}
+ onExited={handleOnClose}
+ footerContent={getFooterContent()}
+ headerInput={getHeaderInput()}
+ >
+
+
+ {loading ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+ );
+};
+
+export default MarketplaceModal;
diff --git a/webapp/channels/src/components/product_notices_modal/__snapshots__/product_notices.test.tsx.snap b/webapp/channels/src/components/product_notices_modal/__snapshots__/product_notices.test.tsx.snap
index abebd8539b..5252df431b 100644
--- a/webapp/channels/src/components/product_notices_modal/__snapshots__/product_notices.test.tsx.snap
+++ b/webapp/channels/src/components/product_notices_modal/__snapshots__/product_notices.test.tsx.snap
@@ -4,6 +4,7 @@ exports[`ProductNoticesModal Match snapshot for single notice 1`] = `
+
+
+
+
+
+
+
+`;
diff --git a/webapp/channels/src/components/widgets/inputs/input/input.scss b/webapp/channels/src/components/widgets/inputs/input/input.scss
index 281c31175f..eacee3bfba 100644
--- a/webapp/channels/src/components/widgets/inputs/input/input.scss
+++ b/webapp/channels/src/components/widgets/inputs/input/input.scss
@@ -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;
+ }
}
diff --git a/webapp/channels/src/components/widgets/inputs/input/input.test.tsx b/webapp/channels/src/components/widgets/inputs/input/input.test.tsx
new file mode 100644
index 0000000000..b77523ba2f
--- /dev/null
+++ b/webapp/channels/src/components/widgets/inputs/input/input.test.tsx
@@ -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(
+