MM-52981, MM-53559: Streamlined in-product marketplace (#24311)

This commit is contained in:
Caleb Roseland
2023-08-25 14:39:25 -05:00
committed by GitHub
parent d57581d01e
commit 818225dffe
25 changed files with 724 additions and 142 deletions

View File

@@ -34,6 +34,10 @@ jobs:
run: |
cd server
make setup-go-work
- name: Setup needed prepackaged plugins
run: |
cd server
make prepackaged-plugins PLUGIN_PACKAGES=mattermost-plugin-jira-v3.2.5
- name: Run docker compose
run: |
cd server/build

View File

@@ -17,6 +17,7 @@ describe('Plugin Marketplace', () => {
before(() => {
cy.shouldNotRunOnCloudEdition();
cy.shouldHaveFeatureFlag('StreamlinedMarketplace', 'false'); // https://mattermost.atlassian.net/browse/MM-54230
cy.shouldHavePluginUploadEnabled();
cy.apiInitSetup().then(({team}) => {
@@ -72,6 +73,7 @@ describe('Plugin Marketplace', () => {
cy.get('#marketplaceTabs-pane-allListing').should('be.visible');
});
// https://mattermost.atlassian.net/browse/MM-54230
it('MM-T2001 autofocus on search plugin input box', () => {
cy.uiClose();
@@ -82,6 +84,7 @@ describe('Plugin Marketplace', () => {
cy.findByPlaceholderText('Search Marketplace').should('be.focused');
});
// https://mattermost.atlassian.net/browse/MM-54230
it('render the list of all plugins by default', () => {
// * Verify all plugins tab should be active
cy.get('#marketplaceTabs-tab-allListing').should('be.visible').parent().should('have.class', 'active');
@@ -92,6 +95,7 @@ describe('Plugin Marketplace', () => {
cy.get('#marketplaceTabs-pane-installed').should('not.exist');
});
// https://mattermost.atlassian.net/browse/MM-54230
// this test uses exist, not visible, due to issues with Cypress
it('render the list of installed plugins on demand', () => {
// # Click on installed plugins tab
@@ -117,6 +121,7 @@ describe('Plugin Marketplace', () => {
cy.get('#modal_marketplace').should('not.exist');
});
// https://mattermost.atlassian.net/browse/MM-54230
it('should filter all on search', () => {
// # Load all plugins before searching
cy.get('.more-modal__row').should('have.length', 15);
@@ -136,6 +141,7 @@ describe('Plugin Marketplace', () => {
should('have.length', 1);
});
// https://mattermost.atlassian.net/browse/MM-54230
it('should show an error bar on failing to filter', () => {
// # Enable Plugin Marketplace
cy.apiUpdateConfig({
@@ -168,6 +174,7 @@ describe('Plugin Marketplace', () => {
cy.get('#marketplace-plugin-com\\.mattermost\\.webex').find('.btn.btn-outline', {timeout: TIMEOUTS.ONE_MIN}).scrollIntoView().should('be.visible').and('have.text', 'Configure');
});
// https://mattermost.atlassian.net/browse/MM-54230
it('should install a plugin from search results on demand', () => {
// # Uninstall any existing webex plugin
cy.apiRemovePluginById('com.mattermost.webex');
@@ -226,6 +233,7 @@ describe('Plugin Marketplace', () => {
cy.get('#marketplace-plugin-github').should('be.visible');
});
// https://mattermost.atlassian.net/browse/MM-54230
it('MM-T1986 change tab to "All Plugins" when "Install Plugins" link is clicked', () => {
cy.get('#marketplaceTabs').scrollIntoView().should('be.visible').within(() => {
// # Switch tab to installed plugin

View File

@@ -679,6 +679,7 @@ const defaultServerConfig: AdminConfig = {
OnboardingTourTips: true,
DeprecateCloudFree: false,
CloudReverseTrial: false,
StreamlinedMarketplace: true
},
ImportSettings: {
Directory: './import',

View File

@@ -157,7 +157,8 @@ MMCTL_PACKAGES=$(shell $(GO) list ./... | grep -E 'server/v8/cmd/mmctl')
TEMPLATES_DIR=templates
# Plugins Packages
PLUGIN_PACKAGES ?= mattermost-plugin-antivirus-v1.0.0
PLUGIN_PACKAGES ?= $(PLUGIN_PACKAGES:)
PLUGIN_PACKAGES += mattermost-plugin-antivirus-v1.0.0
PLUGIN_PACKAGES += mattermost-plugin-autolink-v1.4.0
PLUGIN_PACKAGES += mattermost-plugin-aws-SNS-v1.2.0
PLUGIN_PACKAGES += mattermost-plugin-calls-v0.18.0
@@ -300,7 +301,7 @@ plugin-checker:
$(GO) run $(GOFLAGS) ./public/plugin/checker
prepackaged-plugins: ## Populate the prepackaged-plugins directory
@echo Downloading prepackaged plugins
@echo Downloading prepackaged plugins: $(PLUGIN_PACKAGES)
mkdir -p prepackaged_plugins
@cd prepackaged_plugins && for plugin_package in $(PLUGIN_PACKAGES) ; do \
curl -f -O -L https://plugins-store.test.mattermost.com/release/$$plugin_package.tar.gz; \

View File

@@ -462,6 +462,9 @@ func TestDisableOnRemove(t *testing.T) {
}
func TestGetMarketplacePlugins(t *testing.T) {
os.Setenv("MM_FEATUREFLAGS_STREAMLINEDMARKETPLACE", "false")
defer os.Unsetenv("MM_FEATUREFLAGS_STREAMLINEDMARKETPLACE")
th := Setup(t)
defer th.TearDown()
@@ -682,6 +685,9 @@ func TestGetMarketplacePlugins(t *testing.T) {
}
func TestGetInstalledMarketplacePlugins(t *testing.T) {
os.Setenv("MM_FEATUREFLAGS_STREAMLINEDMARKETPLACE", "false")
defer os.Unsetenv("MM_FEATUREFLAGS_STREAMLINEDMARKETPLACE")
samplePlugins := []*model.MarketplacePlugin{
{
BaseMarketplacePlugin: &model.BaseMarketplacePlugin{
@@ -825,6 +831,9 @@ func TestGetInstalledMarketplacePlugins(t *testing.T) {
}
func TestSearchGetMarketplacePlugins(t *testing.T) {
os.Setenv("MM_FEATUREFLAGS_STREAMLINEDMARKETPLACE", "false")
defer os.Unsetenv("MM_FEATUREFLAGS_STREAMLINEDMARKETPLACE")
samplePlugins := []*model.MarketplacePlugin{
{
BaseMarketplacePlugin: &model.BaseMarketplacePlugin{
@@ -950,6 +959,9 @@ func TestSearchGetMarketplacePlugins(t *testing.T) {
}
func TestGetLocalPluginInMarketplace(t *testing.T) {
os.Setenv("MM_FEATUREFLAGS_STREAMLINEDMARKETPLACE", "false")
defer os.Unsetenv("MM_FEATUREFLAGS_STREAMLINEDMARKETPLACE")
th := Setup(t)
defer th.TearDown()
@@ -1111,6 +1123,9 @@ func TestGetLocalPluginInMarketplace(t *testing.T) {
}
func TestGetRemotePluginInMarketplace(t *testing.T) {
os.Setenv("MM_FEATUREFLAGS_STREAMLINEDMARKETPLACE", "false")
defer os.Unsetenv("MM_FEATUREFLAGS_STREAMLINEDMARKETPLACE")
th := Setup(t)
defer th.TearDown()
@@ -1166,7 +1181,70 @@ func TestGetRemotePluginInMarketplace(t *testing.T) {
require.NoError(t, err)
}
func TestRemoteMarketplaceDisabledByStreamlinedMarketplaceFlag(t *testing.T) {
os.Setenv("MM_FEATUREFLAGS_STREAMLINEDMARKETPLACE", "true")
defer os.Unsetenv("MM_FEATUREFLAGS_STREAMLINEDMARKETPLACE")
th := Setup(t)
defer th.TearDown()
marketplacePlugins := []*model.MarketplacePlugin{
{
BaseMarketplacePlugin: &model.BaseMarketplacePlugin{
HomepageURL: "https://example.com/mattermost/mattermost-plugin-nps",
IconData: "https://example.com/icon.svg",
DownloadURL: "www.github.com/example",
Manifest: &model.Manifest{
Id: "marketplace.test",
Name: "marketplacetest",
Description: "a marketplace plugin",
Version: "0.1.2",
MinServerVersion: "",
},
},
InstalledVersion: "",
},
}
testServer := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) {
res.WriteHeader(http.StatusOK)
json, err := json.Marshal([]*model.MarketplacePlugin{marketplacePlugins[0]})
require.NoError(t, err)
res.Write(json)
}))
defer testServer.Close()
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.PluginSettings.Enable = true
*cfg.PluginSettings.EnableMarketplace = true
*cfg.PluginSettings.EnableRemoteMarketplace = true
*cfg.PluginSettings.EnableUploads = true
*cfg.PluginSettings.MarketplaceURL = testServer.URL
})
prepackagePlugin := &plugin.PrepackagedPlugin{
Manifest: &model.Manifest{
Version: "0.0.1",
Id: "prepackaged.test",
},
}
env := th.App.GetPluginsEnvironment()
env.SetPrepackagedPlugins([]*plugin.PrepackagedPlugin{prepackagePlugin}, nil)
// No marketplace plugins returned
plugins, _, err := th.SystemAdminClient.GetMarketplacePlugins(context.Background(), &model.MarketplacePluginFilter{})
require.NoError(t, err)
// Only returns the prepackaged plugins
require.Len(t, plugins, 1)
require.Equal(t, prepackagePlugin.Manifest, plugins[0].Manifest)
}
func TestGetPrepackagedPluginInMarketplace(t *testing.T) {
os.Setenv("MM_FEATUREFLAGS_STREAMLINEDMARKETPLACE", "false")
defer os.Unsetenv("MM_FEATUREFLAGS_STREAMLINEDMARKETPLACE")
th := Setup(t)
defer th.TearDown()
@@ -1212,6 +1290,30 @@ func TestGetPrepackagedPluginInMarketplace(t *testing.T) {
env := th.App.GetPluginsEnvironment()
env.SetPrepackagedPlugins([]*plugin.PrepackagedPlugin{prepackagePlugin}, nil)
t.Run("prepackaged plugins are shown in Cloud", func(t *testing.T) {
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.PluginSettings.EnableRemoteMarketplace = true
*cfg.PluginSettings.EnableUploads = true
})
lic := th.App.Srv().License()
th.App.Srv().SetLicense(model.NewTestLicense("cloud"))
defer th.App.Srv().SetLicense(lic)
plugins, _, err := th.SystemAdminClient.GetMarketplacePlugins(context.Background(), &model.MarketplacePluginFilter{})
require.NoError(t, err)
expectedPlugins := marketplacePlugins
expectedPlugins = append(expectedPlugins, &model.MarketplacePlugin{
BaseMarketplacePlugin: &model.BaseMarketplacePlugin{
Manifest: prepackagePlugin.Manifest,
},
})
require.ElementsMatch(t, expectedPlugins, plugins)
require.Len(t, plugins, 2)
})
t.Run("get remote and prepackaged plugins", func(t *testing.T) {
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.PluginSettings.EnableRemoteMarketplace = true
@@ -1271,24 +1373,12 @@ func TestGetPrepackagedPluginInMarketplace(t *testing.T) {
require.Len(t, plugins, 1)
require.Equal(t, newerPrepackagePlugin.Manifest, plugins[0].Manifest)
})
t.Run("prepackaged plugins are not shown in Cloud", func(t *testing.T) {
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.PluginSettings.EnableRemoteMarketplace = true
*cfg.PluginSettings.EnableUploads = true
})
th.App.Srv().SetLicense(model.NewTestLicense("cloud"))
plugins, _, err := th.SystemAdminClient.GetMarketplacePlugins(context.Background(), &model.MarketplacePluginFilter{})
require.NoError(t, err)
require.ElementsMatch(t, marketplacePlugins, plugins)
require.Len(t, plugins, 1)
})
}
func TestInstallMarketplacePlugin(t *testing.T) {
os.Setenv("MM_FEATUREFLAGS_STREAMLINEDMARKETPLACE", "false")
defer os.Unsetenv("MM_FEATUREFLAGS_STREAMLINEDMARKETPLACE")
th := Setup(t).InitBasic()
defer th.TearDown()
@@ -1639,6 +1729,9 @@ func TestInstallMarketplacePlugin(t *testing.T) {
}
func TestInstallMarketplacePluginPrepackagedDisabled(t *testing.T) {
os.Setenv("MM_FEATUREFLAGS_STREAMLINEDMARKETPLACE", "false")
defer os.Unsetenv("MM_FEATUREFLAGS_STREAMLINEDMARKETPLACE")
path, _ := fileutils.FindDir("tests")
signatureFilename := "testplugin2.tar.gz.sig"

View File

@@ -828,6 +828,8 @@ func TestPushNotificationAck(t *testing.T) {
}
func TestCompleteOnboarding(t *testing.T) {
os.Setenv("MM_FEATUREFLAGS_STREAMLINEDMARKETPLACE", "false")
defer os.Unsetenv("MM_FEATUREFLAGS_STREAMLINEDMARKETPLACE")
th := Setup(t)
defer th.TearDown()

View File

@@ -552,7 +552,7 @@ func (a *App) GetPlugins() (*model.PluginsResponse, *model.AppError) {
func (a *App) GetMarketplacePlugins(filter *model.MarketplacePluginFilter) ([]*model.MarketplacePlugin, *model.AppError) {
plugins := map[string]*model.MarketplacePlugin{}
if *a.Config().PluginSettings.EnableRemoteMarketplace && !filter.LocalOnly {
if *a.Config().PluginSettings.EnableRemoteMarketplace && !a.Config().FeatureFlags.StreamlinedMarketplace && !filter.LocalOnly {
p, appErr := a.getRemotePlugins()
if appErr != nil {
return nil, appErr
@@ -561,20 +561,12 @@ func (a *App) GetMarketplacePlugins(filter *model.MarketplacePluginFilter) ([]*m
}
if !filter.RemoteOnly {
// Some plugin don't work on cloud. The remote Marketplace is aware of this fact,
// but prepackaged plugins are not. Hence, on a cloud installation prepackaged plugins
// shouldn't be shown in the Marketplace modal.
// This is a short term fix. The long term solution is to have a separate set of
// prepacked plugins for cloud: https://mattermost.atlassian.net/browse/MM-31331.
license := a.Srv().License()
if license == nil || !license.IsCloud() {
appErr := a.mergePrepackagedPlugins(plugins)
if appErr != nil {
return nil, appErr
}
appErr := a.mergePrepackagedPlugins(plugins)
if appErr != nil {
return nil, appErr
}
appErr := a.mergeLocalPlugins(plugins)
appErr = a.mergeLocalPlugins(plugins)
if appErr != nil {
return nil, appErr
}

View File

@@ -289,7 +289,7 @@ func (ch *Channels) InstallMarketplacePlugin(request *model.InstallMarketplacePl
signatureFile = bytes.NewReader(prepackagedPlugin.Signature)
}
if *ch.cfgSvc.Config().PluginSettings.EnableRemoteMarketplace {
if *ch.cfgSvc.Config().PluginSettings.EnableRemoteMarketplace && !ch.cfgSvc.Config().FeatureFlags.StreamlinedMarketplace {
var plugin *model.BaseMarketplacePlugin
plugin, appErr = ch.getRemoteMarketplacePlugin(request.Id, request.Version)
// The plugin might only be prepackaged and not on the Marketplace.

View File

@@ -48,6 +48,8 @@ type FeatureFlags struct {
EnableExportDirectDownload bool
DataRetentionConcurrencyEnabled bool
StreamlinedMarketplace bool
}
func (f *FeatureFlags) SetDefaults() {
@@ -65,6 +67,7 @@ func (f *FeatureFlags) SetDefaults() {
f.CloudReverseTrial = false
f.EnableExportDirectDownload = false
f.DataRetentionConcurrencyEnabled = true
f.StreamlinedMarketplace = true
}
// ToMap returns the feature flags as a map[string]string

View File

@@ -18,12 +18,14 @@ import {GenericAction} from 'mattermost-redux/types/actions';
import {appsFeatureFlagEnabled} from 'mattermost-redux/selectors/entities/apps';
import PluginManagement from './plugin_management';
import {streamlinedMarketplaceEnabled} from 'mattermost-redux/selectors/entities/preferences';
function mapStateToProps(state: any) {
return {
plugins: state.entities.admin.plugins,
pluginStatuses: state.entities.admin.pluginStatuses,
appsFeatureFlagEnabled: appsFeatureFlagEnabled(state),
streamlinedMarketplaceFlagEnabled: streamlinedMarketplaceEnabled(state),
};
}

View File

@@ -91,6 +91,7 @@ describe('components/PluginManagement', () => {
},
},
appsFeatureFlagEnabled: false,
streamlinedMarketplaceFlagEnabled: false,
actions: {
uploadPlugin: jest.fn(),
installPluginFromUrl: jest.fn(),
@@ -234,6 +235,7 @@ describe('components/PluginManagement', () => {
pluginStatuses: {},
plugins: {},
appsFeatureFlagEnabled: false,
streamlinedMarketplaceFlagEnabled: false,
actions: {
uploadPlugin: jest.fn(),
installPluginFromUrl: jest.fn(),
@@ -324,6 +326,7 @@ describe('components/PluginManagement', () => {
},
},
appsFeatureFlagEnabled: false,
streamlinedMarketplaceFlagEnabled: false,
actions: {
uploadPlugin: jest.fn(),
installPluginFromUrl: jest.fn(),
@@ -381,6 +384,7 @@ describe('components/PluginManagement', () => {
},
},
appsFeatureFlagEnabled: false,
streamlinedMarketplaceFlagEnabled: false,
actions: {
uploadPlugin: jest.fn(),
installPluginFromUrl: jest.fn(),
@@ -438,6 +442,7 @@ describe('components/PluginManagement', () => {
},
},
appsFeatureFlagEnabled: false,
streamlinedMarketplaceFlagEnabled: false,
actions: {
uploadPlugin: jest.fn(),
installPluginFromUrl: jest.fn(),
@@ -497,6 +502,7 @@ describe('components/PluginManagement', () => {
},
},
appsFeatureFlagEnabled: false,
streamlinedMarketplaceFlagEnabled: false,
actions: {
uploadPlugin: jest.fn(),
installPluginFromUrl: jest.fn(),

View File

@@ -205,16 +205,17 @@ const PluginItem = ({
className={deactivating || isDisabled ? 'disabled' : ''}
onClick={handleDisable}
>
{deactivating ?
{deactivating ? (
<FormattedMessage
id='admin.plugin.disabling'
defaultMessage='Disabling...'
/> :
/>
) : (
<FormattedMessage
id='admin.plugin.disable'
defaultMessage='Disable'
/>
}
)}
</a>
);
} else {
@@ -224,16 +225,17 @@ const PluginItem = ({
className={activating || isDisabled ? 'disabled' : ''}
onClick={handleEnable}
>
{activating ?
{activating ? (
<FormattedMessage
id='admin.plugin.enabling'
defaultMessage='Enabling...'
/> :
/>
) : (
<FormattedMessage
id='admin.plugin.enable'
defaultMessage='Enable'
/>
}
)}
</a>
);
}
@@ -415,6 +417,7 @@ type Props = BaseProps & {
pluginStatuses: Record<string, PluginStatus>;
plugins: any;
appsFeatureFlagEnabled: boolean;
streamlinedMarketplaceFlagEnabled: boolean;
actions: {
uploadPlugin: (fileData: File, force: boolean) => any;
removePlugin: (pluginId: string) => any;
@@ -1214,39 +1217,43 @@ export default class PluginManagement extends AdminSettings<Props, State> {
onChange={this.handleChange}
setByEnv={this.isSetByEnv('PluginSettings.EnableMarketplace')}
/>
<BooleanSetting
id='enableRemoteMarketplace'
label={
<FormattedMessage
id='admin.plugins.settings.enableRemoteMarketplace'
defaultMessage='Enable Remote Marketplace:'
{!this.props.streamlinedMarketplaceFlagEnabled && (
<>
<BooleanSetting
id='enableRemoteMarketplace'
label={
<FormattedMessage
id='admin.plugins.settings.enableRemoteMarketplace'
defaultMessage='Enable Remote Marketplace:'
/>
}
helpText={
<FormattedMarkdownMessage
id='admin.plugins.settings.enableRemoteMarketplaceDesc'
defaultMessage='When true, marketplace fetches latest plugins from the configured Marketplace URL.'
/>
}
value={this.state.enableRemoteMarketplace}
disabled={this.props.isDisabled || !this.state.enable || !this.state.enableUploads || !this.state.enableMarketplace}
onChange={this.handleChange}
setByEnv={this.isSetByEnv('PluginSettings.EnableRemoteMarketplace')}
/>
}
helpText={
<FormattedMarkdownMessage
id='admin.plugins.settings.enableRemoteMarketplaceDesc'
defaultMessage='When true, marketplace fetches latest plugins from the configured Marketplace URL.'
<TextSetting
id={'marketplaceUrl'}
label={
<FormattedMessage
id='admin.plugins.settings.marketplaceUrl'
defaultMessage='Marketplace URL:'
/>
}
helpText={this.getMarketplaceURLHelpText(this.state.marketplaceUrl, this.state.enableUploads)}
value={this.state.marketplaceUrl}
disabled={this.props.isDisabled || !this.state.enable || !this.state.enableUploads || !this.state.enableMarketplace || !this.state.enableRemoteMarketplace}
onChange={this.handleChange}
setByEnv={this.isSetByEnv('PluginSettings.MarketplaceURL')}
/>
}
value={this.state.enableRemoteMarketplace}
disabled={this.props.isDisabled || !this.state.enable || !this.state.enableUploads || !this.state.enableMarketplace}
onChange={this.handleChange}
setByEnv={this.isSetByEnv('PluginSettings.EnableRemoteMarketplace')}
/>
<TextSetting
id={'marketplaceUrl'}
label={
<FormattedMessage
id='admin.plugins.settings.marketplaceUrl'
defaultMessage='Marketplace URL:'
/>
}
helpText={this.getMarketplaceURLHelpText(this.state.marketplaceUrl, this.state.enableUploads)}
value={this.state.marketplaceUrl}
disabled={this.props.isDisabled || !this.state.enable || !this.state.enableUploads || !this.state.enableMarketplace || !this.state.enableRemoteMarketplace}
onChange={this.handleChange}
setByEnv={this.isSetByEnv('PluginSettings.MarketplaceURL')}
/>
</>
)}
</>
)}
{pluginsContainer}

View File

@@ -1,5 +1,225 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/marketplace/ doesn't show web marketplace banner in FeatureFlags.StreamlinedMarketplace for Cloud 1`] = `
<Modal
animation={true}
aria-label="App Marketplace"
autoFocus={true}
backdrop={true}
bsClass="modal"
dialogClassName="a11y__modal GenericModal GenericModal__compassDesign marketplace-modal streamlined-marketplace"
dialogComponentClass={[Function]}
enforceFocus={true}
id="marketplace-modal"
keyboard={true}
manager={
ModalManager {
"add": [Function],
"containers": Array [],
"data": Array [],
"handleContainerOverflow": true,
"hideSiblingNodes": true,
"isTopModal": [Function],
"modals": Array [],
"remove": [Function],
}
}
onExited={[Function]}
onHide={[Function]}
renderBackdrop={[Function]}
restoreFocus={true}
role="dialog"
show={true}
>
<div
className="GenericModal__wrapper-enter-key-press-catcher"
onKeyDown={[Function]}
tabIndex={0}
>
<ModalHeader
bsClass="modal-header"
closeButton={true}
closeLabel="Close"
>
<div
className="GenericModal__header"
>
<h1
id="genericModalLabel"
>
App Marketplace
</h1>
</div>
</ModalHeader>
<ModalBody
bsClass="modal-body"
className="divider"
componentClass="div"
>
<div
className="genericModalError"
>
<i
className="icon icon-alert-outline"
/>
<span>
Error connecting to the marketplace server. Please check your settings in the
<Link
key=".1"
to="/admin_console/plugins/plugin_management"
>
System Console
</Link>
.
</span>
</div>
<div
className="GenericModal__body"
>
<MarketplaceList
filter=""
listRef={
Object {
"current": null,
}
}
listing={Array []}
noResultsMessage="No plugins found"
page={0}
/>
</div>
</ModalBody>
</div>
</Modal>
`;
exports[`components/marketplace/ hides search, shows web marketplace banner in FeatureFlags.StreamlinedMarketplace 1`] = `
<Modal
animation={true}
aria-label="App Marketplace"
autoFocus={true}
backdrop={true}
bsClass="modal"
dialogClassName="a11y__modal GenericModal GenericModal__compassDesign marketplace-modal streamlined-marketplace with-web-marketplace-link"
dialogComponentClass={[Function]}
enforceFocus={true}
id="marketplace-modal"
keyboard={true}
manager={
ModalManager {
"add": [Function],
"containers": Array [],
"data": Array [],
"handleContainerOverflow": true,
"hideSiblingNodes": true,
"isTopModal": [Function],
"modals": Array [],
"remove": [Function],
}
}
onExited={[Function]}
onHide={[Function]}
renderBackdrop={[Function]}
restoreFocus={true}
role="dialog"
show={true}
>
<div
className="GenericModal__wrapper-enter-key-press-catcher"
onKeyDown={[Function]}
tabIndex={0}
>
<ModalHeader
bsClass="modal-header"
closeButton={true}
closeLabel="Close"
>
<div
className="GenericModal__header"
>
<h1
id="genericModalLabel"
>
App Marketplace
</h1>
</div>
</ModalHeader>
<ModalBody
bsClass="modal-body"
className="divider"
componentClass="div"
>
<div
className="genericModalError"
>
<i
className="icon icon-alert-outline"
/>
<span>
Error connecting to the marketplace server. Please check your settings in the
<Link
key=".1"
to="/admin_console/plugins/plugin_management"
>
System Console
</Link>
.
</span>
</div>
<div
className="GenericModal__body"
>
<MarketplaceList
filter=""
listRef={
Object {
"current": null,
}
}
listing={
Array [
Object {
"author_type": "mattermost",
"download_url": "https://github.com/mattermost/mattermost-plugin-nps/releases/download/v1.0.3/com.mattermost.nps-1.0.3.tar.gz",
"enterprise": false,
"homepage_url": "https://github.com/mattermost/mattermost-plugin-nps",
"installed_version": "",
"manifest": Object {
"description": "This plugin sends quarterly user satisfaction surveys to gather feedback and help improve Mattermost",
"id": "com.mattermost.nps",
"min_server_version": "5.14.0",
"name": "User Satisfaction Surveys",
"version": "1.0.3",
},
"release_stage": "production",
},
Object {
"author_type": "mattermost",
"download_url": "https://github.com/mattermost/mattermost-test/releases/download/v1.0.3/com.mattermost.nps-1.0.3.tar.gz",
"enterprise": false,
"homepage_url": "https://github.com/mattermost/mattermost-test",
"installed_version": "1.0.3",
"manifest": Object {
"description": "This plugin is to test",
"id": "com.mattermost.test",
"min_server_version": "5.14.0",
"name": "Test",
"version": "1.0.3",
},
"release_stage": "production",
},
]
}
noResultsMessage="No plugins found"
page={0}
/>
</div>
</ModalBody>
<WebMarketplaceBanner />
</div>
</Modal>
`;
exports[`components/marketplace/ should render default 1`] = `
<Modal
animation={true}
@@ -73,6 +293,7 @@ exports[`components/marketplace/ should render default 1`] = `
</ModalHeader>
<ModalBody
bsClass="modal-body"
className=""
componentClass="div"
>
<div
@@ -209,6 +430,7 @@ exports[`components/marketplace/ should render with error banner 1`] = `
</ModalHeader>
<ModalBody
bsClass="modal-body"
className=""
componentClass="div"
>
<div
@@ -362,6 +584,7 @@ exports[`components/marketplace/ should render with no plugins available 1`] = `
</ModalHeader>
<ModalBody
bsClass="modal-body"
className=""
componentClass="div"
>
<div
@@ -506,6 +729,7 @@ exports[`components/marketplace/ should render with plugins available 1`] = `
</ModalHeader>
<ModalBody
bsClass="modal-body"
className=""
componentClass="div"
>
<div
@@ -668,6 +892,7 @@ exports[`components/marketplace/ should render with plugins installed 1`] = `
</ModalHeader>
<ModalBody
bsClass="modal-body"
className=""
componentClass="div"
>
<div

View File

@@ -2,7 +2,20 @@
@import 'utils/mixins';
.marketplace-modal {
width: 800px;
width: 832px;
&.streamlined-marketplace {
.modal-header {
padding: 26px 32px 26px !important;
}
&.with-web-marketplace-link {
.modal-content {
border-bottom-left-radius: 16px !important;
border-bottom-right-radius: 16px !important;
}
}
}
div.navigation-row {
overflow: unset;
@@ -211,6 +224,10 @@
width: 100%;
flex-direction: column;
margin-top: 12px;
.nav-tabs {
border-bottom: 1px solid rgba(var(--center-channel-color-rgb), 0.08);
}
}
.subtitle {

View File

@@ -12,6 +12,7 @@ import {GlobalState} from 'types/store';
import {ModalIdentifiers} from 'utils/constants';
import MarketplaceModal, {OpenedFromType} from './marketplace_modal';
import WebMarketplaceBanner from './web_marketplace_banner';
let mockState: GlobalState;
@@ -76,6 +77,12 @@ describe('components/marketplace/', () => {
entities: {
general: {
firstAdminCompleteSetup: false,
config: {
FeatureFlagStreamlinedMarketplace: 'false',
},
license: {
Cloud: 'false',
},
},
admin: {
pluginStatuses: {},
@@ -156,4 +163,49 @@ describe('components/marketplace/', () => {
expect(wrapper.shallow()).toMatchSnapshot();
});
test('hides search, shows web marketplace banner in FeatureFlags.StreamlinedMarketplace', () => {
const setState = jest.fn();
const useStateSpy = jest.spyOn(React, 'useState');
useStateSpy.mockImplementation(() => [true, setState]);
mockState.views.marketplace.plugins = [
samplePlugin,
sampleInstalledPlugin,
];
(mockState.entities.general.config as any).FeatureFlagStreamlinedMarketplace = 'true';
const wrapper = shallow(
<MarketplaceModal {...defaultProps}/>,
);
wrapper.update();
const content = wrapper.shallow();
expect(content.exists('#searchMarketplaceTextbox')).toBe(false);
expect(content.exists(WebMarketplaceBanner)).toBe(true);
expect(content).toMatchSnapshot();
});
test("doesn't show web marketplace banner in FeatureFlags.StreamlinedMarketplace for Cloud", () => {
const setState = jest.fn();
const useStateSpy = jest.spyOn(React, 'useState');
useStateSpy.mockImplementation(() => [true, setState]);
(mockState.entities.general.config as any).FeatureFlagStreamlinedMarketplace = 'true';
mockState.entities.general.license.Cloud = 'true';
const wrapper = shallow(
<MarketplaceModal {...defaultProps}/>,
);
wrapper.update();
const content = wrapper.shallow();
expect(content.exists(WebMarketplaceBanner)).toBe(false);
expect(content).toMatchSnapshot();
});
});

View File

@@ -13,7 +13,8 @@ import {MagnifyIcon} from '@mattermost/compass-icons/components';
import {FooterPagination, GenericModal} 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 {getFirstAdminVisitMarketplaceStatus, getLicense} from 'mattermost-redux/selectors/entities/general';
import {streamlinedMarketplaceEnabled} from 'mattermost-redux/selectors/entities/preferences';
import {ActionResult} from 'mattermost-redux/types/actions';
import {fetchListing, filterListing} from 'actions/marketplace';
@@ -27,10 +28,13 @@ import {getListing, getInstalledListing} from 'selectors/views/marketplace';
import {isModalOpen} from 'selectors/views/modals';
import {GlobalState} from 'types/store';
import {ModalIdentifiers} from 'utils/constants';
import WebMarketplaceBanner from './web_marketplace_banner';
import {isCloudLicense} from 'utils/license_utils';
import './marketplace_modal.scss';
import MarketplaceList, {ITEMS_PER_PAGE} from './marketplace_list/marketplace_list';
import classNames from 'classnames';
const MarketplaceTabs = {
ALL_LISTING: 'all',
@@ -63,6 +67,9 @@ const MarketplaceModal = ({
const installedListing = useSelector(getInstalledListing);
const pluginStatuses = useSelector((state: GlobalState) => state.entities.admin.pluginStatuses);
const hasFirstAdminVisitedMarketplace = useSelector(getFirstAdminVisitMarketplaceStatus);
const isStreamlinedMarketplaceEnabled = useSelector(streamlinedMarketplaceEnabled);
const license = useSelector(getLicense);
const isCloud = isCloudLicense(license);
const [tabKey, setTabKey] = useState(MarketplaceTabs.ALL_LISTING);
const [filter, setFilter] = useState('');
@@ -162,39 +169,62 @@ const MarketplaceModal = ({
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 getHeaderInput = useCallback(() => {
if (isStreamlinedMarketplaceEnabled) {
return null;
}
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 (
<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(() => {
if (isStreamlinedMarketplaceEnabled && listing.length <= ITEMS_PER_PAGE) {
return null;
}
return (
<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, isStreamlinedMarketplaceEnabled]);
const getAppendedContent = useCallback(() => {
if (!isStreamlinedMarketplaceEnabled || isCloud) {
return null;
}
return <WebMarketplaceBanner/>;
}, [isStreamlinedMarketplaceEnabled, isCloud]);
return (
<GenericModal
id='marketplace-modal'
className='marketplace-modal'
className={classNames('marketplace-modal', {
'streamlined-marketplace': isStreamlinedMarketplaceEnabled,
'with-web-marketplace-link': isStreamlinedMarketplaceEnabled && !isCloud,
})}
modalHeaderText={formatMessage({id: 'marketplace_modal.title', defaultMessage: 'App Marketplace'})}
ariaLabel={formatMessage({id: 'marketplace_modal.title', defaultMessage: 'App Marketplace'})}
errorText={serverError ? (
@@ -209,58 +239,72 @@ const MarketplaceModal = ({
show={show}
compassDesign={true}
bodyPadding={false}
bodyDivider={isStreamlinedMarketplaceEnabled}
footerDivider={true}
onExited={handleOnClose}
footerContent={getFooterContent()}
appendedContent={getAppendedContent()}
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},
)}
>
{isStreamlinedMarketplaceEnabled ? (
<>
<MarketplaceList
listRef={listRef}
listing={installedListing}
listing={listing}
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,
}}
noResultsMessage={formatMessage({id: 'marketplace_modal.no_plugins', defaultMessage: 'No plugins found'})}
/>
</Tab>
</Tabs>
</>
) : (
<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>
);
};

View File

@@ -0,0 +1,109 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {useIntl} from 'react-intl';
import styled from 'styled-components';
import ExternalLink from '../external_link';
import webMarketplaceBannerBackground from 'images/marketplace-notice-background.jpg';
import pluginIconConfluence from 'images/icons/confluence.svg';
import pluginIconGiphy from 'images/icons/giphy.svg';
import pluginIconPagerDuty from 'images/icons/pager-duty.svg';
import {ArrowRightIcon} from '@mattermost/compass-icons/components';
const WEB_MARKETPLACE_LINK = 'https://mattermost.com/marketplace';
const WebMarketplaceBanner = () => {
const {formatMessage} = useIntl();
return (
<WebMarketplaceBannerRoot className='WebMarketplaceBanner'>
<ExternalBannerLink
href={WEB_MARKETPLACE_LINK}
location='marketplace_modal'
>
<Title>
{formatMessage({id: 'marketplace_modal.web_marketplace_link.title', defaultMessage: 'Explore Community Integrations'})}
<ArrowRightIcon size={24}/>
</Title>
<Description>
{formatMessage({id: 'marketplace_modal.web_marketplace_link.desc', defaultMessage: 'We have dozens of community integrations available. So definitely do check them out!'})}
</Description>
<IconsContainer>
<PluginIcon src={pluginIconConfluence}/>
<PluginIcon src={pluginIconGiphy}/>
<PluginIcon src={pluginIconPagerDuty}/>
</IconsContainer>
</ExternalBannerLink>
</WebMarketplaceBannerRoot>
);
};
const ExternalBannerLink = styled(ExternalLink)`
&&,
&&:hover,
&&:focus {
color: var(--denim-center-channel-bg, #FFF);
text-decoration: none;
}
&& {
display: grid;
grid-template-columns: auto auto;
justify-content: space-between;
text-align: left;
padding: 24px 32px;
}
`;
const WebMarketplaceBannerRoot = styled.section`
background-image: url(${webMarketplaceBannerBackground});
background-position: center;
background-repeat: no-repeat;
background-size: cover;
border-radius: 0 0 12px 12px !important;
margin: -1px;
`;
const Title = styled.div`
font-family: Metropolis;
font-size: 16px;
font-style: normal;
font-weight: 600;
line-height: 24px;
margin: 4px 0;
grid-column: 1;
svg {
vertical-align: middle;
display: inline-block;
margin-left: 4px;
}
`;
const Description = styled.p`
font-family: Open Sans;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px;
grid-column: 1;
margin-bottom: 4px;
`;
const PluginIcon = styled.img`
width: 50px;
height: 50px;
border-radius: 50%;
`;
const IconsContainer = styled.div`
grid-column: 2;
grid-row: span 2/2;
${PluginIcon}:nth-child(n+2) {
margin-left: calc(-54px / 1/4);
}
`;
export default WebMarketplaceBanner;

View File

@@ -3949,6 +3949,8 @@
"marketplace_modal.tabs.all_listing": "All",
"marketplace_modal.tabs.installed_listing": "Installed ({count})",
"marketplace_modal.title": "App Marketplace",
"marketplace_modal.web_marketplace_link.desc": "We have dozens of community integrations available. So definitely do check them out!",
"marketplace_modal.web_marketplace_link.title": "Explore Community Integrations",
"menu.cloudFree.enterpriseTrialDescription": "Your trial is active until {trialEndDay}. Discover our top Enterprise features. <openModalLink>Learn more</openModalLink>",
"menu.cloudFree.enterpriseTrialTitle": "Enterprise Trial",
"menu.cloudFree.postTrial.tryEnterprise": "Interested in a limitless plan with high-security features? <openModalLink>See plans</openModalLink>",

View File

@@ -0,0 +1 @@
<svg fill="none" height="48" viewBox="0 0 48 48" width="48" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><linearGradient id="a"><stop offset="0" stop-color="#0050d3"/><stop offset=".94" stop-color="#007ffc"/><stop offset="1" stop-color="#0082ff"/></linearGradient><linearGradient id="b" gradientUnits="userSpaceOnUse" x1="37.5113" x2="18.7215" xlink:href="#a" y1="40.2376" y2="29.4358"/><linearGradient id="c" gradientUnits="userSpaceOnUse" x1="10.466" x2="29.2558" xlink:href="#a" y1="7.74603" y2="18.5431"/><rect fill="#fff" height="48" rx="24" width="48"/><path d="m10.0648 31.5952c-.31054.5064-.65929 1.094-.93161 1.5622-.12744.2154-.16531.4722-.10546.7152.05986.2429.21266.4528.42555.5843l6.21072 3.822c.1079.0666.228.1111.3533.1308s.2533.0143.3764-.0159c.1232-.0303.2392-.0847.3411-.1602s.1877-.1706.2525-.2796c.2437-.4156.5638-.9555.9125-1.5336 2.4604-4.0608 4.9399-3.564 9.3973-1.4332l6.1582 2.9286c.1154.0549.2407.0861.3684.0918.1277.0056.2553-.0144.3751-.059.1199-.0445.2296-.1127.3226-.2004s.1675-.1932.219-.3102l2.9572-6.6884c.1005-.2296.1067-.4895.0173-.7236-.0894-.2342-.2671-.4238-.495-.5281-1.2995-.6115-3.8841-1.8346-6.2107-2.9525-8.3892-4.08-15.5029-3.8124-20.9444 5.0498z" fill="url(#b)"/><path d="m37.9125 16.4028c.3105-.5064.6593-1.0988.9555-1.5622.127-.216.1642-.4732.1034-.7162-.0608-.2431-.2146-.4525-.4283-.5833l-6.2107-3.82198c-.1077-.06588-.2274-.10974-.3522-.12907-.1248-.01932-.2522-.01372-.3748.01649s-.238.08442-.3396.15951c-.1015.07509-.1871.16955-.2519.27795-.2485.4156-.5686.9555-.9173 1.5336-2.4604 4.0608-4.9351 3.5639-9.3973 1.4332l-6.1772-2.9143c-.1152-.0541-.2401-.0848-.3673-.09-.1272-.0053-.2542.015-.3735.0595-.1192.0445-.2284.1124-.3211.1997-.0927.0872-.167.1922-.2185.3086l-2.9573 6.6884c-.1004.2296-.1066.4895-.0172.7236.0894.2342.2671.4238.495.5281 1.2995.6116 3.8888 1.8298 6.2107 2.9525 8.3844 4.0465 15.4981 3.779 20.9396-5.0641z" fill="url(#c)"/></svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 32 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

View File

@@ -281,3 +281,7 @@ export function deprecateCloudFree(state: GlobalState): boolean {
export function cloudReverseTrial(state: GlobalState): boolean {
return getFeatureFlagValue(state, 'CloudReverseTrial') === 'true';
}
export function streamlinedMarketplaceEnabled(state: GlobalState): boolean {
return getFeatureFlagValue(state, 'StreamlinedMarketplace') === 'true';
}

View File

@@ -22,6 +22,10 @@
max-height: 100%;
padding: 0;
&.divider {
border-top: 1px solid rgba(var(--center-channel-color-rgb), 0.08);
}
.form-control {
height: 40px;
box-sizing: border-box;
@@ -106,7 +110,7 @@
border-radius: 4px;
&.divider {
border-top: 1px solid rgba(63, 67, 80, 0.08);
border-top: 1px solid rgba(var(--center-channel-color-rgb), 0.08);
}
}

View File

@@ -39,8 +39,10 @@ export type Props = {
keyboardEscape?: boolean;
headerInput?: React.ReactNode;
bodyPadding?: boolean;
bodyDivider?: boolean;
footerContent?: React.ReactNode;
footerDivider?: boolean;
appendedContent?: React.ReactNode;
headerButton?: React.ReactNode;
};
@@ -199,7 +201,7 @@ export class GenericModal extends React.PureComponent<Props, State> {
</>
)}
</Modal.Header>
<Modal.Body>
<Modal.Body className={classNames({divider: this.props.bodyDivider})}>
{this.props.compassDesign ? (
this.props.errorText && (
<div className='genericModalError'>
@@ -226,6 +228,7 @@ export class GenericModal extends React.PureComponent<Props, State> {
)}
</Modal.Footer>
)}
{Boolean(this.props.appendedContent) && this.props.appendedContent}
</div>
</Modal>
);