From 4c37c5c97474a57e262e2b95a5867dd9d11d2861 Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Thu, 28 Oct 2021 11:40:30 -0300 Subject: [PATCH 01/78] Bump version to 4.2.0-develop --- .docker/Dockerfile.rhel | 2 +- .snapcraft/resources/prepareRocketChat | 2 +- .snapcraft/snap/snapcraft.yaml | 2 +- app/utils/rocketchat.info | 2 +- package-lock.json | 2 +- package.json | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.docker/Dockerfile.rhel b/.docker/Dockerfile.rhel index 8822de22ef80..098af5ace225 100644 --- a/.docker/Dockerfile.rhel +++ b/.docker/Dockerfile.rhel @@ -1,6 +1,6 @@ FROM registry.access.redhat.com/ubi8/nodejs-12 -ENV RC_VERSION 4.1.0 +ENV RC_VERSION 4.2.0-develop MAINTAINER buildmaster@rocket.chat diff --git a/.snapcraft/resources/prepareRocketChat b/.snapcraft/resources/prepareRocketChat index 92c8f26d454d..51dced777dab 100755 --- a/.snapcraft/resources/prepareRocketChat +++ b/.snapcraft/resources/prepareRocketChat @@ -1,6 +1,6 @@ #!/bin/bash -curl -SLf "https://releases.rocket.chat/4.1.0/download/" -o rocket.chat.tgz +curl -SLf "https://releases.rocket.chat/4.2.0-develop/download/" -o rocket.chat.tgz tar xf rocket.chat.tgz --strip 1 diff --git a/.snapcraft/snap/snapcraft.yaml b/.snapcraft/snap/snapcraft.yaml index 9c13354e4f6a..bee2a499cbc4 100644 --- a/.snapcraft/snap/snapcraft.yaml +++ b/.snapcraft/snap/snapcraft.yaml @@ -7,7 +7,7 @@ # 5. `snapcraft snap` name: rocketchat-server -version: 4.1.0 +version: 4.2.0-develop summary: Rocket.Chat server description: Have your own Slack like online chat, built with Meteor. https://rocket.chat/ confinement: strict diff --git a/app/utils/rocketchat.info b/app/utils/rocketchat.info index b3e4de0fd745..643b57be5004 100644 --- a/app/utils/rocketchat.info +++ b/app/utils/rocketchat.info @@ -1,3 +1,3 @@ { - "version": "4.1.0" + "version": "4.2.0-develop" } diff --git a/package-lock.json b/package-lock.json index b4f4b752b5aa..9fec1aaf5120 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "Rocket.Chat", - "version": "4.1.0", + "version": "4.2.0-develop", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index 8c4109ab850d..e7b26de962e2 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "Rocket.Chat", "description": "The Ultimate Open Source WebChat Platform", - "version": "4.1.0", + "version": "4.2.0-develop", "author": { "name": "Rocket.Chat", "url": "https://rocket.chat/" From ac84a4120fa76d6d4c0505ea51019343d5e9e7f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrique=20Guimar=C3=A3es=20Ribeiro?= Date: Fri, 29 Oct 2021 09:55:04 -0300 Subject: [PATCH 02/78] [IMPROVE] MKP12 - New UI - Merge Apps and Marketplace Tabs and Content (#23542) * WIP * improve: finished the page merging and routing logic Co-authored-by: Guilherme Gazzo Co-authored-by: dougfabris --- client/views/admin/apps/AppRow.tsx | 1 - client/views/admin/apps/AppsPage.js | 46 ------------ client/views/admin/apps/AppsPage.tsx | 84 ++++++++++++++++++++++ client/views/admin/apps/AppsRoute.js | 17 +++-- client/views/admin/routes.js | 5 ++ client/views/admin/sidebarItems.js | 8 +-- packages/rocketchat-i18n/i18n/en.i18n.json | 1 + 7 files changed, 99 insertions(+), 63 deletions(-) delete mode 100644 client/views/admin/apps/AppsPage.js create mode 100644 client/views/admin/apps/AppsPage.tsx diff --git a/client/views/admin/apps/AppRow.tsx b/client/views/admin/apps/AppRow.tsx index 01104d18689d..762257cb5c86 100644 --- a/client/views/admin/apps/AppRow.tsx +++ b/client/views/admin/apps/AppRow.tsx @@ -93,7 +93,6 @@ const AppRow: FC = ({ medium, ...props }) => { {current} ))} - ; )} diff --git a/client/views/admin/apps/AppsPage.js b/client/views/admin/apps/AppsPage.js deleted file mode 100644 index 2c4c47b7cdc9..000000000000 --- a/client/views/admin/apps/AppsPage.js +++ /dev/null @@ -1,46 +0,0 @@ -import { Button, ButtonGroup, Icon } from '@rocket.chat/fuselage'; -import React from 'react'; - -import Page from '../../../components/Page'; -import { useRoute } from '../../../contexts/RouterContext'; -import { useSetting } from '../../../contexts/SettingsContext'; -import { useTranslation } from '../../../contexts/TranslationContext'; -import AppsTable from './AppsTable'; - -function AppsPage() { - const t = useTranslation(); - - const isDevelopmentMode = useSetting('Apps_Framework_Development_Mode'); - const marketplaceRoute = useRoute('admin-marketplace'); - const appsRoute = useRoute('admin-apps'); - - const handleUploadButtonClick = () => { - appsRoute.push({ context: 'install' }); - }; - - const handleViewMarketplaceButtonClick = () => { - marketplaceRoute.push(); - }; - - return ( - - - - {isDevelopmentMode && ( - - )} - - - - - - - - ); -} - -export default AppsPage; diff --git a/client/views/admin/apps/AppsPage.tsx b/client/views/admin/apps/AppsPage.tsx new file mode 100644 index 000000000000..ab42a902de2c --- /dev/null +++ b/client/views/admin/apps/AppsPage.tsx @@ -0,0 +1,84 @@ +import { Button, ButtonGroup, Icon, Skeleton, Tabs } from '@rocket.chat/fuselage'; +import React, { useEffect, useState, ReactElement } from 'react'; + +import Page from '../../../components/Page'; +import { useRoute } from '../../../contexts/RouterContext'; +import { useMethod } from '../../../contexts/ServerContext'; +import { useSetting } from '../../../contexts/SettingsContext'; +import { useTranslation } from '../../../contexts/TranslationContext'; +import AppsTable from './AppsTable'; +import MarketplaceTable from './MarketplaceTable'; + +type AppsPageProps = { + isMarketPlace: boolean; + context: string; +}; + +const AppsPage = ({ isMarketPlace, context }: AppsPageProps): ReactElement => { + const t = useTranslation(); + + const isDevelopmentMode = useSetting('Apps_Framework_Development_Mode'); + const marketplaceRoute = useRoute('admin-marketplace'); + const appsRoute = useRoute('admin-apps'); + const cloudRoute = useRoute('cloud'); + const checkUserLoggedIn = useMethod('cloud:checkUserLoggedIn'); + + const [isLoggedInCloud, setIsLoggedInCloud] = useState(); + + useEffect(() => { + const initialize = async (): Promise => { + setIsLoggedInCloud(await checkUserLoggedIn()); + }; + initialize(); + }, [checkUserLoggedIn]); + + const handleLoginButtonClick = (): void => { + cloudRoute.push(); + }; + + const handleUploadButtonClick = (): void => { + appsRoute.push({ context: 'install' }); + }; + + return ( + + + + {isMarketPlace && !isLoggedInCloud && ( + + )} + {Boolean(isDevelopmentMode) && ( + + )} + + + + marketplaceRoute.push({ context: '' })} + selected={isMarketPlace} + > + {t('Marketplace')} + + marketplaceRoute.push({ context: 'installed' })} + selected={context === 'installed'} + > + {t('Installed')} + + + {context === 'installed' ? : } + + ); +}; + +export default AppsPage; diff --git a/client/views/admin/apps/AppsRoute.js b/client/views/admin/apps/AppsRoute.js index 9c0299c653dd..6cdeb5f710ce 100644 --- a/client/views/admin/apps/AppsRoute.js +++ b/client/views/admin/apps/AppsRoute.js @@ -3,16 +3,15 @@ import React, { useState, useEffect } from 'react'; import NotAuthorizedPage from '../../../components/NotAuthorizedPage'; import PageSkeleton from '../../../components/PageSkeleton'; import { usePermission } from '../../../contexts/AuthorizationContext'; -import { useRouteParameter, useRoute, useCurrentRoute } from '../../../contexts/RouterContext'; +import { useRouteParameter, useRoute } from '../../../contexts/RouterContext'; import { useMethod } from '../../../contexts/ServerContext'; import AppDetailsPage from './AppDetailsPage'; import AppInstallPage from './AppInstallPage'; import AppLogsPage from './AppLogsPage'; import AppsPage from './AppsPage'; import AppsProvider from './AppsProvider'; -import MarketplacePage from './MarketplacePage'; -function AppsRoute() { +const AppsRoute = () => { const [isLoading, setLoading] = useState(true); const canViewAppsAndMarketplace = usePermission('manage-apps'); const isAppsEngineEnabled = useMethod('apps/is-enabled'); @@ -45,11 +44,10 @@ function AppsRoute() { }; }, [canViewAppsAndMarketplace, isAppsEngineEnabled, appsWhatIsItRoute]); - const [currentRouteName] = useCurrentRoute(); + const context = useRouteParameter('context'); - const isMarketPlace = currentRouteName === 'admin-marketplace'; + const isMarketPlace = !context; - const context = useRouteParameter('context'); const id = useRouteParameter('id'); const version = useRouteParameter('version'); @@ -63,13 +61,14 @@ function AppsRoute() { return ( - {(!context && isMarketPlace && ) || - (!context && !isMarketPlace && ) || + {((!context || context === 'installed') && ( + + )) || (context === 'details' && ) || (context === 'logs' && ) || (context === 'install' && )} ); -} +}; export default AppsRoute; diff --git a/client/views/admin/routes.js b/client/views/admin/routes.js index 815fed54be02..4d86946c7603 100644 --- a/client/views/admin/routes.js +++ b/client/views/admin/routes.js @@ -26,6 +26,11 @@ registerAdminRoute('/apps/:context?/:id?/:version?', { lazyRouteComponent: () => import('./apps/AppsRoute'), }); +registerAdminRoute('/apps/:context?/:id?/:version?', { + name: 'admin-apps', + lazyRouteComponent: () => import('./apps/AppsRoute'), +}); + registerAdminRoute('/info', { name: 'admin-info', lazyRouteComponent: () => import('./info/InformationRoute'), diff --git a/client/views/admin/sidebarItems.js b/client/views/admin/sidebarItems.js index f8f7a9d7579d..a9531ca52e44 100644 --- a/client/views/admin/sidebarItems.js +++ b/client/views/admin/sidebarItems.js @@ -62,16 +62,10 @@ export const { i18nLabel: 'Federation Dashboard', permissionGranted: () => hasRole(Meteor.userId(), 'admin'), }, - { - icon: 'cube', - href: 'admin-apps', - i18nLabel: 'Apps', - permissionGranted: () => hasPermission(['manage-apps']), - }, { icon: 'cube', href: 'admin-marketplace', - i18nLabel: 'Marketplace', + i18nLabel: 'Apps', permissionGranted: () => hasPermission(['manage-apps']), }, { diff --git a/packages/rocketchat-i18n/i18n/en.i18n.json b/packages/rocketchat-i18n/i18n/en.i18n.json index c2e32ce53de4..f378fa709eb4 100644 --- a/packages/rocketchat-i18n/i18n/en.i18n.json +++ b/packages/rocketchat-i18n/i18n/en.i18n.json @@ -2801,6 +2801,7 @@ "Markdown_Parser": "Markdown Parser", "Markdown_SupportSchemesForLink": "Markdown Support Schemes for Link", "Markdown_SupportSchemesForLink_Description": "Comma-separated list of allowed schemes", + "Marketplace": "Marketplace", "Marketplace_view_marketplace": "View Marketplace", "MAU_value": "MAU __value__", "Max_length_is": "Max length is %s", From c954c5ffece8111bf924bec91e6666b25a66749f Mon Sep 17 00:00:00 2001 From: Matheus Barbosa Silva <36537004+matheusbsilva137@users.noreply.github.com> Date: Fri, 29 Oct 2021 14:40:04 -0300 Subject: [PATCH 03/78] [FIX] Notifications are not being filtered (#23487) * Add migration to update push notification setting's value * Update mobileNotifications preference to pushNotifications --- app/api/server/v1/users.js | 2 +- app/utils/lib/getDefaultSubscriptionPref.js | 6 ++--- .../methods/saveUserPreferences.ts | 2 +- .../PreferencesNotificationsSection.js | 10 +++---- server/methods/saveUserPreferences.js | 10 +++---- server/startup/migrations/index.ts | 1 + server/startup/migrations/v243.ts | 26 +++++++++++++++++++ tests/data/user.js | 2 +- 8 files changed, 43 insertions(+), 16 deletions(-) create mode 100644 server/startup/migrations/v243.ts diff --git a/app/api/server/v1/users.js b/app/api/server/v1/users.js index 714fc5e9265f..1f3f22e38e6c 100644 --- a/app/api/server/v1/users.js +++ b/app/api/server/v1/users.js @@ -593,7 +593,7 @@ API.v1.addRoute('users.setPreferences', { authRequired: true }, { unreadAlert: Match.Maybe(Boolean), notificationsSoundVolume: Match.Maybe(Number), desktopNotifications: Match.Maybe(String), - mobileNotifications: Match.Maybe(String), + pushNotifications: Match.Maybe(String), enableAutoAway: Match.Maybe(Boolean), highlights: Match.Maybe(Array), desktopNotificationRequireInteraction: Match.Maybe(Boolean), diff --git a/app/utils/lib/getDefaultSubscriptionPref.js b/app/utils/lib/getDefaultSubscriptionPref.js index 294a7d50a734..0200cb128e33 100644 --- a/app/utils/lib/getDefaultSubscriptionPref.js +++ b/app/utils/lib/getDefaultSubscriptionPref.js @@ -3,7 +3,7 @@ export const getDefaultSubscriptionPref = (userPref) => { const { desktopNotifications, - mobileNotifications, + pushNotifications, emailNotificationMode, highlights, } = (userPref.settings && userPref.settings.preferences) || {}; @@ -17,8 +17,8 @@ export const getDefaultSubscriptionPref = (userPref) => { subscription.desktopPrefOrigin = 'user'; } - if (mobileNotifications && mobileNotifications !== 'default') { - subscription.mobilePushNotifications = mobileNotifications; + if (pushNotifications && pushNotifications !== 'default') { + subscription.mobilePushNotifications = pushNotifications; subscription.mobilePrefOrigin = 'user'; } diff --git a/client/contexts/ServerContext/methods/saveUserPreferences.ts b/client/contexts/ServerContext/methods/saveUserPreferences.ts index 1f5f67100c97..7e6b20ad0bf6 100644 --- a/client/contexts/ServerContext/methods/saveUserPreferences.ts +++ b/client/contexts/ServerContext/methods/saveUserPreferences.ts @@ -12,7 +12,7 @@ type UserPreferences = { unreadAlert: boolean; notificationsSoundVolume: number; desktopNotifications: string; - mobileNotifications: string; + pushNotifications: string; enableAutoAway: boolean; highlights: string[]; messageViewMode: number; diff --git a/client/views/account/preferences/PreferencesNotificationsSection.js b/client/views/account/preferences/PreferencesNotificationsSection.js index 615b063d8e3e..6419f8df5ffe 100644 --- a/client/views/account/preferences/PreferencesNotificationsSection.js +++ b/client/views/account/preferences/PreferencesNotificationsSection.js @@ -50,7 +50,7 @@ const PreferencesNotificationsSection = ({ onChange, commitRef, ...props }) => { { desktopNotificationRequireInteraction: userDesktopNotificationRequireInteraction, desktopNotifications: userDesktopNotifications, - mobileNotifications: userMobileNotifications, + pushNotifications: userMobileNotifications, emailNotificationMode: userEmailNotificationMode, }, onChange, @@ -59,14 +59,14 @@ const PreferencesNotificationsSection = ({ onChange, commitRef, ...props }) => { const { desktopNotificationRequireInteraction, desktopNotifications, - mobileNotifications, + pushNotifications, emailNotificationMode, } = values; const { handleDesktopNotificationRequireInteraction, handleDesktopNotifications, - handleMobileNotifications, + handlePushNotifications, handleEmailNotificationMode, } = handlers; @@ -171,8 +171,8 @@ const PreferencesNotificationsSection = ({ onChange, commitRef, ...props }) => { {t('Notification_Push_Default_For')} }> - - {channels && !channels.length && - {t('No_data_found')} - } - {(!channels || channels.length) - && - - - {'#'} - {t('Channel')} - {t('Created')} - {t('Last_active')} - {t('Messages_sent')} - - - - {channels && channels.map(({ t, name, createdAt, updatedAt, messagesCount, messagesVariation }, i) => - - {i + 1}. - - - {(t === 'd' && ) - || (t === 'c' && ) - || (t === 'p' && )} - - {name} - - - {moment(createdAt).format('L')} - - - {moment(updatedAt).format('L')} - - - {messagesCount} {messagesVariation} - - )} - {!channels && Array.from({ length: 5 }, (_, i) => - - - - - - - - - - - - - - - - - )} - -
} - t('Items_per_page:')} - showingResultsLabel={({ count, current, itemsPerPage }) => - t('Showing_results_of', current + 1, Math.min(current + itemsPerPage, count), count)} - count={(data && data.total) || 0} - onSetItemsPerPage={setItemsPerPage} - onSetCurrent={setCurrent} - /> -
- ; -}; - -export default TableSection; diff --git a/ee/app/engagement-dashboard/client/components/ChannelsTab/index.js b/ee/app/engagement-dashboard/client/components/ChannelsTab/index.js deleted file mode 100644 index db12bcb932aa..000000000000 --- a/ee/app/engagement-dashboard/client/components/ChannelsTab/index.js +++ /dev/null @@ -1,7 +0,0 @@ -import React from 'react'; - -import TableSection from './TableSection'; - -const ChannelsTab = () => ; - -export default ChannelsTab; diff --git a/ee/app/engagement-dashboard/client/components/EngagementDashboardPage.js b/ee/app/engagement-dashboard/client/components/EngagementDashboardPage.js deleted file mode 100644 index 18f381930964..000000000000 --- a/ee/app/engagement-dashboard/client/components/EngagementDashboardPage.js +++ /dev/null @@ -1,42 +0,0 @@ -import { Box, Select, Tabs } from '@rocket.chat/fuselage'; -import React, { useMemo, useState } from 'react'; - -import { useTranslation } from '../../../../../client/contexts/TranslationContext'; -import Page from '../../../../../client/components/Page'; -import UsersTab from './UsersTab'; -import MessagesTab from './MessagesTab'; -import ChannelsTab from './ChannelsTab'; - -export const EngagementDashboardPage = ({ - tab = 'users', - onSelectTab, -}) => { - const t = useTranslation(); - const timezoneOptions = useMemo(() => [ - ['utc', t('UTC_Timezone')], - ['local', t('Local_Timezone')], - ], [t]); - - const [timezoneId, setTimezoneId] = useState('utc'); - const handleTimezoneChange = (timezoneId) => setTimezoneId(timezoneId); - - const handleTabClick = useMemo(() => (onSelectTab ? (tab) => () => onSelectTab(tab) : () => undefined), [onSelectTab]); - - return - - } - > - - - - - - - - {pie - ? - - - - - - {t('Value_messages', { value })} - } - /> - - - - - - - - - - - {t('Private_Chats')} - - - - {t('Private_Channels')} - - - - {t('Public_Channels')} - - - - - - - : } - - - - - - - {table ? {t('Most_popular_channels_top_5')} : } - - {table && !table.length && - {t('Not_enough_data')} - } - {(!table || !!table.length) && - - - {'#'} - {t('Channel')} - {t('Number_of_messages')} - - - - {table && table.map(({ i, t, name, messages }) => - {i + 1}. - - - {(t === 'd' && ) - || (t === 'c' && ) - || (t === 'p' && )} - - {name} - - {messages} - )} - {!table && Array.from({ length: 5 }, (_, i) => - - - - - - - - - - )} - -
} -
-
-
-
-
-
- ; -}; - -export default MessagesPerChannelSection; diff --git a/ee/app/engagement-dashboard/client/components/MessagesTab/MessagesSentSection.js b/ee/app/engagement-dashboard/client/components/MessagesTab/MessagesSentSection.js deleted file mode 100644 index 7871259261a7..000000000000 --- a/ee/app/engagement-dashboard/client/components/MessagesTab/MessagesSentSection.js +++ /dev/null @@ -1,186 +0,0 @@ -import { ResponsiveBar } from '@nivo/bar'; -import { Box, Flex, Select, Skeleton, ActionButton } from '@rocket.chat/fuselage'; -import moment from 'moment'; -import React, { useMemo, useState } from 'react'; - -import { useTranslation } from '../../../../../../client/contexts/TranslationContext'; -import { useEndpointData } from '../../../../../../client/hooks/useEndpointData'; -import CounterSet from '../../../../../../client/components/data/CounterSet'; -import { Section } from '../Section'; -import { downloadCsvAs } from '../../../../../../client/lib/download'; - -const MessagesSentSection = () => { - const t = useTranslation(); - - const periodOptions = useMemo(() => [ - ['last 7 days', t('Last_7_days')], - ['last 30 days', t('Last_30_days')], - ['last 90 days', t('Last_90_days')], - ], [t]); - - const [periodId, setPeriodId] = useState('last 7 days'); - - const period = useMemo(() => { - switch (periodId) { - case 'last 7 days': - return { - start: moment().set({ hour: 0, minute: 0, second: 0, millisecond: 0 }).subtract(7, 'days'), - end: moment().set({ hour: 0, minute: 0, second: 0, millisecond: 0 }).subtract(1), - }; - - case 'last 30 days': - return { - start: moment().set({ hour: 0, minute: 0, second: 0, millisecond: 0 }).subtract(30, 'days'), - end: moment().set({ hour: 0, minute: 0, second: 0, millisecond: 0 }).subtract(1), - }; - - case 'last 90 days': - return { - start: moment().set({ hour: 0, minute: 0, second: 0, millisecond: 0 }).subtract(90, 'days'), - end: moment().set({ hour: 0, minute: 0, second: 0, millisecond: 0 }).subtract(1), - }; - } - }, [periodId]); - - const handlePeriodChange = (periodId) => setPeriodId(periodId); - - const params = useMemo(() => ({ - start: period.start.toISOString(), - end: period.end.toISOString(), - }), [period]); - - const { value: data } = useEndpointData('engagement-dashboard/messages/messages-sent', params); - - const [ - countFromPeriod, - variatonFromPeriod, - countFromYesterday, - variationFromYesterday, - values, - ] = useMemo(() => { - if (!data) { - return []; - } - - const values = Array.from({ length: moment(period.end).diff(period.start, 'days') + 1 }, (_, i) => ({ - date: moment(period.start).add(i, 'days').toISOString(), - newMessages: 0, - })); - for (const { day, messages } of data.days) { - const i = moment(day).diff(period.start, 'days'); - if (i >= 0) { - values[i].newMessages += messages; - } - } - - return [ - data.period.count, - data.period.variation, - data.yesterday.count, - data.yesterday.variation, - values, - ]; - }, [data, period]); - - const downloadData = () => { - const data = [ - ['Date', 'Messages'], - ...values.map(({ date, newMessages }) => [date, newMessages]), - ]; - downloadCsvAs(data, `MessagesSentSection_start_${ params.start }_end_${ params.end }`); - }; - - return
} - > - , - variation: data ? variatonFromPeriod : 0, - description: periodOptions.find(([id]) => id === periodId)[1], - }, - { - count: data ? countFromYesterday : , - variation: data ? variationFromYesterday : 0, - description: t('Yesterday'), - }, - ]} - /> - - {data - ? - - - - moment(date).format(values.length === 7 ? 'dddd' : 'DD/MM'), - }} - axisLeft={{ - tickSize: 0, - // TODO: Get it from theme - tickPadding: 4, - tickRotation: 0, - }} - animate={true} - motionStiffness={90} - motionDamping={15} - theme={{ - // TODO: Get it from theme - axis: { - ticks: { - text: { - fill: '#9EA2A8', - fontFamily: 'Inter, -apple-system, system-ui, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Helvetica Neue", "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Meiryo UI", Arial, sans-serif', - fontSize: '10px', - fontStyle: 'normal', - fontWeight: '600', - letterSpacing: '0.2px', - lineHeight: '12px', - }, - }, - }, - tooltip: { - backgroundColor: '#1F2329', - boxShadow: '0px 0px 12px rgba(47, 52, 61, 0.12), 0px 0px 2px rgba(47, 52, 61, 0.08)', - borderRadius: 2, - padding: 4, - }, - }} - tooltip={({ value, indexValue }) => - {t('Value_users', { value })}, {formatDate(indexValue)} - } - /> - - - - - : } - -
; -}; - -export default NewUsersSection; diff --git a/ee/app/engagement-dashboard/client/components/UsersTab/UsersByTimeOfTheDaySection.js b/ee/app/engagement-dashboard/client/components/UsersTab/UsersByTimeOfTheDaySection.js deleted file mode 100644 index 415a01214142..000000000000 --- a/ee/app/engagement-dashboard/client/components/UsersTab/UsersByTimeOfTheDaySection.js +++ /dev/null @@ -1,211 +0,0 @@ -import { ResponsiveHeatMap } from '@nivo/heatmap'; -import { Box, Flex, Select, Skeleton, ActionButton } from '@rocket.chat/fuselage'; -import moment from 'moment'; -import React, { useMemo, useState } from 'react'; - -import { useTranslation } from '../../../../../../client/contexts/TranslationContext'; -import { useEndpointData } from '../../../../../../client/hooks/useEndpointData'; -import { Section } from '../Section'; -import { downloadCsvAs } from '../../../../../../client/lib/download'; - -const UsersByTimeOfTheDaySection = ({ timezone }) => { - const t = useTranslation(); - const utc = timezone === 'utc'; - - const periodOptions = useMemo(() => [ - ['last 7 days', t('Last_7_days')], - ['last 30 days', t('Last_30_days')], - ['last 90 days', t('Last_90_days')], - ], [t]); - - const [periodId, setPeriodId] = useState('last 7 days'); - - const period = useMemo(() => { - switch (periodId) { - case 'last 7 days': - return { - start: utc - ? moment.utc().startOf('day').subtract(7, 'days') - : moment().startOf('day').subtract(8, 'days'), - end: utc - ? moment.utc().endOf('day').subtract(1, 'days') - : moment().endOf('day'), - }; - - case 'last 30 days': - return { - start: utc - ? moment.utc().startOf('day').subtract(30, 'days') - : moment().startOf('day').subtract(31, 'days'), - end: utc - ? moment.utc().endOf('day').subtract(1, 'days') - : moment().endOf('day'), - }; - - case 'last 90 days': - return { - start: utc - ? moment.utc().startOf('day').subtract(90, 'days') - : moment().startOf('day').subtract(91, 'days'), - end: utc - ? moment.utc().endOf('day').subtract(1, 'days') - : moment().endOf('day'), - }; - } - }, [periodId, utc]); - - const handlePeriodChange = (periodId) => setPeriodId(periodId); - - const params = useMemo(() => ({ - start: period.start.toISOString(), - end: period.end.toISOString(), - }), [period]); - - const { value: data } = useEndpointData('engagement-dashboard/users/users-by-time-of-the-day-in-a-week', useMemo(() => params, [params])); - - const [ - dates, - values, - ] = useMemo(() => { - if (!data) { - return []; - } - - const dates = Array.from({ length: utc - ? moment(period.end).diff(period.start, 'days') + 1 - : moment(period.end).diff(period.start, 'days') - 1 }, - (_, i) => moment(period.start).endOf('day').add(utc ? i : i + 1, 'days')); - - const values = Array.from({ length: 24 }, (_, hour) => ({ - hour: String(hour), - ...dates.map((date) => ({ [date.toISOString()]: 0 })) - .reduce((obj, elem) => ({ ...obj, ...elem }), {}), - })); - - const timezoneOffset = moment().utcOffset() / 60; - - for (const { users, hour, day, month, year } of data.week) { - const date = utc - ? moment.utc([year, month - 1, day, hour]) - : moment([year, month - 1, day, hour]).add(timezoneOffset, 'hours'); - - if (utc || (!date.isSame(period.end) && !date.clone().startOf('day').isSame(period.start))) { - values[date.hour()][date.endOf('day').toISOString()] += users; - } - } - - return [ - dates.map((date) => date.toISOString()), - values, - ]; - }, [data, period.end, period.start, utc]); - - const downloadData = () => { - const _data = [ - ['Date', 'Users'], - ...data.week.map(({ - users, - hour, - day, - month, - year, - }) => ({ - date: moment([year, month - 1, day, hour, 0, 0, 0]), - users, - })) - .sort((a, b) => a > b) - .map(({ date, users }) => [date.toISOString(), users]), - ]; - downloadCsvAs(_data, `UsersByTimeOfTheDaySection_start_${ params.start }_end_${ params.end }`); - }; - return
+ + + + {t('Users')} + + + {t('Messages')} + + + {t('Channels')} + + + + + {(tab === 'users' && ) || + (tab === 'messages' && ) || + (tab === 'channels' && )} + + + + ); +}; + +export default EngagementDashboardPage; diff --git a/ee/client/views/admin/engagementDashboard/EngagementDashboardRoute.tsx b/ee/client/views/admin/engagementDashboard/EngagementDashboardRoute.tsx new file mode 100644 index 000000000000..86967d5a1749 --- /dev/null +++ b/ee/client/views/admin/engagementDashboard/EngagementDashboardRoute.tsx @@ -0,0 +1,43 @@ +import React, { ReactElement, useEffect } from 'react'; + +import NotAuthorizedPage from '../../../../../client/components/NotAuthorizedPage'; +import { usePermission } from '../../../../../client/contexts/AuthorizationContext'; +import { useCurrentRoute, useRoute } from '../../../../../client/contexts/RouterContext'; +import EngagementDashboardPage from './EngagementDashboardPage'; + +const isValidTab = (tab: string | undefined): tab is 'users' | 'messages' | 'channels' => + typeof tab === 'string' && ['users', 'messages', 'channels'].includes(tab); + +const EngagementDashboardRoute = (): ReactElement | null => { + const canViewEngagementDashboard = usePermission('view-engagement-dashboard'); + const engagementDashboardRoute = useRoute('engagement-dashboard'); + const [routeName, routeParams] = useCurrentRoute(); + const { tab } = routeParams ?? {}; + + useEffect(() => { + if (routeName !== 'engagement-dashboard') { + return; + } + + if (!isValidTab(tab)) { + engagementDashboardRoute.replace({ tab: 'users' }); + } + }, [routeName, engagementDashboardRoute, tab]); + + if (!isValidTab(tab)) { + return null; + } + + if (!canViewEngagementDashboard) { + return ; + } + + return ( + engagementDashboardRoute.push({ tab })} + /> + ); +}; + +export default EngagementDashboardRoute; diff --git a/ee/client/views/admin/engagementDashboard/Section.tsx b/ee/client/views/admin/engagementDashboard/Section.tsx new file mode 100644 index 000000000000..3029fd2904ff --- /dev/null +++ b/ee/client/views/admin/engagementDashboard/Section.tsx @@ -0,0 +1,30 @@ +import { Box, Flex, InputBox, Margins } from '@rocket.chat/fuselage'; +import React, { ReactElement, ReactNode } from 'react'; + +type SectionProps = { + children?: ReactNode; + title?: ReactNode; + filter?: ReactNode; +}; + +const Section = ({ + children, + title = undefined, + filter = , +}: SectionProps): ReactElement => ( + + + + {title && ( + + {title} + + )} + {filter && {filter}} + + {children} + + +); + +export default Section; diff --git a/ee/client/views/admin/engagementDashboard/channels/ChannelsTab.stories.tsx b/ee/client/views/admin/engagementDashboard/channels/ChannelsTab.stories.tsx new file mode 100644 index 000000000000..b4da7fb6d1df --- /dev/null +++ b/ee/client/views/admin/engagementDashboard/channels/ChannelsTab.stories.tsx @@ -0,0 +1,14 @@ +import { Margins } from '@rocket.chat/fuselage'; +import { Meta, Story } from '@storybook/react'; +import React from 'react'; + +import ChannelsTab from './ChannelsTab'; + +export default { + title: 'admin/engagementDashboard/ChannelsTab', + component: ChannelsTab, + decorators: [(fn) => ], +} as Meta; + +export const Default: Story = () => ; +Default.storyName = 'ChannelsTab'; diff --git a/ee/client/views/admin/engagementDashboard/channels/ChannelsTab.tsx b/ee/client/views/admin/engagementDashboard/channels/ChannelsTab.tsx new file mode 100644 index 000000000000..f2ac3cbd2173 --- /dev/null +++ b/ee/client/views/admin/engagementDashboard/channels/ChannelsTab.tsx @@ -0,0 +1,148 @@ +import { Box, Icon, Margins, Pagination, Skeleton, Table, Tile } from '@rocket.chat/fuselage'; +import moment from 'moment'; +import React, { ReactElement, useMemo, useState } from 'react'; + +import Growth from '../../../../../../client/components/data/Growth'; +import { useTranslation } from '../../../../../../client/contexts/TranslationContext'; +import Section from '../Section'; +import DownloadDataButton from '../data/DownloadDataButton'; +import PeriodSelector from '../data/PeriodSelector'; +import { usePeriodSelectorState } from '../data/usePeriodSelectorState'; +import { useChannelsList } from './useChannelsList'; + +const ChannelsTab = (): ReactElement => { + const [period, periodSelectorProps] = usePeriodSelectorState( + 'last 7 days', + 'last 30 days', + 'last 90 days', + ); + + const t = useTranslation(); + + const [current, setCurrent] = useState(0); + const [itemsPerPage, setItemsPerPage] = useState<25 | 50 | 100>(25); + + const { data } = useChannelsList({ + period, + offset: current, + count: itemsPerPage, + }); + + const channels = useMemo(() => { + if (!data) { + return; + } + + return data?.channels?.map( + ({ room: { t, name, usernames, ts, _updatedAt }, messages, diffFromLastWeek }) => ({ + t, + name: name || usernames?.join(' × '), + createdAt: ts, + updatedAt: _updatedAt, + messagesCount: messages, + messagesVariation: diffFromLastWeek, + }), + ); + }, [data]); + + return ( +
+ + + data?.channels?.map(({ room: { t, name, usernames, ts, _updatedAt }, messages }) => [ + t, + name || usernames?.join(' × '), + messages, + _updatedAt, + ts, + ]) + } + /> + + } + > + + {channels && !channels.length && ( + + {t('No_data_found')} + + )} + {(!channels || channels.length) && ( + + + + {'#'} + {t('Channel')} + {t('Created')} + {t('Last_active')} + {t('Messages_sent')} + + + + {channels && + channels.map( + ({ t, name, createdAt, updatedAt, messagesCount, messagesVariation }, i) => ( + + {i + 1}. + + + {(t === 'd' && ) || + (t === 'c' && ) || + (t === 'p' && )} + + {name} + + {moment(createdAt).format('L')} + {moment(updatedAt).format('L')} + + {messagesCount} {messagesVariation} + + + ), + )} + {!channels && + Array.from({ length: 5 }, (_, i) => ( + + + + + + + + + + + + + + + + + + ))} + +
+ )} + t('Items_per_page:')} + showingResultsLabel={({ count, current, itemsPerPage }): string => + t('Showing_results_of', current + 1, Math.min(current + itemsPerPage, count), count) + } + count={(data && data.total) || 0} + onSetItemsPerPage={setItemsPerPage} + onSetCurrent={setCurrent} + /> +
+
+ ); +}; + +export default ChannelsTab; diff --git a/ee/client/views/admin/engagementDashboard/channels/useChannelsList.ts b/ee/client/views/admin/engagementDashboard/channels/useChannelsList.ts new file mode 100644 index 000000000000..955f8834427c --- /dev/null +++ b/ee/client/views/admin/engagementDashboard/channels/useChannelsList.ts @@ -0,0 +1,38 @@ +import { useQuery } from 'react-query'; + +import { getFromRestApi } from '../../../../lib/getFromRestApi'; +import { getPeriodRange, Period } from '../data/periods'; + +type UseChannelsListOptions = { + period: Period['key']; + offset: number; + count: number; +}; + +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +export const useChannelsList = ({ period, offset, count }: UseChannelsListOptions) => + useQuery( + ['admin/engagement-dashboard/channels/list', { period, offset, count }], + async () => { + const { start, end } = getPeriodRange(period); + + const response = await getFromRestApi('/v1/engagement-dashboard/channels/list')({ + start: start.toISOString(), + end: end.toISOString(), + offset, + count, + }); + + return response + ? { + ...response, + start, + end, + } + : undefined; + }, + { + keepPreviousData: true, + refetchInterval: 5 * 60 * 1000, + }, + ); diff --git a/ee/client/views/admin/engagementDashboard/data/DownloadDataButton.tsx b/ee/client/views/admin/engagementDashboard/data/DownloadDataButton.tsx new file mode 100644 index 000000000000..13a9d6da21d7 --- /dev/null +++ b/ee/client/views/admin/engagementDashboard/data/DownloadDataButton.tsx @@ -0,0 +1,60 @@ +import { Box, ActionButton } from '@rocket.chat/fuselage'; +import React, { ComponentProps, ReactElement } from 'react'; + +import { useToastMessageDispatch } from '../../../../../../client/contexts/ToastMessagesContext'; +import { useTranslation } from '../../../../../../client/contexts/TranslationContext'; +import { downloadCsvAs } from '../../../../../../client/lib/download'; + +type RowFor = readonly unknown[] & { + length: THeaders['length']; +}; + +type DownloadDataButtonProps = { + attachmentName: string; + headers: RowFor; + dataAvailable: boolean; + dataExtractor: () => Promise[] | undefined> | RowFor[] | undefined; +} & Omit, 'attachmentName' | 'headers' | 'data'>; + +const DownloadDataButton = ({ + attachmentName, + headers, + dataAvailable, + dataExtractor, + ...props +}: DownloadDataButtonProps): ReactElement => { + const t = useTranslation(); + const dispatchToastMessage = useToastMessageDispatch(); + + const handleClick = (): void => { + if (!dataAvailable) { + return; + } + + Promise.resolve(dataExtractor()) + .then((data) => { + if (!data) { + return; + } + + downloadCsvAs([headers, ...data], attachmentName); + }) + .catch((error) => { + dispatchToastMessage({ type: 'error', message: error }); + }); + }; + + return ( + + ); +}; + +export default DownloadDataButton; diff --git a/ee/client/views/admin/engagementDashboard/data/LegendSymbol.stories.tsx b/ee/client/views/admin/engagementDashboard/data/LegendSymbol.stories.tsx new file mode 100644 index 000000000000..2252e1b6c922 --- /dev/null +++ b/ee/client/views/admin/engagementDashboard/data/LegendSymbol.stories.tsx @@ -0,0 +1,34 @@ +import { Box, Margins } from '@rocket.chat/fuselage'; +import { Meta, Story } from '@storybook/react'; +import React, { ReactElement } from 'react'; + +import LegendSymbol from './LegendSymbol'; +import { monochromaticColors, polychromaticColors } from './colors'; + +export default { + title: 'admin/engagementDashboard/data/LegendSymbol', + component: LegendSymbol, + decorators: [(fn): ReactElement => ], +} as Meta; + +export const withoutColor: Story = () => ( + + + Legend text + +); + +export const withColor: Story = () => ( + <> + {monochromaticColors.map((color) => ( + + {color} + + ))} + {polychromaticColors.map((color) => ( + + {color} + + ))} + +); diff --git a/ee/client/views/admin/engagementDashboard/data/LegendSymbol.tsx b/ee/client/views/admin/engagementDashboard/data/LegendSymbol.tsx new file mode 100644 index 000000000000..8a688eb26d33 --- /dev/null +++ b/ee/client/views/admin/engagementDashboard/data/LegendSymbol.tsx @@ -0,0 +1,25 @@ +import { Box, Margins } from '@rocket.chat/fuselage'; +import React, { CSSProperties, ReactElement } from 'react'; + +type LegendSymbolProps = { + color?: CSSProperties['backgroundColor']; +}; + +const LegendSymbol = ({ color = 'currentColor' }: LegendSymbolProps): ReactElement => ( + + +); + +export default LegendSymbol; diff --git a/ee/client/views/admin/engagementDashboard/data/PeriodSelector.tsx b/ee/client/views/admin/engagementDashboard/data/PeriodSelector.tsx new file mode 100644 index 000000000000..e1c7b59e1bbf --- /dev/null +++ b/ee/client/views/admin/engagementDashboard/data/PeriodSelector.tsx @@ -0,0 +1,34 @@ +import { Select } from '@rocket.chat/fuselage'; +import React, { ReactElement, useMemo } from 'react'; + +import { useTranslation } from '../../../../../../client/contexts/TranslationContext'; +import { getPeriod, Period } from './periods'; + +type PeriodSelectorProps = { + periods: TPeriod[]; + value: TPeriod; + onChange: (value: TPeriod) => void; +}; + +const PeriodSelector = ({ + periods, + value, + onChange, +}: PeriodSelectorProps): ReactElement => { + const t = useTranslation(); + + const options = useMemo<[string, string][]>( + () => periods.map((period) => [period, t(...getPeriod(period).label)]), + [periods, t], + ); + + return ( +