diff --git a/app/apps/assets/stylesheets/apps.css b/app/apps/assets/stylesheets/apps.css index 0037d1191c73..121b1cbc9314 100644 --- a/app/apps/assets/stylesheets/apps.css +++ b/app/apps/assets/stylesheets/apps.css @@ -1,3 +1,4 @@ +.rc-apps-section, .rc-apps-marketplace { display: flex; @@ -300,6 +301,12 @@ height: 100vh; + margin-top: 20px; + + & .rc-form-filters { + margin: 8px 0; + } + & .js-sort { cursor: pointer; @@ -321,6 +328,50 @@ font-size: 1rem; } } + + & tbody .rc-table-tr:not(.table-no-click):not(.table-no-pointer):hover { + background-color: #f7f8fa; + } + + & .rc-table-info { + margin: 0; + justify-content: center; + + & .rc-table-title, + & .rc-table-subtitle { + font-size: 0.875rem; + line-height: 1.25rem; + } + + & .rc-apps-categories { + display: flex; + + height: 1.25rem; + margin: 0 -0.25rem; + align-items: center; + flex-wrap: wrap; + + & .rc-apps-category { + overflow: hidden; + flex: 0 0 auto; + + box-sizing: border-box; + margin: 0.125rem 0.25rem; + padding: 0.0625rem 0.25rem; + + text-transform: none; + text-overflow: ellipsis; + + color: #9da1a8; + border-radius: 9999px; + background-color: #eef0f3; + + font-size: 0.625rem; + font-weight: 500; + line-height: 0.875rem; + } + } + } } @media (width <= 700px) { diff --git a/app/apps/client/admin/appManage.css b/app/apps/client/admin/appManage.css new file mode 100644 index 000000000000..7d8d1b1cdff8 --- /dev/null +++ b/app/apps/client/admin/appManage.css @@ -0,0 +1,147 @@ +#rocket-chat .content .rc-apps-details { + &__content { + justify-content: flex-start; + } + + &__app-name { + flex: 0 0 1.75rem; + + margin: 0; + + letter-spacing: 0; + text-transform: none; + + color: rgb(84, 88, 94); + + font-family: inherit; + font-size: 1.375rem; + font-weight: normal; + line-height: 1.75rem; + } + + &__app-info { + display: flex; + flex: 0 0 1.25rem; + flex-wrap: nowrap; + + > span::after { + display: inline-block; + + width: 1px; + height: 12px; + margin: 0 8px; + + content: ''; + + background: rgb(203, 206, 209); + } + + > span:last-child::after { + display: none; + + content: none; + } + } + + &__app-author { + letter-spacing: -0.2px; + + color: rgb(158, 162, 168); + + font-family: inherit; + font-size: 14px; + font-weight: 500; + line-height: 20px; + } + + &__app-version { + letter-spacing: -0.2px; + + color: rgb(158, 162, 168); + + font-family: inherit; + font-size: 14px; + font-weight: normal; + line-height: 20px; + } + + &__app-status { + display: flex; + flex: 1; + + margin-top: 8px; + align-items: center; + } + + &__app-install-status { + display: flex; + + height: 40px; + + letter-spacing: 0; + + color: rgb(158, 162, 168); + + font-family: inherit; + font-size: 14px; + font-weight: 500; + align-items: center; + flex-wrap: nowrap; + + & > .rc-icon { + color: var(--rc-color-button-primary); + } + } + + &__app-price { + letter-spacing: -0.2px; + + color: rgb(157, 161, 168); + + font-family: inherit; + font-size: 14px; + font-weight: normal; + line-height: 20px; + + &::before { + display: inline-block; + + width: 1px; + height: 12px; + margin: 0 16px; + + content: ''; + + background: rgb(203, 206, 209); + } + } + + &__app-button-wrapper { + flex: 1; + } + + & .rc-button.loading { + padding: 0 1.5rem; + + opacity: 0.6; + + &::before { + display: none; + } + + & > .rc-icon { + animation: spin 1s linear infinite; + } + } + + &__app-menu-trigger { + padding: 0; + + &::before { + display: inline-block; + flex: 1; + + content: ''; + } + } +} diff --git a/app/apps/client/admin/appManage.html b/app/apps/client/admin/appManage.html index b20244ccb06a..892801b13040 100644 --- a/app/apps/client/admin/appManage.html +++ b/app/apps/client/admin/appManage.html @@ -16,10 +16,10 @@ {{/header}}
{{#requiresPermission 'manage-apps'}} - {{#if hasError}} + {{#if error}}
-

{{theError}}

+

{{error}}

{{else if isReady}}
@@ -30,35 +30,60 @@
{{/if}}
-
-

{{name}}

+

{{name}}

+
+ {{#if author.name}} + by {{author.name}} + {{/if}} + Version {{version}}
- {{#if author.name}} -
- by {{author.name}} | Version {{version}} -
- {{/if}} -
+ +
{{#if isInstalled}} - {{#if newVersion}} - - {{/if}} - - {{#if isEnabled}} - - {{else}} - - {{/if}} - - {{else}} - {{#if hasPurchased}} - - {{else}} - {{#if $eq price 0}} - + + {{#if canUpdate}} + + {{else if isFromMarketplace}} + + {{> icon icon="checkmark-circled" block="rc-icon--default-size"}} + {{_ "Up to date"}} + {{else}} - + + {{> icon icon="checkmark-circled" block="rc-icon--default-size"}} + {{_ "Installed"}} + {{/if}} + + + + {{else}} + {{#if canTrial}} + + {{else if canBuy}} + + {{else}} + + {{/if}} + + {{#if priceDisplay}} + + {{priceDisplay}} + {{/if}} {{/if}}
@@ -390,30 +415,6 @@

{{_ "Settings"}}

{{/if}}
- {{ else if $eq type 'language'}}
diff --git a/app/apps/client/admin/appManage.js b/app/apps/client/admin/appManage.js index 1b4e8261f8b3..6889387635b7 100644 --- a/app/apps/client/admin/appManage.js +++ b/app/apps/client/admin/appManage.js @@ -9,16 +9,19 @@ import s from 'underscore.string'; import toastr from 'toastr'; import semver from 'semver'; -import { isEmail, APIClient } from '../../../utils'; -import { settings } from '../../../settings'; +import { isEmail, t, APIClient } from '../../../utils'; import { Markdown } from '../../../markdown/client'; import { modal } from '../../../ui-utils'; import { AppEvents } from '../communication'; import { Utilities } from '../../lib/misc/Utilities'; import { Apps } from '../orchestrator'; -import { SideNav } from '../../../ui-utils/client'; +import { SideNav, popover } from '../../../ui-utils/client'; -function getApps(instance) { +import './appManage.html'; +import './appManage.css'; + + +const getApp = (instance) => { const id = instance.id.get(); const appInfo = { remote: undefined, local: undefined }; @@ -26,7 +29,7 @@ function getApps(instance) { .catch((e) => { console.log(e); toastr.error((e.xhr.responseJSON && e.xhr.responseJSON.error) || e.message); - return Promise.resolve({ app: undefined }); + return { app: undefined }; }) .then((remote) => { if (!remote.app || !remote.app.bundledIn || remote.app.bundledIn.length === 0) { @@ -59,11 +62,10 @@ function getApps(instance) { .then((apis) => instance.apis.set(apis)) .catch((e) => { if (appInfo.remote || appInfo.local) { - return Promise.resolve(true); + return true; } - instance.hasError.set(true); - instance.theError.set(e.message); + instance.error.set(e.message); }).then((goOn) => { if (typeof goOn !== 'undefined' && !goOn) { return; @@ -82,6 +84,8 @@ function getApps(instance) { appInfo.local.price = appInfo.remote.price; appInfo.local.displayPrice = appInfo.remote.displayPrice; appInfo.local.bundledIn = appInfo.remote.bundledIn; + appInfo.local.purchaseType = appInfo.remote.purchaseType; + appInfo.local.subscriptionInfo = appInfo.remote.subscriptionInfo; if (semver.gt(appInfo.remote.version, appInfo.local.version) && (appInfo.remote.isPurchased || appInfo.remote.price <= 0)) { appInfo.local.newVersion = appInfo.remote.version; @@ -107,7 +111,7 @@ function getApps(instance) { } } - return Promise.resolve(false); + return false; }).then((updateInfo) => { if (!updateInfo) { return; @@ -121,43 +125,146 @@ function getApps(instance) { instance.app.set(appInfo.local); } }); -} - -function installAppFromEvent(e, t) { - const el = $(e.currentTarget); - el.prop('disabled', true); - el.addClass('loading'); - - const app = t.app.get(); - - const api = app.newVersion ? `apps/${ t.id.get() }` : 'apps/'; - - APIClient.post(api, { - appId: app.id, - marketplace: true, - version: app.version, - }).then(() => getApps(t)).then(() => { - el.prop('disabled', false); - el.removeClass('loading'); - }).catch((e) => { - el.prop('disabled', false); - el.removeClass('loading'); - t.hasError.set(true); - t.theError.set((e.xhr.responseJSON && e.xhr.responseJSON.error) || e.message); +}; + +const formatPrice = (price) => `\$${ Number.parseFloat(price).toFixed(2) }`; + +const formatPricingPlan = (pricingPlan) => { + const perUser = pricingPlan.isPerSeat && pricingPlan.tiers && pricingPlan.tiers.length; + + const pricingPlanTranslationString = [ + 'Apps_Marketplace_pricingPlan', + pricingPlan.strategy, + perUser && 'perUser', + ].filter(Boolean).join('_'); + + return t(pricingPlanTranslationString, { + price: formatPrice(pricingPlan.price), + }); +}; + +const handleAPIError = (e) => { + console.error(e); + toastr.error((e.xhr.responseJSON && e.xhr.responseJSON.error) || e.message); +}; + +const triggerButtonLoadingState = (button) => { + const icon = button.querySelector('.rc-icon use'); + const iconHref = icon.getAttribute('href'); + + button.classList.add('loading'); + button.disabled = true; + icon.setAttribute('href', '#icon-loading'); + + return () => { + button.classList.remove('loading'); + button.disabled = false; + icon.setAttribute('href', iconHref); + }; +}; + +const promptSubscription = async (app, instance) => { + const { latest, purchaseType = 'buy' } = app; + + let data = null; + try { + data = await APIClient.get(`apps?buildExternalUrl=true&appId=${ latest.id }&purchaseType=${ purchaseType }`); + } catch (e) { + handleAPIError(e, instance); + return; + } + + modal.open({ + allowOutsideClick: false, + data, + template: 'iframeModal', + }, async () => { + try { + await APIClient.post('apps/', { + appId: latest.id, + marketplace: true, + version: latest.version, + }); + await getApp(instance); + } catch (e) { + handleAPIError(e, instance); + } }); +}; - // play animation - // TODO this icon and animation are not working - $(e.currentTarget).find('.rc-icon').addClass('play'); -} +const viewLogs = ({ id }) => { + FlowRouter.go(`/admin/apps/${ id }/logs`, {}, { version: FlowRouter.getQueryParam('version') }); +}; + +const setAppStatus = async (app, status, instance) => { + try { + const result = await APIClient.post(`apps/${ app.id }/status`, { status }); + app.status = result.status; + instance.app.set(app); + } catch (e) { + handleAPIError(e, instance); + } +}; + +const activateApp = (app, instance) => { + setAppStatus(app, 'manually_enabled', instance); +}; + +const promptAppDeactivation = (app, instance) => { + modal.open({ + text: t('Apps_Marketplace_Deactivate_App_Prompt'), + type: 'warning', + showCancelButton: true, + confirmButtonColor: '#DD6B55', + confirmButtonText: t('Yes'), + cancelButtonText: t('No'), + closeOnConfirm: true, + html: false, + }, (confirmed) => { + if (!confirmed) { + return; + } + setAppStatus(app, 'manually_disabled', instance); + }); +}; + +const uninstallApp = async ({ id }, instance) => { + try { + await APIClient.delete(`apps/${ id }`); + } catch (e) { + handleAPIError(e, instance); + } + + try { + await getApp(instance); + } catch (e) { + handleAPIError(e, instance); + } +}; + +const promptAppUninstall = (app, instance) => { + modal.open({ + text: t('Apps_Marketplace_Uninstall_App_Prompt'), + type: 'warning', + showCancelButton: true, + confirmButtonColor: '#DD6B55', + confirmButtonText: t('Yes'), + cancelButtonText: t('No'), + closeOnConfirm: true, + html: false, + }, (confirmed) => { + if (!confirmed) { + return; + } + uninstallApp(app, instance); + }); +}; Template.appManage.onCreated(function() { const instance = this; this.id = new ReactiveVar(FlowRouter.getParam('appId')); this.ready = new ReactiveVar(false); - this.hasError = new ReactiveVar(false); - this.theError = new ReactiveVar(''); - this.processingEnabled = new ReactiveVar(false); + this.error = new ReactiveVar(''); this.app = new ReactiveVar({}); this.appsList = new ReactiveVar([]); this.settings = new ReactiveVar({}); @@ -165,14 +272,29 @@ Template.appManage.onCreated(function() { this.loading = new ReactiveVar(false); const id = this.id.get(); - getApps(instance); + getApp(instance); this.__ = (key, options, lang_tag) => { const appKey = Utilities.getI18nKeyForApp(key, id); return TAPi18next.exists(`project:${ appKey }`) ? TAPi18n.__(appKey, options, lang_tag) : TAPi18n.__(key, options, lang_tag); }; - function _morphSettings(settings) { + this.onStatusChanged = ({ appId, status }) => { + if (appId !== id) { + return; + } + + const app = instance.app.get(); + app.status = status; + instance.app.set(app); + }; + + this.onSettingUpdated = async ({ appId }) => { + if (appId !== id) { + return; + } + + const { settings } = await APIClient.get(`apps/${ id }/settings`); Object.keys(settings).forEach((k) => { settings[k].i18nPlaceholder = settings[k].i18nPlaceholder || ' '; settings[k].value = settings[k].value !== undefined && settings[k].value !== null ? settings[k].value : settings[k].packageValue; @@ -181,37 +303,80 @@ Template.appManage.onCreated(function() { }); instance.settings.set(settings); - } + }; - instance.onStatusChanged = function _onStatusChanged({ appId, status }) { - if (appId !== id) { + this.onAppAdded = async (appId) => { + if (appId !== this.id.get()) { return; } - const app = instance.app.get(); - app.status = status; - instance.app.set(app); + try { + await getApp(instance); + } catch (e) { + handleAPIError(e, this); + } }; - instance.onSettingUpdated = function _onSettingUpdated({ appId }) { - if (appId !== id) { + this.onAppRemoved = async (appId) => { + if (appId !== this.id.get()) { return; } - APIClient.get(`apps/${ id }/settings`).then((result) => { - _morphSettings(result.settings); - }); + try { + await getApp(instance); + } catch (e) { + handleAPIError(e, this); + } }; + + Apps.getWsListener().registerListener(AppEvents.APP_ADDED, this.onAppAdded); + Apps.getWsListener().registerListener(AppEvents.APP_REMOVED, this.onAppRemoved); }); Template.apps.onDestroyed(function() { - const instance = this; - - Apps.getWsListener().unregisterListener(AppEvents.APP_STATUS_CHANGE, instance.onStatusChanged); - Apps.getWsListener().unregisterListener(AppEvents.APP_SETTING_UPDATED, instance.onSettingUpdated); + Apps.getWsListener().unregisterListener(AppEvents.APP_STATUS_CHANGE, this.onStatusChanged); + Apps.getWsListener().unregisterListener(AppEvents.APP_SETTING_UPDATED, this.onSettingUpdated); + Apps.getWsListener().unregisterListener(AppEvents.APP_ADDED, this.onAppAdded); + Apps.getWsListener().unregisterListener(AppEvents.APP_REMOVED, this.onAppRemoved); }); Template.appManage.helpers({ + isInstalled() { + const app = Template.instance().app.get(); + return app.installed; + }, + canUpdate() { + const app = Template.instance().app.get(); + return app.installed && app.newVersion; + }, + isFromMarketplace() { + const app = Template.instance().app.get(); + return app.subscriptionInfo; + }, + canTrial() { + const app = Template.instance().app.get(); + return app.purchaseType === 'subscription' && app.subscriptionInfo && !app.subscriptionInfo.status; + }, + canBuy() { + const app = Template.instance().app.get(); + return app.price > 0; + }, + priceDisplay() { + const app = Template.instance().app.get(); + if (app.purchaseType === 'subscription') { + if (!app.pricingPlans || !Array.isArray(app.pricingPlans) || app.pricingPlans.length === 0) { + return; + } + + return formatPricingPlan(app.pricingPlans[0]); + } + + if (app.price > 0) { + return formatPrice(app.price); + } + + return 'Free'; + }, isEmail, _(key, ...args) { const options = args.pop().hash; @@ -237,22 +402,10 @@ Template.appManage.helpers({ }); return result; }, - appLanguage(key) { - const setting = settings.get('Language'); - return setting && setting.split('-').shift().toLowerCase() === key; - }, selectedOption(_id, val) { const settings = Template.instance().settings.get(); return settings[_id].value === val; }, - getColorVariable(color) { - return color.replace(/theme-color-/, '@'); - }, - dirty() { - const t = Template.instance(); - const settings = t.settings.get(); - return Object.keys(settings).some((k) => settings[k].hasChanged); - }, disabled() { const t = Template.instance(); const settings = t.settings.get(); @@ -265,46 +418,13 @@ Template.appManage.helpers({ return false; }, - hasError() { - if (Template.instance().hasError) { - return Template.instance().hasError.get(); - } - - return false; - }, - theError() { - if (Template.instance().theError) { - return Template.instance().theError.get(); + error() { + if (Template.instance().error) { + return Template.instance().error.get(); } return ''; }, - isProcessingEnabled() { - if (Template.instance().processingEnabled) { - return Template.instance().processingEnabled.get(); - } - - return false; - }, - isEnabled() { - if (!Template.instance().app) { - return false; - } - - const info = Template.instance().app.get(); - - return info.status === 'auto_enabled' || info.status === 'manually_enabled'; - }, - isInstalled() { - const instance = Template.instance(); - - return instance.app.get().installed === true; - }, - hasPurchased() { - const instance = Template.instance(); - - return instance.app.get().isPurchased === true; - }, app() { return Template.instance().app.get(); }, @@ -346,94 +466,7 @@ Template.appManage.helpers({ }, }); -async function setActivate(actiavate, e, t) { - t.processingEnabled.set(true); - - const el = $(e.currentTarget); - el.prop('disabled', true); - - const status = actiavate ? 'manually_enabled' : 'manually_disabled'; - - try { - const result = await APIClient.post(`apps/${ t.id.get() }/status`, { status }); - const info = t.app.get(); - info.status = result.status; - t.app.set(info); - } catch (e) { - toastr.error((e.xhr.responseJSON && e.xhr.responseJSON.error) || e.message); - } - t.processingEnabled.set(false); - el.prop('disabled', false); -} - Template.appManage.events({ - 'click .expand': (e) => { - $(e.currentTarget).closest('.section').removeClass('section-collapsed'); - $(e.currentTarget).closest('button').removeClass('expand').addClass('collapse').find('span').text(TAPi18n.__('Collapse')); - $('.CodeMirror').each((index, codeMirror) => codeMirror.CodeMirror.refresh()); - }, - - 'click .collapse': (e) => { - $(e.currentTarget).closest('.section').addClass('section-collapsed'); - $(e.currentTarget).closest('button').addClass('expand').removeClass('collapse').find('span').text(TAPi18n.__('Expand')); - }, - - 'click .js-cancel'() { - FlowRouter.go('/admin/apps'); - }, - - 'click .js-activate'(e, t) { - setActivate(true, e, t); - }, - - 'click .js-deactivate'(e, t) { - setActivate(false, e, t); - }, - - 'click .js-uninstall': async (e, t) => { - t.ready.set(false); - try { - await APIClient.delete(`apps/${ t.id.get() }`); - FlowRouter.go('/admin/apps'); - } catch (err) { - console.warn('Error:', err); - } finally { - t.ready.set(true); - } - }, - - 'click .js-install': async (e, t) => { - installAppFromEvent(e, t); - }, - - 'click .js-purchase': (e, t) => { - const rl = t.app.get(); - - APIClient.get(`apps?buildBuyUrl=true&appId=${ rl.id }`) - .then((data) => { - data.successCallback = async () => { - installAppFromEvent(e, t); - }; - - modal.open({ - allowOutsideClick: false, - data, - template: 'iframeModal', - }); - }) - .catch((e) => { - toastr.error((e.xhr.responseJSON && e.xhr.responseJSON.error) || e.message); - }); - }, - - 'click .js-update': (e, t) => { - FlowRouter.go(`/admin/app/install?isUpdatingId=${ t.id.get() }`); - }, - - 'click .js-view-logs': (e, t) => { - FlowRouter.go(`/admin/apps/${ t.id.get() }/logs`, {}, { version: FlowRouter.getQueryParam('version') }); - }, - 'click .js-cancel-editing': async (e, t) => { t.onSettingUpdated({ appId: t.id.get() }); }, @@ -453,7 +486,6 @@ Template.appManage.events({ if (setting.hasChanged) { toSave.push(setting); } - // return !!setting.hasChanged; }); if (toSave.length === 0) { @@ -477,6 +509,129 @@ Template.appManage.events({ } }, + 'click .js-cancel'() { + FlowRouter.go('/admin/apps'); + }, + + 'click .js-menu'(e, instance) { + e.stopPropagation(); + const { currentTarget } = e; + + const app = instance.app.get(); + const isActive = app && ['auto_enabled', 'manually_enabled'].includes(app.status); + + popover.open({ + currentTarget, + instance, + columns: [{ + groups: [ + { + items: [ + ...this.purchaseType === 'subscription' ? [{ + icon: 'card', + name: t('Subscription'), + action: () => promptSubscription(this, instance), + }] : [], + { + icon: 'list-alt', + name: t('View_Logs'), + action: () => viewLogs(app, instance), + }, + ], + }, + { + items: [ + isActive + ? { + icon: 'ban', + name: t('Deactivate'), + modifier: 'alert', + action: () => promptAppDeactivation(app, instance), + } + : { + icon: 'check', + name: t('Activate'), + action: () => activateApp(app, instance), + }, + { + icon: 'trash', + name: t('Uninstall'), + modifier: 'alert', + action: () => promptAppUninstall(app, instance), + }, + ], + }, + ], + }], + }); + }, + + async 'click .js-install'(e, instance) { + e.stopPropagation(); + + const { currentTarget: button } = e; + const stopLoading = triggerButtonLoadingState(button); + + const { id, version } = instance.app.get(); + + try { + await APIClient.post('apps/', { + appId: id, + marketplace: true, + version, + }); + } catch (e) { + handleAPIError(e, instance); + } + + try { + await getApp(instance); + } catch (e) { + handleAPIError(e, instance); + } finally { + stopLoading(); + } + }, + + async 'click .js-purchase'(e, instance) { + const { id, purchaseType = 'buy', version } = instance.app.get(); + const { currentTarget: button } = e; + const stopLoading = triggerButtonLoadingState(button); + + let data = null; + try { + data = await APIClient.get(`apps?buildExternalUrl=true&appId=${ id }&purchaseType=${ purchaseType }`); + } catch (e) { + handleAPIError(e, instance); + stopLoading(); + return; + } + + modal.open({ + allowOutsideClick: false, + data, + template: 'iframeModal', + }, async () => { + try { + await APIClient.post('apps/', { + appId: id, + marketplace: true, + version, + }); + } catch (e) { + handleAPIError(e, instance); + } + + try { + await getApp(instance); + } catch (e) { + handleAPIError(e, instance); + } finally { + stopLoading(); + } + }, stopLoading); + }, + 'change input[type="checkbox"]': (e, t) => { const labelFor = $(e.currentTarget).attr('name'); const isChecked = $(e.currentTarget).prop('checked'); diff --git a/app/apps/client/admin/apps.html b/app/apps/client/admin/apps.html index 96f0f9b699bd..1f48fa8716c0 100644 --- a/app/apps/client/admin/apps.html +++ b/app/apps/client/admin/apps.html @@ -1,5 +1,5 @@