From c765a03e9fde5156fde444f87a36bc92fb428782 Mon Sep 17 00:00:00 2001 From: Gabriel Henriques Date: Tue, 19 May 2020 17:05:33 -0300 Subject: [PATCH 01/18] WIP --- client/admin/apps/AppsRoute.js | 29 ++++++ client/admin/apps/MarketplacePage.js | 48 ++++++++++ client/admin/apps/MarketplaceTable.js | 130 ++++++++++++++++++++++++++ client/admin/routes.js | 4 + 4 files changed, 211 insertions(+) create mode 100644 client/admin/apps/AppsRoute.js create mode 100644 client/admin/apps/MarketplacePage.js create mode 100644 client/admin/apps/MarketplaceTable.js diff --git a/client/admin/apps/AppsRoute.js b/client/admin/apps/AppsRoute.js new file mode 100644 index 000000000000..1fe4b493d82b --- /dev/null +++ b/client/admin/apps/AppsRoute.js @@ -0,0 +1,29 @@ +import React, { useState, useEffect } from 'react'; +import { Box } from '@rocket.chat/fuselage'; + +import { Apps } from '../../../app/apps/client/orchestrator'; +import { usePermission } from '../../contexts/AuthorizationContext'; +import { useTranslation } from '../../contexts/TranslationContext'; +import NotAuthorizedPage from '../NotAuthorizedPage'; +import MarketplacePage from './MarketplacePage'; + +export default function AppsRoute() { + const t = useTranslation(); + + const canViewAppsAndMarketplace = usePermission('manage-apps'); + const [isEnabled, setEnabled] = useState(false); + + useEffect(() => { + (async () => setEnabled(await Apps.isEnabled()))(); + }, []); + + if (!canViewAppsAndMarketplace) { + return ; + } + + if (!isEnabled) { + return {t('Apps_disabled')}; + } + + return ; +} diff --git a/client/admin/apps/MarketplacePage.js b/client/admin/apps/MarketplacePage.js new file mode 100644 index 000000000000..ff1edc6fd7f9 --- /dev/null +++ b/client/admin/apps/MarketplacePage.js @@ -0,0 +1,48 @@ +import { Button, ButtonGroup, Icon, Tabs } from '@rocket.chat/fuselage'; +import React, { useEffect, useCallback } from 'react'; + +import Page from '../../components/basic/Page'; +import { Apps } from '../../../app/apps/client/orchestrator'; +import { useTranslation } from '../../contexts/TranslationContext'; +import { useRoute, useRouteParameter } from '../../contexts/RouterContext'; +import { useMethod } from '../../contexts/ServerContext'; +import MarketplaceTable from './MarketplaceTable'; + +function MarketplacePage() { + const t = useTranslation(); + + const cloudRouter = useRoute('cloud'); + + // const handleNewButtonClick = useCallback(() => { + // router.push({ context: 'new', type: 'incoming' }); + // }, []); + + // const context = useRouteParameter('context'); + // useEffect(() => { + // if (!context) { + // router.push({ context: 'webhook-incoming' }); + // } + // }, [context]); + + const getLoggedInCloud = useMethod('cloud:checkUserLoggedIn'); + const isLoggedInCloud = getLoggedInCloud(); + + useEffect(() => { + (async () => console.log(await Apps.getAppsFromMarketplace()))(); + }, []); + + return + + {isLoggedInCloud && + + } + + + {/* */} + + ; +} + +export default MarketplacePage; diff --git a/client/admin/apps/MarketplaceTable.js b/client/admin/apps/MarketplaceTable.js new file mode 100644 index 000000000000..2766cf1f6b6a --- /dev/null +++ b/client/admin/apps/MarketplaceTable.js @@ -0,0 +1,130 @@ +import { Box, Table, TextInput, Icon, Chip } from '@rocket.chat/fuselage'; +import { useDebouncedValue, useResizeObserver } from '@rocket.chat/fuselage-hooks'; +import React, { useMemo, useCallback, useState, useEffect } from 'react'; + +import { GenericTable, Th } from '../../../app/ui/client/components/GenericTable'; +import { useTranslation } from '../../contexts/TranslationContext'; +import { useRoute } from '../../contexts/RouterContext'; +import { useEndpointDataExperimental } from '../../hooks/useEndpointDataExperimental'; +import { useFormatDateAndTime } from '../../hooks/useFormatDateAndTime'; +import { Apps } from '../../../app/apps/client/orchestrator'; +import { formatPricingPlan } from '../../../app/apps/client/admin/helpers'; + + +// const style = { whiteSpace: 'nowrap', textOverflow: 'ellipsis', overflow: 'hidden' }; + +const FilterByText = React.memo(({ setFilter, ...props }) => { + const t = useTranslation(); + + const [text, setText] = useState(''); + + const handleChange = useCallback((event) => setText(event.currentTarget.value), []); + + useEffect(() => { + setFilter({ text }); + }, [text]); + + return e.preventDefault(), [])} display='flex' flexDirection='column' {...props}> + } onChange={handleChange} value={text} /> + ; +}); + +const useResizeInlineBreakpoint = (sizes = [], debounceDelay = 0) => { + const { ref, borderBoxSize } = useResizeObserver({ debounceDelay }); + const inlineSize = borderBoxSize ? borderBoxSize.inlineSize : 0; + sizes = useMemo(() => sizes.map((current) => (inlineSize ? inlineSize > current : true)), [inlineSize]); + return [ref, ...sizes]; +}; + +const formatPrice = (purchaseType, pricingPlans, price) => { + if (purchaseType === 'subscription') { + if (!pricingPlans || !Array.isArray(pricingPlans) || pricingPlans.length === 0) { + return; + } + + return formatPricingPlan(pricingPlans[0]); + } + + if (price > 0) { + return formatPrice(price); + } + + return 'Free'; +}; + +export function MarketplaceTable({ type }) { + const t = useTranslation(); + const formatDateAndTime = useFormatDateAndTime(); + const [ref, isBig] = useResizeInlineBreakpoint([700], 200); + + const [data, setData] = useState({}); + + useEffect(() => { + (async () => { + const appsData = await Promise.all([Apps.getAppsFromMarketplace(), Apps.getApps()]); + setData(await Apps.getAppsFromMarketplace()) + })(); + }, []); + + const [params, setParams] = useState({ text: '', current: 0, itemsPerPage: 25 }); + const [sort, setSort] = useState(['name', 'asc']); + + const debouncedText = useDebouncedValue(params.text, 500); + const debouncedSort = useDebouncedValue(sort, 500); + + const filteredData = useMemo(() => { + const filteredValues = Object.values(data).filter(debouncedSort).sort((a, b) => (a.name > b.name ? 1 : -1)); + return debouncedSort[1] === 'asc' ? filteredValues : filteredValues.reverse(); + }, [debouncedText, debouncedSort, params.current, params.itemsPerPage, JSON.stringify(data)]); + + const router = useRoute('admin-integrations'); + + const onClick = (_id, type) => () => router.push({ + context: 'edit', + type: type === 'webhook-incoming' ? 'incoming' : 'outgoing', + id: _id, + }); + + const onHeaderClick = useCallback((id) => { + const [sortBy, sortDirection] = sort; + + if (sortBy === id) { + setSort([id, sortDirection === 'asc' ? 'desc' : 'asc']); + return; + } + setSort([id, 'asc']); + }, [sort]); + + const header = useMemo(() => [ + {t('Name')}, + {t('Details')}, + {t('Price')}, + isBig && {t('Status')}, + + ].filter(Boolean), [sort, isBig]); + + const renderRow = useCallback(({ author, name, id, description, categories, purchaseType, pricingPlans, price, purchased, }) => { + const handler = useMemo(() => onClick(id, type), []); + return + + avatar + + {name} + {`${ t('By') } ${ author.name }`} + + + + + {description} + {categories && {categories.map((current) => {current})}} + + + {formatPrice(purchaseType, pricingPlans, price)} + {isBig && {}} + ; + }, []); + + return ; +} + +export default MarketplaceTable; diff --git a/client/admin/routes.js b/client/admin/routes.js index 6ede81d1ef1d..a2228c36cfee 100644 --- a/client/admin/routes.js +++ b/client/admin/routes.js @@ -37,6 +37,10 @@ registerAdminRoute('/custom-sounds/:context?/:id?', { lazyRouteComponent: () => import('./customSounds/AdminSoundsRoute'), }); +registerAdminRoute('/appsNew/:context?/:id?', { + name: 'admin-apps', + lazyRouteComponent: () => import('./apps/AppsRoute'), +}); registerAdminRoute('/info', { name: 'admin-info', From f2af7ec25d9acd67bf625947a4cf10e0d1d04d9f Mon Sep 17 00:00:00 2001 From: Gabriel Henriques Date: Wed, 20 May 2020 18:31:46 -0300 Subject: [PATCH 02/18] Wip 4 --- client/admin/apps/AppDetailsPage.js | 117 ++++++++++++++ client/admin/apps/AppMenu.js | 10 ++ client/admin/apps/AppStatus.js | 87 ++++++++++ client/admin/apps/AppsRoute.js | 11 +- client/admin/apps/CloudLoginModal.js | 29 ++++ client/admin/apps/IframeModal.js | 29 ++++ client/admin/apps/MarketplacePage.js | 29 +--- client/admin/apps/MarketplaceTable.js | 134 ++++++++-------- client/admin/apps/PriceDisplay.js | 31 ++++ client/admin/apps/helpers.js | 144 +++++++++++++++++ client/admin/apps/hooks/useAppInfo.js | 96 +++++++++++ client/admin/apps/hooks/useMarketplaceApps.js | 122 ++++++++++++++ client/admin/apps/hooks/useMenuOptions.js | 151 ++++++++++++++++++ client/admin/routes.js | 2 +- client/hooks/useResizeInlineBreakpoint.js | 9 ++ 15 files changed, 912 insertions(+), 89 deletions(-) create mode 100644 client/admin/apps/AppDetailsPage.js create mode 100644 client/admin/apps/AppMenu.js create mode 100644 client/admin/apps/AppStatus.js create mode 100644 client/admin/apps/CloudLoginModal.js create mode 100644 client/admin/apps/IframeModal.js create mode 100644 client/admin/apps/PriceDisplay.js create mode 100644 client/admin/apps/helpers.js create mode 100644 client/admin/apps/hooks/useAppInfo.js create mode 100644 client/admin/apps/hooks/useMarketplaceApps.js create mode 100644 client/admin/apps/hooks/useMenuOptions.js create mode 100644 client/hooks/useResizeInlineBreakpoint.js diff --git a/client/admin/apps/AppDetailsPage.js b/client/admin/apps/AppDetailsPage.js new file mode 100644 index 000000000000..572dd5f24059 --- /dev/null +++ b/client/admin/apps/AppDetailsPage.js @@ -0,0 +1,117 @@ +import React, { useState, useCallback } from 'react'; +import { Button, ButtonGroup, Icon, Avatar, Box, Divider, Chip, Margins } from '@rocket.chat/fuselage'; + +import Page from '../../components/basic/Page'; +import { useRoute } from '../../contexts/RouterContext'; +import { useMethod } from '../../contexts/ServerContext'; +import PriceDisplay from './PriceDisplay'; +import { AppStatus } from './AppStatus'; +import { useTranslation } from '../../contexts/TranslationContext'; +import { useAppInfo } from './hooks/useAppInfo'; +import { AppMenu } from './AppMenu'; + +const objectFit = { objectFit: 'contain' }; + +export default function AppDetailsPage({ id }) { + const t = useTranslation(); + const data = useAppInfo(id); + const [modal, setModal] = useState(null); + + const router = useRoute('admin-apps'); + const handleReturn = useCallback(() => router.push({})); + + const { + iconFileData = '', + name, + author: { name: authorName, homepage, support } = {}, + description, + categories = [], + version, + price, + purchaseType, + pricingPlans, + iconFileContent, + installed, + bundledIn, + } = data; + + const getLoggedInCloud = useMethod('cloud:checkUserLoggedIn'); + const isLoggedIn = getLoggedInCloud(); + + return <> + + + + + + + + + + + {name} + + {`${ t('By') } ${ authorName }`} + | + {`${ t('Version') } ${ version }`} + + + + + {!installed && } + + {installed && } + + + + + + + + {t('Categories')} + + {categories && categories.map((current) => {current})} + + + {t('Contact')} + + + {t('Author_Site')} + {homepage} + + + {t('Support')} + {support} + + + + {t('Details')} + {description} + + + + {bundledIn && <> + + + + {t('Bundles')} + {bundledIn.map((bundle) => + + {bundle.apps.map((app) => )} + + + {bundle.bundleName} + {bundle.apps.map((app) => {app.latest.name},)} + + )} + + + } + + {modal}; +} diff --git a/client/admin/apps/AppMenu.js b/client/admin/apps/AppMenu.js new file mode 100644 index 000000000000..745d0c0cdf3f --- /dev/null +++ b/client/admin/apps/AppMenu.js @@ -0,0 +1,10 @@ +import React from 'react'; +import { Menu } from '@rocket.chat/fuselage'; + +import { useMenuOptions } from './hooks/useMenuOptions'; + +export const AppMenu = ({ app, setModal, isLoggedIn, ...props }) => { + const menuOptions = useMenuOptions({ app, setModal, isLoggedIn }); + + return ; +}; diff --git a/client/admin/apps/AppStatus.js b/client/admin/apps/AppStatus.js new file mode 100644 index 000000000000..4ea476ce1353 --- /dev/null +++ b/client/admin/apps/AppStatus.js @@ -0,0 +1,87 @@ +import React, { useCallback, useState } from 'react'; +import { Box, Button, Icon, Throbber } from '@rocket.chat/fuselage'; + +import { useTranslation } from '../../contexts/TranslationContext'; +import { appButtonProps, appStatusSpanProps, handleAPIError, warnStatusChange } from './helpers'; +import { Apps } from '../../../app/apps/client/orchestrator'; +import { IframeModal } from './IframeModal'; +import { CloudLoginModal } from './CloudLoginModal'; + +const installApp = async ({ id, name, version }) => { + try { + const { status } = await Apps.installApp(id, version); + warnStatusChange(name, status); + } catch (error) { + handleAPIError(error); + } +}; + +const actions = { + purchase: installApp, + install: installApp, + update: async ({ id, name, version }) => { + try { + const { status } = await Apps.updateApp(id, version); + warnStatusChange(name, status); + } catch (error) { + handleAPIError(error); + } + }, +}; + +export const AppStatus = React.memo(({ app, setModal, isLoggedIn, ...props }) => { + const t = useTranslation(); + const [loading, setLoading] = useState(); + + const button = appButtonProps(app); + const status = !button && appStatusSpanProps(app); + + const { id } = app; + + const confirmAction = () => { + setModal(null); + + actions[button.action](app).then(() => { + setLoading(false); + }); + }; + + const cancelAction = () => { + setModal(null); + setLoading(false); + }; + + const openModal = async () => { + try { + const data = await Apps.buildExternalUrl(app.id, app.purchaseType, false); + + setModal(() => ); + } catch (error) { + handleAPIError(error); + } + }; + + const handleClick = useCallback((e) => { + e.preventDefault(); + e.stopPropagation(); + if (isLoggedIn) { + setLoading(true); + + button.action === 'purchase' ? openModal() : confirmAction(); + return; + } + setModal( setModal(null)} />); + }, [id, isLoggedIn, button && button.action]); + + return + {button && } + {status && + + {t(status.label)} + } + ; +}); diff --git a/client/admin/apps/AppsRoute.js b/client/admin/apps/AppsRoute.js index 1fe4b493d82b..7b5bd1cfb6bf 100644 --- a/client/admin/apps/AppsRoute.js +++ b/client/admin/apps/AppsRoute.js @@ -2,9 +2,11 @@ import React, { useState, useEffect } from 'react'; import { Box } from '@rocket.chat/fuselage'; import { Apps } from '../../../app/apps/client/orchestrator'; +import { useRouteParameter } from '../../contexts/RouterContext'; import { usePermission } from '../../contexts/AuthorizationContext'; import { useTranslation } from '../../contexts/TranslationContext'; import NotAuthorizedPage from '../NotAuthorizedPage'; +import AppDetailsPage from './AppDetailsPage'; import MarketplacePage from './MarketplacePage'; export default function AppsRoute() { @@ -13,6 +15,10 @@ export default function AppsRoute() { const canViewAppsAndMarketplace = usePermission('manage-apps'); const [isEnabled, setEnabled] = useState(false); + const context = useRouteParameter('context'); + const id = useRouteParameter('id'); + const version = useRouteParameter('version'); + useEffect(() => { (async () => setEnabled(await Apps.isEnabled()))(); }, []); @@ -25,5 +31,8 @@ export default function AppsRoute() { return {t('Apps_disabled')}; } - return ; + return <> + {!context && } + {context === 'details' && } + ; } diff --git a/client/admin/apps/CloudLoginModal.js b/client/admin/apps/CloudLoginModal.js new file mode 100644 index 000000000000..0effb2f61f88 --- /dev/null +++ b/client/admin/apps/CloudLoginModal.js @@ -0,0 +1,29 @@ + +import React from 'react'; +import { Icon, Button, ButtonGroup } from '@rocket.chat/fuselage'; + +import { useTranslation } from '../../contexts/TranslationContext'; +import { Modal } from '../../components/basic/Modal'; +import { useRoute } from '../../contexts/RouterContext'; + +export const CloudLoginModal = ({ cancel, ...props }) => { + const t = useTranslation(); + const router = useRoute('cloud'); + + return + + + {t('Apps_Marketplace_Login_Required_Title')} + + + + {t('Apps_Marketplace_Login_Required_Description')} + + + + + + + + ; +}; diff --git a/client/admin/apps/IframeModal.js b/client/admin/apps/IframeModal.js new file mode 100644 index 000000000000..bd5cce284012 --- /dev/null +++ b/client/admin/apps/IframeModal.js @@ -0,0 +1,29 @@ +import React, { useEffect } from 'react'; +import { Box } from '@rocket.chat/fuselage'; + +import { Modal } from '../../components/basic/Modal'; + +const iframeMsgListener = (confirm, cancel) => (e) => { + let data; + try { + data = JSON.parse(e.data); + } catch (e) { + return; + } + + data.result ? confirm(data) : cancel(); +}; + +export const IframeModal = ({ url, confirm, cancel, ...props }) => { + useEffect(() => { + const listener = iframeMsgListener(confirm, cancel); + window.addEventListener('message', listener); + + return () => window.removeEventListener('message', listener); + }, []); + return + +