diff --git a/webapp/channels/src/actions/command.ts b/webapp/channels/src/actions/command.ts index 2b89fae193..62195ac6dc 100644 --- a/webapp/channels/src/actions/command.ts +++ b/webapp/channels/src/actions/command.ts @@ -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'; diff --git a/webapp/channels/src/components/__snapshots__/generic_modal.test.tsx.snap b/webapp/channels/src/components/__snapshots__/generic_modal.test.tsx.snap index 95b80cb2da..cf1462ee4a 100644 --- a/webapp/channels/src/components/__snapshots__/generic_modal.test.tsx.snap +++ b/webapp/channels/src/components/__snapshots__/generic_modal.test.tsx.snap @@ -59,7 +59,7 @@ exports[`components/GenericModal should match snapshot for base case 1`] = `
@@ -126,11 +126,12 @@ exports[`components/GenericModal should match snapshot with both buttons 1`] = `
-`; - -exports[`components/marketplace/ AllListing should render with one plugin 1`] = ` - -`; - -exports[`components/marketplace/ AllListing should render with plugins 1`] = ` - -`; - -exports[`components/marketplace/ InstalledPlugins should render with multiple plugins 1`] = ` - -`; - -exports[`components/marketplace/ InstalledPlugins should render with no plugins 1`] = ` -
-
- -
- -
- -
-`; - -exports[`components/marketplace/ InstalledPlugins should render with one plugin 1`] = ` - -`; - -exports[`components/marketplace/ MarketplaceModal should render with error banner 1`] = ` - -
-
- +

+ App Marketplace +

+
+ } + inputSize="large" + name="searchMarketplaceTextbox" + onChange={[Function]} + onClear={[Function]} + placeholder="Search marketplace" + type="text" + useLegend={false} + value="" /> -
- - -
-
+ + `; -exports[`components/marketplace/ MarketplaceModal should render with no plugins installed 1`] = ` - - + +
+ + + + + + + + +
+ + + + -
-
+ + `; -exports[`components/marketplace/ MarketplaceModal should render with plugins installed 1`] = ` - - + + + + + - - + + +`; + +exports[`components/marketplace/ should render with plugins available 1`] = ` + + +
+ +
+

+ App Marketplace +

+
+ + } + inputSize="large" + name="searchMarketplaceTextbox" + onChange={[Function]} + onClear={[Function]} + placeholder="Search marketplace" + type="text" + useLegend={false} + value="" + /> +
+ +
+ + + + + + + + +
+
+ + + +
+
+
+`; + +exports[`components/marketplace/ should render with plugins installed 1`] = ` + + +
+ +
+

+ App Marketplace +

+
+ + } + inputSize="large" + name="searchMarketplaceTextbox" + onChange={[Function]} + onClear={[Function]} + placeholder="Search marketplace" + type="text" + useLegend={false} + value="" + /> +
+ +
+ + + + + + + + +
+
+ + + +
+
+
`; diff --git a/webapp/channels/src/components/plugin_marketplace/index.ts b/webapp/channels/src/components/plugin_marketplace/index.ts deleted file mode 100644 index b004efae32..0000000000 --- a/webapp/channels/src/components/plugin_marketplace/index.ts +++ /dev/null @@ -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; - getPluginStatuses(): Promise; -} - -function mapDispatchToProps(dispatch: Dispatch) { - return { - actions: bindActionCreators, Actions>({ - closeModal: () => closeModal(ModalIdentifiers.PLUGIN_MARKETPLACE), - fetchListing, - filterListing, - setFirstAdminVisitMarketplaceStatus, - getPluginStatuses, - }, dispatch), - }; -} - -export default connect(mapStateToProps, mapDispatchToProps)(MarketplaceModal); diff --git a/webapp/channels/src/components/plugin_marketplace/marketplace_item/marketplace_item_app/__snapshots__/marketplace_item_app.test.tsx.snap b/webapp/channels/src/components/plugin_marketplace/marketplace_item/marketplace_item_app/__snapshots__/marketplace_item_app.test.tsx.snap index 1ec6de8134..7fb956f716 100644 --- a/webapp/channels/src/components/plugin_marketplace/marketplace_item/marketplace_item_app/__snapshots__/marketplace_item_app.test.tsx.snap +++ b/webapp/channels/src/components/plugin_marketplace/marketplace_item/marketplace_item_app/__snapshots__/marketplace_item_app.test.tsx.snap @@ -11,7 +11,7 @@ exports[`components/MarketplaceItemApp MarketplaceItem should render 1`] = ` } button={ + )} + + ) : ( +
+ {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 ( -
-
- -
- -
- -
- ); - } - - 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} - - - - ); - } } + +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( + , + ); + + expect(wrapper).toMatchSnapshot(); + }); + + test('should render with clearable enabled', () => { + const value = 'value'; + const clearableTooltipText = 'tooltip text'; + const onClear = jest.fn(); + + const wrapper = shallow( + , + ); + + 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); + }); +}); diff --git a/webapp/channels/src/components/widgets/inputs/input/input.tsx b/webapp/channels/src/components/widgets/inputs/input/input.tsx index 1a55dbb9f4..bcc66e4a20 100644 --- a/webapp/channels/src/components/widgets/inputs/input/input.tsx +++ b/webapp/channels/src/components/widgets/inputs/input/input.tsx @@ -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 { 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, @@ -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 ? ( +
+ + {clearableTooltipText || formatMessage({id: 'widget.input.clear', defaultMessage: 'Clear'})} + + )} + > + + +
+ ) : null; + return (
)} {inputSuffix} + {clearButton}
{addon} diff --git a/webapp/channels/src/i18n/en.json b/webapp/channels/src/i18n/en.json index b6cdf2c336..e273515c5a 100644 --- a/webapp/channels/src/i18n/en.json +++ b/webapp/channels/src/i18n/en.json @@ -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 System Console.", + "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. Learn more", "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", diff --git a/webapp/channels/src/sass/utils/_mixins.scss b/webapp/channels/src/sass/utils/_mixins.scss index 2d3f74df34..bb4ef5b0a0 100644 --- a/webapp/channels/src/sass/utils/_mixins.scss +++ b/webapp/channels/src/sass/utils/_mixins.scss @@ -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; diff --git a/webapp/platform/components/src/generic_modal/footer_content/__snapshots__/footer_pagination.test.tsx.snap b/webapp/platform/components/src/generic_modal/footer_content/__snapshots__/footer_pagination.test.tsx.snap new file mode 100644 index 0000000000..29d49c0a2a --- /dev/null +++ b/webapp/platform/components/src/generic_modal/footer_content/__snapshots__/footer_pagination.test.tsx.snap @@ -0,0 +1,41 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`components/GenericModal/FooterPagination should render default 1`] = ` +
+
+
+ + +
+
+`; diff --git a/webapp/platform/components/src/generic_modal/footer_content/footer_pagination.scss b/webapp/platform/components/src/generic_modal/footer_content/footer_pagination.scss new file mode 100644 index 0000000000..35594f8bc5 --- /dev/null +++ b/webapp/platform/components/src/generic_modal/footer_content/footer_pagination.scss @@ -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; + } + } + } +} diff --git a/webapp/platform/components/src/generic_modal/footer_content/footer_pagination.test.tsx b/webapp/platform/components/src/generic_modal/footer_content/footer_pagination.test.tsx new file mode 100644 index 0000000000..9b80d2a2fe --- /dev/null +++ b/webapp/platform/components/src/generic_modal/footer_content/footer_pagination.test.tsx @@ -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( + , + ); + + expect(wrapper).toMatchSnapshot(); + }); + + test('should render pagination legend', () => { + const wrapper = shallow( + , + ); + + 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( + , + ); + + const buttons = wrapper.find('.footer-pagination__button-container__button'); + + expect(buttons.length).toEqual(2); + expect(buttons.at(0).text()).toEqual('Previous'); + expect(buttons.at(1).text()).toEqual('Next'); + }); + + test('should handle pagination buttons', async () => { + const onPreviousPage = jest.fn(); + const onNextPage = jest.fn(); + + const wrapper = shallow( + , + ); + + 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); + }); +}); diff --git a/webapp/platform/components/src/generic_modal/footer_content/footer_pagination.tsx b/webapp/platform/components/src/generic_modal/footer_content/footer_pagination.tsx new file mode 100644 index 0000000000..3efd3c7a75 --- /dev/null +++ b/webapp/platform/components/src/generic_modal/footer_content/footer_pagination.tsx @@ -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) => void; + onPreviousPage: (event: React.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 ( +
+
+ {Boolean(total) && ( + formatMessage( + { + id: 'footer_pagination.count', + defaultMessage: 'Showing {startCount, number}-{endCount, number} of {total, number}', + }, + { + startCount: startCount + 1, + endCount, + total, + }, + ) + )} +
+
+ + +
+
+ ); +}; diff --git a/webapp/platform/components/src/generic_modal/footer_content/index.ts b/webapp/platform/components/src/generic_modal/footer_content/index.ts new file mode 100644 index 0000000000..6097345e39 --- /dev/null +++ b/webapp/platform/components/src/generic_modal/footer_content/index.ts @@ -0,0 +1,4 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +export * from './footer_pagination'; diff --git a/webapp/platform/components/src/generic_modal/generic_modal.tsx b/webapp/platform/components/src/generic_modal/generic_modal.tsx index 9f5ea7a357..68272eb8f4 100644 --- a/webapp/platform/components/src/generic_modal/generic_modal.tsx +++ b/webapp/platform/components/src/generic_modal/generic_modal.tsx @@ -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 { autoCloseOnConfirmButton: true, enforceFocus: true, keyboardEscape: true, + bodyPadding: true, }; constructor(props: Props) { @@ -195,7 +200,12 @@ export class GenericModal extends React.PureComponent { className='GenericModal__wrapper-enter-key-press-catcher' > - {this.props.compassDesign && headerText} + {this.props.compassDesign && ( + <> + {headerText} + {this.props.headerInput} + + )} {this.props.compassDesign ? ( @@ -208,14 +218,22 @@ export class GenericModal extends React.PureComponent { ) : ( headerText )} -
+
{this.props.children}
- {(cancelButton || confirmButton) && - {cancelButton} - {confirmButton} - } + {(cancelButton || confirmButton || this.props.footerContent) && ( + + {(cancelButton || confirmButton) ? ( + <> + {cancelButton} + {confirmButton} + + ) : ( + this.props.footerContent + )} + + )}
diff --git a/webapp/platform/components/src/index.tsx b/webapp/platform/components/src/index.tsx index f1615adf19..2ead4f1994 100644 --- a/webapp/platform/components/src/index.tsx +++ b/webapp/platform/components/src/index.tsx @@ -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';