diff --git a/.storybook/.babelrc b/.storybook/.babelrc new file mode 100644 index 00000000000..6e8b075e8c4 --- /dev/null +++ b/.storybook/.babelrc @@ -0,0 +1,13 @@ +{ + "presets": [ + [ + "@babel/preset-env", + { + "shippedProposals": true, + "useBuiltIns": "usage", + "corejs": "3" + } + ], + "@babel/preset-react" + ] +} \ No newline at end of file diff --git a/.storybook/addons.js b/.storybook/addons.js new file mode 100644 index 00000000000..6aed412d04a --- /dev/null +++ b/.storybook/addons.js @@ -0,0 +1,2 @@ +import '@storybook/addon-actions/register'; +import '@storybook/addon-links/register'; diff --git a/.storybook/config.js b/.storybook/config.js new file mode 100644 index 00000000000..984a7b25e28 --- /dev/null +++ b/.storybook/config.js @@ -0,0 +1,3 @@ +import { configure } from '@storybook/react'; + +configure(require.context('../client', true, /\.stories\.js$/), module); diff --git a/.storybook/helpers.js b/.storybook/helpers.js new file mode 100644 index 00000000000..00ad512dc47 --- /dev/null +++ b/.storybook/helpers.js @@ -0,0 +1,44 @@ +import { action } from '@storybook/addon-actions'; +import '@rocket.chat/icons/dist/font/RocketChat.minimal.css'; +import React from 'react'; + +import '../app/theme/client/main.css'; +import { ConnectionStatusProvider } from '../client/components/providers/ConnectionStatusProvider.mock'; +import { TranslationProvider } from '../client/components/providers/TranslationProvider.mock'; + +export const rocketChatWrapper = (fn) => + + + +
+
+ {fn()} +
+ +; diff --git a/.storybook/webpack.config.js b/.storybook/webpack.config.js new file mode 100644 index 00000000000..b9ededccd61 --- /dev/null +++ b/.storybook/webpack.config.js @@ -0,0 +1,22 @@ +'use strict'; + +module.exports = async ({ config, mode }) => { + const cssRule = config.module.rules.find(({ test }) => test.test('index.css')); + cssRule.use[1].options.url = (url, resourcePath) => { + if (/^(\.\/)?images\//.test(url)) { + return false; + } + + return true; + }; + + cssRule.use[2].options.plugins = [ + require('postcss-custom-properties')({ preserve: true }), + require('postcss-media-minmax')(), + require('postcss-selector-not')(), + require('postcss-nested')(), + require('autoprefixer')(), + ]; + + return config; +}; diff --git a/app/theme/client/imports/general/base_old.css b/app/theme/client/imports/general/base_old.css index f576907d6e0..7254ac8b6cf 100644 --- a/app/theme/client/imports/general/base_old.css +++ b/app/theme/client/imports/general/base_old.css @@ -4914,23 +4914,6 @@ rc-old select, top: 19px; } -.rc-old .one-passsword { - position: absolute; - top: -5px; - right: -8px; - - float: right; - - width: 40px; - height: 40px; - - opacity: 0.6; - background-image: url('/images/onepassword-button.png'); - background-repeat: no-repeat; - background-position: center; - background-size: 23px; -} - .rc-old .collapse-switch { cursor: pointer; } diff --git a/client/components/admin/info/BuildEnvironmentSection.js b/client/components/admin/info/BuildEnvironmentSection.js index 32c046a6029..22163c75ec2 100644 --- a/client/components/admin/info/BuildEnvironmentSection.js +++ b/client/components/admin/info/BuildEnvironmentSection.js @@ -1,13 +1,12 @@ import React from 'react'; -import { useTranslation } from '../../../hooks/useTranslation'; -import { useFormatters } from '../../../hooks/useFormatters'; +import { useTranslation } from '../../contexts/TranslationContext'; import { InformationList } from './InformationList'; import { InformationEntry } from './InformationEntry'; +import { formatDate } from './formatters'; export function BuildEnvironmentSection({ info }) { const t = useTranslation(); - const { formatDate } = useFormatters(); const build = info && (info.compile || info.build); return <> diff --git a/client/components/admin/info/CommitSection.js b/client/components/admin/info/CommitSection.js index e7d3570e520..f814bc5c703 100644 --- a/client/components/admin/info/CommitSection.js +++ b/client/components/admin/info/CommitSection.js @@ -1,6 +1,6 @@ import React from 'react'; -import { useTranslation } from '../../../hooks/useTranslation'; +import { useTranslation } from '../../contexts/TranslationContext'; import { InformationList } from './InformationList'; import { InformationEntry } from './InformationEntry'; diff --git a/client/components/admin/info/InformationPage.js b/client/components/admin/info/InformationPage.js index 6e1a256d6d8..cdcc1280651 100644 --- a/client/components/admin/info/InformationPage.js +++ b/client/components/admin/info/InformationPage.js @@ -3,13 +3,13 @@ import React, { useEffect, useState } from 'react'; import { call } from '../../../../app/ui-utils/client/lib/callMethod'; import { useViewStatisticsPermission } from '../../../hooks/usePermissions'; -import { useTranslation } from '../../../hooks/useTranslation'; import { useReactiveValue } from '../../../hooks/useReactiveValue'; import { Info } from '../../../../app/utils'; import { SideNav } from '../../../../app/ui-utils/client/lib/SideNav'; import { Header } from '../../header/Header'; import { Link } from '../../basic/Link'; import { ErrorAlert } from '../../basic/ErrorAlert'; +import { useTranslation } from '../../contexts/TranslationContext'; import { RocketChatSection } from './RocketChatSection'; import { CommitSection } from './CommitSection'; import { RuntimeEnvironmentSection } from './RuntimeEnvironmentSection'; @@ -103,7 +103,7 @@ export function InformationPage() { {canViewStatistics &&
} diff --git a/client/components/admin/info/InstancesSection.js b/client/components/admin/info/InstancesSection.js index f28ee39b018..7e82305f0cd 100644 --- a/client/components/admin/info/InstancesSection.js +++ b/client/components/admin/info/InstancesSection.js @@ -1,13 +1,12 @@ import React from 'react'; -import { useTranslation } from '../../../hooks/useTranslation'; -import { useFormatters } from '../../../hooks/useFormatters'; +import { formatDate } from './formatters'; +import { useTranslation } from '../../contexts/TranslationContext'; import { InformationList } from './InformationList'; import { InformationEntry } from './InformationEntry'; export function InstancesSection({ instances }) { const t = useTranslation(); - const { formatDate } = useFormatters(); if (!instances || !instances.length) { return null; diff --git a/client/components/admin/info/RocketChatSection.js b/client/components/admin/info/RocketChatSection.js index 7dd273c37b5..7c10d79f66f 100644 --- a/client/components/admin/info/RocketChatSection.js +++ b/client/components/admin/info/RocketChatSection.js @@ -1,15 +1,14 @@ import React from 'react'; -import { useTranslation } from '../../../hooks/useTranslation'; +import { useTranslation } from '../../contexts/TranslationContext'; import { SkeletonText } from './SkeletonText'; -import { useFormatters } from '../../../hooks/useFormatters'; +import { formatDate, formatHumanReadableTime } from './formatters'; import { InformationList } from './InformationList'; import { InformationEntry } from './InformationEntry'; export function RocketChatSection({ info, statistics, isLoading }) { const s = (fn) => (isLoading ? : fn()); const t = useTranslation(); - const { formatDate, formatHumanReadableTime } = useFormatters(); const appsEngineVersion = info.marketplaceApiVersion; @@ -25,7 +24,7 @@ export function RocketChatSection({ info, statistics, isLoading }) { {s(() => statistics.migration.version)} {s(() => formatDate(statistics.migration.lockedAt))} {s(() => formatDate(statistics.installedAt))} - {s(() => formatHumanReadableTime(statistics.process.uptime))} + {s(() => formatHumanReadableTime(statistics.process.uptime, t))} {s(() => statistics.uniqueId)} {s(() => statistics.process.pid)} {s(() => statistics.instanceCount)} diff --git a/client/components/admin/info/RuntimeEnvironmentSection.js b/client/components/admin/info/RuntimeEnvironmentSection.js index 9d3d86cc20c..01c2a9ff78e 100644 --- a/client/components/admin/info/RuntimeEnvironmentSection.js +++ b/client/components/admin/info/RuntimeEnvironmentSection.js @@ -1,15 +1,14 @@ import React from 'react'; -import { useTranslation } from '../../../hooks/useTranslation'; +import { useTranslation } from '../../contexts/TranslationContext'; import { SkeletonText } from './SkeletonText'; -import { useFormatters } from '../../../hooks/useFormatters'; +import { formatMemorySize, formatHumanReadableTime, formatCPULoad } from './formatters'; import { InformationList } from './InformationList'; import { InformationEntry } from './InformationEntry'; export function RuntimeEnvironmentSection({ statistics, isLoading }) { const s = (fn) => (isLoading ? : fn()); const t = useTranslation(); - const { formatMemorySize, formatHumanReadableTime, formatCPULoad } = useFormatters(); if (!statistics) { return null; @@ -25,7 +24,7 @@ export function RuntimeEnvironmentSection({ statistics, isLoading }) { {s(() => statistics.process.nodeVersion)} {s(() => statistics.mongoVersion)} {s(() => statistics.mongoStorageEngine)} - {s(() => formatHumanReadableTime(statistics.os.uptime))} + {s(() => formatHumanReadableTime(statistics.os.uptime, t))} {s(() => formatCPULoad(statistics.os.loadavg))} {s(() => formatMemorySize(statistics.os.totalmem))} {s(() => formatMemorySize(statistics.os.freemem))} diff --git a/client/components/admin/info/UsageSection.js b/client/components/admin/info/UsageSection.js index 7ea5ee0e22a..64b76ea0d35 100644 --- a/client/components/admin/info/UsageSection.js +++ b/client/components/admin/info/UsageSection.js @@ -1,15 +1,14 @@ import React from 'react'; -import { useTranslation } from '../../../hooks/useTranslation'; +import { useTranslation } from '../../contexts/TranslationContext'; import { SkeletonText } from './SkeletonText'; -import { useFormatters } from '../../../hooks/useFormatters'; +import { formatMemorySize } from './formatters'; import { InformationList } from './InformationList'; import { InformationEntry } from './InformationEntry'; export function UsageSection({ statistics, isLoading }) { const s = (fn) => (isLoading ? : fn()); const t = useTranslation(); - const { formatMemorySize } = useFormatters(); if (!statistics) { return null; diff --git a/client/components/admin/info/formatters.js b/client/components/admin/info/formatters.js new file mode 100644 index 00000000000..db8c147d284 --- /dev/null +++ b/client/components/admin/info/formatters.js @@ -0,0 +1,64 @@ +import moment from 'moment'; +import s from 'underscore.string'; + +export const formatNumber = (number) => s.numberFormat(number, 2); + +export const formatMemorySize = (memorySize) => { + if (typeof memorySize !== 'number') { + return null; + } + + const units = ['bytes', 'kB', 'MB', 'GB']; + + let order; + for (order = 0; order < units.length - 1; ++order) { + const upperLimit = Math.pow(1024, order + 1); + + if (memorySize < upperLimit) { + break; + } + } + + const divider = Math.pow(1024, order); + const decimalDigits = order === 0 ? 0 : 2; + return `${ s.numberFormat(memorySize / divider, decimalDigits) } ${ units[order] }`; +}; + +export const formatDate = (date) => { + if (!date) { + return null; + } + + return moment(date).format('LLL'); +}; + +export const formatHumanReadableTime = (time, t) => { + const days = Math.floor(time / 86400); + const hours = Math.floor((time % 86400) / 3600); + const minutes = Math.floor(((time % 86400) % 3600) / 60); + const seconds = Math.floor(((time % 86400) % 3600) % 60); + let out = ''; + if (days > 0) { + out += `${ days } ${ t('days') }, `; + } + if (hours > 0) { + out += `${ hours } ${ t('hours') }, `; + } + if (minutes > 0) { + out += `${ minutes } ${ t('minutes') }, `; + } + if (seconds > 0) { + out += `${ seconds } ${ t('seconds') }`; + } + return out; +}; + +export const formatCPULoad = (load) => { + if (!load) { + return null; + } + + const [oneMinute, fiveMinutes, fifteenMinutes] = load; + + return `${ formatNumber(oneMinute) }, ${ formatNumber(fiveMinutes) }, ${ formatNumber(fifteenMinutes) }`; +}; diff --git a/client/components/basic/Button.stories.js b/client/components/basic/Button.stories.js new file mode 100644 index 00000000000..27428a33ba6 --- /dev/null +++ b/client/components/basic/Button.stories.js @@ -0,0 +1,20 @@ +import React from 'react'; + +import { rocketChatWrapper } from '../../../.storybook/helpers'; +import { Button } from './Button'; + +export default { + title: 'basic/Button', + component: Button, + decorators: [ + rocketChatWrapper, + ], +}; + +export const _default = () => ; + +export const invisible = () => ; + +export const primary = () => ; + +export const secondary = () => ; diff --git a/client/components/basic/ErrorAlert.stories.js b/client/components/basic/ErrorAlert.stories.js new file mode 100644 index 00000000000..a5b8731620e --- /dev/null +++ b/client/components/basic/ErrorAlert.stories.js @@ -0,0 +1,16 @@ +import React from 'react'; + +import { rocketChatWrapper } from '../../../.storybook/helpers'; +import { ErrorAlert } from './ErrorAlert'; + +export default { + title: 'basic/ErrorAlert', + component: ErrorAlert, + decorators: [ + rocketChatWrapper, + ], +}; + +export const _default = () => Content; + +export const withTitle = () => Content; diff --git a/client/components/basic/Input.stories.js b/client/components/basic/Input.stories.js new file mode 100644 index 00000000000..43d1885c743 --- /dev/null +++ b/client/components/basic/Input.stories.js @@ -0,0 +1,30 @@ +import React from 'react'; + +import { rocketChatWrapper } from '../../../.storybook/helpers'; +import { Input } from './Input'; + +export default { + title: 'basic/Input', + component: Input, + decorators: [ + rocketChatWrapper, + ], +}; + +export const _default = () => ; + +export const withTitle = () => ; + +export const withError = () => ; + +export const withIcon = () => ; + +export const withPlaceholder = () => ; + +export const focused = () => ; + +export const ofTypeSelect = () => ; diff --git a/client/components/basic/Link.stories.js b/client/components/basic/Link.stories.js new file mode 100644 index 00000000000..6423435215e --- /dev/null +++ b/client/components/basic/Link.stories.js @@ -0,0 +1,16 @@ +import React from 'react'; + +import { rocketChatWrapper } from '../../../.storybook/helpers'; +import { Link } from './Link'; + +export default { + title: 'basic/Link', + component: Link, + decorators: [ + rocketChatWrapper, + ], +}; + +export const _default = () => Link; + +export const withTitle = () => Content; diff --git a/client/components/connectionStatus/ConnectionStatusAlert.js b/client/components/connectionStatus/ConnectionStatusAlert.js index 387ec9bc355..24bb99718b1 100644 --- a/client/components/connectionStatus/ConnectionStatusAlert.js +++ b/client/components/connectionStatus/ConnectionStatusAlert.js @@ -1,16 +1,17 @@ -import { Meteor } from 'meteor/meteor'; +import { Icon } from '@rocket.chat/fuselage'; import React, { useEffect, useRef, useState } from 'react'; -import { useReactiveValue } from '../../hooks/useReactiveValue'; -import { useTranslation } from '../../hooks/useTranslation'; -import { Icon } from '../basic/Icon'; +import { useConnectionStatus, useReconnect } from '../contexts/ConnectionStatusContext'; +import { useTranslation } from '../contexts/TranslationContext'; +import './ConnectionStatusAlert.css'; export function ConnectionStatusAlert() { const { connected, retryTime, status, - } = useReactiveValue(() => ({ ...Meteor.status() })); + } = useConnectionStatus(); + const reconnect = useReconnect(); const reconnectionTimerRef = useRef(); const [reconnectCountdown, setReconnectCountdown] = useState(0); const t = useTranslation(); @@ -42,12 +43,12 @@ export function ConnectionStatusAlert() { const handleRetryClick = (event) => { event.preventDefault(); - Meteor.reconnect(); + reconnect(); }; return
- {t('meteor_status', { context: status })} + {t('meteor_status', { context: status })} {status === 'waiting' && <> diff --git a/client/components/connectionStatus/ConnectionStatusAlert.stories.js b/client/components/connectionStatus/ConnectionStatusAlert.stories.js new file mode 100644 index 00000000000..896d6376c8e --- /dev/null +++ b/client/components/connectionStatus/ConnectionStatusAlert.stories.js @@ -0,0 +1,34 @@ +import { action } from '@storybook/addon-actions'; +import React from 'react'; + +import { rocketChatWrapper } from '../../../.storybook/helpers'; +import { ConnectionStatusAlert } from './ConnectionStatusAlert'; +import { ConnectionStatusProvider } from '../providers/ConnectionStatusProvider.mock'; + +export default { + title: 'connectionStatus/ConnectionStatusAlert', + component: ConnectionStatusAlert, + decorators: [ + rocketChatWrapper, + ], +}; + +export const connected = () => + +; + +export const connecting = () => + +; + +export const failed = () => + +; + +export const waiting = () => + +; + +export const offline = () => + +; diff --git a/client/components/contexts/ConnectionStatusContext.js b/client/components/contexts/ConnectionStatusContext.js new file mode 100644 index 00000000000..a8f33e99a4d --- /dev/null +++ b/client/components/contexts/ConnectionStatusContext.js @@ -0,0 +1,14 @@ +import { createContext, useContext } from 'react'; + +export const ConnectionStatusContext = createContext({ + status: { + connected: true, + status: 'connected', + retryCount: 0, + }, + reconnect: () => {}, +}); + +export const useConnectionStatus = () => useContext(ConnectionStatusContext).status; + +export const useReconnect = () => useContext(ConnectionStatusContext).reconnect; diff --git a/client/components/contexts/RouterContext.js b/client/components/contexts/RouterContext.js new file mode 100644 index 00000000000..bc9d41d31c5 --- /dev/null +++ b/client/components/contexts/RouterContext.js @@ -0,0 +1,38 @@ +import { createContext, useContext, useEffect, useMemo, useState } from 'react'; + +export const RouterContext = createContext({ + navigateTo: () => {}, + replaceWith: () => {}, + getRouteParameter: () => {}, + watchRouteParameter: () => {}, + getQueryStringParameter: () => {}, + watchQueryStringParameter: () => {}, +}); + +export const useRoute = (pathDefinition) => { + const { navigateTo, replaceWith } = useContext(RouterContext); + + return useMemo(() => { + const navigate = (...args) => navigateTo(pathDefinition, ...args); + navigate.replacingState = (...args) => replaceWith(pathDefinition, ...args); + return navigate; + }, [navigateTo, replaceWith]); +}; + +export const useRouteParameter = (name) => { + const { getRouteParameter, watchRouteParameter } = useContext(RouterContext); + const [parameter, setParameter] = useState(getRouteParameter(name)); + + useEffect(() => watchRouteParameter(name, setParameter), [watchRouteParameter, name]); + + return parameter; +}; + +export const useQueryStringParameter = (name) => { + const { getQueryStringParameter, watchQueryStringParameter } = useContext(RouterContext); + const [parameter, setParameter] = useState(getQueryStringParameter(name)); + + useEffect(() => watchQueryStringParameter(name, setParameter), [watchQueryStringParameter, name]); + + return parameter; +}; diff --git a/client/components/contexts/TranslationContext.js b/client/components/contexts/TranslationContext.js new file mode 100644 index 00000000000..67986658f6a --- /dev/null +++ b/client/components/contexts/TranslationContext.js @@ -0,0 +1,11 @@ +import { createContext, useContext } from 'react'; + +const translate = function(key) { + return key; +}; + +translate.has = () => true; + +export const TranslationContext = createContext(translate); + +export const useTranslation = () => useContext(TranslationContext); diff --git a/client/components/header/BurgerMenuButton.js b/client/components/header/BurgerMenuButton.js index d5f396bbd4c..42ade4c25f5 100644 --- a/client/components/header/BurgerMenuButton.js +++ b/client/components/header/BurgerMenuButton.js @@ -1,18 +1,21 @@ import React, { useMemo } from 'react'; import { ChatSubscription } from '../../../app/models/client/models/ChatSubscription'; -import { Layout } from '../../../app/ui-utils/client/lib/Layout'; -import { useSession } from '../../hooks/useSession'; +import { menu } from '../../../app/ui-utils/client/lib/menu'; +import { useEmbeddedLayout } from '../../hooks/useEmbeddedLayout'; import { useReactiveValue } from '../../hooks/useReactiveValue'; +import { useSession } from '../../hooks/useSession'; import { useUserPreference } from '../../hooks/useUserPreference'; -import { menu } from '../../../app/ui-utils/client/lib/menu'; import './BurgerMenuButton.css'; -const useBurgerMenuState = () => { - const isMenuOpen = useSession('isMenuOpen'); - const isLayoutEmbedded = useReactiveValue(() => Layout.isEmbedded(), []); +const useSidebarState = () => { + const isOpen = useSession('isMenuOpen'); + const toggle = () => menu.toggle(); + return [isOpen, toggle]; +}; +const useUnreadMessagesBadge = () => { const alertUnreadMessages = useUserPreference('unreadAlert') !== false; const openedRoom = useSession('openedRoom'); const [unreadCount, unreadAlert] = useReactiveValue(() => ChatSubscription @@ -41,30 +44,30 @@ const useBurgerMenuState = () => { return [unreadCount, unreadAlert]; }, [0, false]), [openedRoom, alertUnreadMessages]); - const unreadBadge = useMemo(() => { + return useMemo(() => { if (unreadCount > 0) { return unreadCount > 99 ? '99+' : unreadCount.toString(10); } return unreadAlert || ''; }, [unreadCount, unreadAlert]); - - return { isMenuOpen, isLayoutEmbedded, unreadBadge }; }; export function BurgerMenuButton() { - const { isMenuOpen, isLayoutEmbedded, unreadBadge } = useBurgerMenuState(); + const [isSidebarOpen, toggleSidebarOpen] = useSidebarState(); + const isLayoutEmbedded = useEmbeddedLayout(); + const unreadMessagesBadge = useUnreadMessagesBadge(); const handleClick = () => { - menu.toggle(); + toggleSidebarOpen(); }; return