[IMPROVE] Detach React components from Meteor API (#15482)
parent
aa8c80644d
commit
b5114edff1
@ -0,0 +1,13 @@ |
||||
{ |
||||
"presets": [ |
||||
[ |
||||
"@babel/preset-env", |
||||
{ |
||||
"shippedProposals": true, |
||||
"useBuiltIns": "usage", |
||||
"corejs": "3" |
||||
} |
||||
], |
||||
"@babel/preset-react" |
||||
] |
||||
} |
@ -0,0 +1,2 @@ |
||||
import '@storybook/addon-actions/register'; |
||||
import '@storybook/addon-links/register'; |
@ -0,0 +1,3 @@ |
||||
import { configure } from '@storybook/react'; |
||||
|
||||
configure(require.context('../client', true, /\.stories\.js$/), module); |
@ -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) => |
||||
<ConnectionStatusProvider connected status='connected' reconnect={action('reconnect')}> |
||||
<TranslationProvider> |
||||
<style>{` |
||||
body { |
||||
background-color: white; |
||||
} |
||||
|
||||
.global-font-family { |
||||
font-family: |
||||
-apple-system, |
||||
BlinkMacSystemFont, |
||||
'Segoe UI', |
||||
Roboto, |
||||
Oxygen, |
||||
Ubuntu, |
||||
Cantarell, |
||||
'Helvetica Neue', |
||||
'Apple Color Emoji', |
||||
'Segoe UI Emoji', |
||||
'Segoe UI Symbol', |
||||
'Meiryo UI', |
||||
Arial, |
||||
sans-serif; |
||||
} |
||||
|
||||
.color-primary-font-color { |
||||
color: #444; |
||||
} |
||||
`}</style>
|
||||
<div dangerouslySetInnerHTML={{__html: require('!!raw-loader!../private/public/icons.svg').default}} /> |
||||
<div className='global-font-family color-primary-font-color'> |
||||
{fn()} |
||||
</div> |
||||
</TranslationProvider> |
||||
</ConnectionStatusProvider>; |
@ -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; |
||||
}; |
@ -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) }`; |
||||
}; |
@ -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 = () => <Button>Button</Button>; |
||||
|
||||
export const invisible = () => <Button invisible>Button</Button>; |
||||
|
||||
export const primary = () => <Button primary>Button</Button>; |
||||
|
||||
export const secondary = () => <Button secondary>Button</Button>; |
@ -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 = () => <ErrorAlert>Content</ErrorAlert>; |
||||
|
||||
export const withTitle = () => <ErrorAlert title='Title'>Content</ErrorAlert>; |
@ -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 = () => <Input />; |
||||
|
||||
export const withTitle = () => <Input title='Title' />; |
||||
|
||||
export const withError = () => <Input error='Error' />; |
||||
|
||||
export const withIcon = () => <Input icon='key' />; |
||||
|
||||
export const withPlaceholder = () => <Input placeholder='Placeholder' />; |
||||
|
||||
export const focused = () => <Input focused />; |
||||
|
||||
export const ofTypeSelect = () => <Input type='select' options={[ |
||||
{ label: 'A', value: 'a' }, |
||||
{ label: 'B', value: 'b' }, |
||||
{ label: 'C', value: 'c' }, |
||||
]} />; |
@ -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 href='#'>Link</Link>; |
||||
|
||||
export const withTitle = () => <Link external href='#'>Content</Link>; |
@ -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 = () => <ConnectionStatusProvider connected status='connected' reconnect={action('reconnect')}> |
||||
<ConnectionStatusAlert /> |
||||
</ConnectionStatusProvider>; |
||||
|
||||
export const connecting = () => <ConnectionStatusProvider status='connecting' reconnect={action('reconnect')}> |
||||
<ConnectionStatusAlert /> |
||||
</ConnectionStatusProvider>; |
||||
|
||||
export const failed = () => <ConnectionStatusProvider status='failed' reconnect={action('reconnect')}> |
||||
<ConnectionStatusAlert /> |
||||
</ConnectionStatusProvider>; |
||||
|
||||
export const waiting = () => <ConnectionStatusProvider status='waiting' retryTime={Date.now() + 300000} reconnect={action('reconnect')}> |
||||
<ConnectionStatusAlert /> |
||||
</ConnectionStatusProvider>; |
||||
|
||||
export const offline = () => <ConnectionStatusProvider status='offline' reconnect={action('reconnect')}> |
||||
<ConnectionStatusAlert /> |
||||
</ConnectionStatusProvider>; |
@ -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; |
@ -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; |
||||
}; |
@ -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); |
@ -0,0 +1,18 @@ |
||||
import { Meteor } from 'meteor/meteor'; |
||||
import React, { useMemo } from 'react'; |
||||
|
||||
import { ConnectionStatusContext } from '../contexts/ConnectionStatusContext'; |
||||
import { useReactiveValue } from '../../hooks/useReactiveValue'; |
||||
|
||||
export function ConnectionStatusProvider({ children }) { |
||||
const status = useReactiveValue(() => ({ ...Meteor.status() })); |
||||
|
||||
const contextValue = useMemo(() => ({ |
||||
status, |
||||
reconnect: Meteor.reconnect, |
||||
}), [status]); |
||||
|
||||
return <ConnectionStatusContext.Provider value={contextValue}> |
||||
{children} |
||||
</ConnectionStatusContext.Provider>; |
||||
} |
@ -0,0 +1,24 @@ |
||||
import React from 'react'; |
||||
|
||||
import { ConnectionStatusContext } from '../contexts/ConnectionStatusContext'; |
||||
|
||||
export function ConnectionStatusProvider({ |
||||
children, |
||||
connected = false, |
||||
status, |
||||
retryTime, |
||||
retryCount = 3, |
||||
reconnect = () => {}, |
||||
}) { |
||||
return <ConnectionStatusContext.Provider value={{ |
||||
status: { |
||||
connected, |
||||
retryCount, |
||||
retryTime, |
||||
status, |
||||
}, |
||||
reconnect, |
||||
}}> |
||||
{children} |
||||
</ConnectionStatusContext.Provider>; |
||||
} |
@ -0,0 +1,15 @@ |
||||
import React from 'react'; |
||||
|
||||
import { RouterProvider } from './RouterProvider'; |
||||
import { ConnectionStatusProvider } from './ConnectionStatusProvider'; |
||||
import { TranslationProvider } from './TranslationProvider'; |
||||
|
||||
export function MeteorProvider({ children }) { |
||||
return <ConnectionStatusProvider> |
||||
<RouterProvider> |
||||
<TranslationProvider> |
||||
{children} |
||||
</TranslationProvider> |
||||
</RouterProvider> |
||||
</ConnectionStatusProvider>; |
||||
} |
@ -0,0 +1,50 @@ |
||||
import { FlowRouter } from 'meteor/kadira:flow-router'; |
||||
import { Tracker } from 'meteor/tracker'; |
||||
import React from 'react'; |
||||
|
||||
import { RouterContext } from '../contexts/RouterContext'; |
||||
|
||||
const navigateTo = (pathDefinition, parameters, queryStringParameters) => { |
||||
FlowRouter.go(pathDefinition, parameters, queryStringParameters); |
||||
}; |
||||
|
||||
const replaceWith = (pathDefinition, parameters, queryStringParameters) => { |
||||
FlowRouter.withReplaceState(() => { |
||||
FlowRouter.go(pathDefinition, parameters, queryStringParameters); |
||||
}); |
||||
}; |
||||
|
||||
const getRouteParameter = (name) => Tracker.nonreactive(() => FlowRouter.getParam(name)); |
||||
|
||||
const watchRouteParameter = (name, subscriber) => { |
||||
const computation = Tracker.autorun(() => { |
||||
subscriber(FlowRouter.getParam(name)); |
||||
}); |
||||
|
||||
return () => computation.stop(); |
||||
}; |
||||
|
||||
const getQueryStringParameter = (name) => Tracker.nonreactive(() => FlowRouter.getQueryParam(name)); |
||||
|
||||
const watchQueryStringParameter = (name, subscriber) => { |
||||
const computation = Tracker.autorun(() => { |
||||
subscriber(FlowRouter.getQueryParam(name)); |
||||
}); |
||||
|
||||
return () => computation.stop(); |
||||
}; |
||||
|
||||
const router = { |
||||
navigateTo, |
||||
replaceWith, |
||||
getRouteParameter, |
||||
watchRouteParameter, |
||||
getQueryStringParameter, |
||||
watchQueryStringParameter, |
||||
}; |
||||
|
||||
export function RouterProvider({ children }) { |
||||
return <RouterContext.Provider value={router}> |
||||
{children} |
||||
</RouterContext.Provider>; |
||||
} |
@ -0,0 +1,44 @@ |
||||
import React, { useMemo } from 'react'; |
||||
import { TAPi18n, TAPi18next } from 'meteor/rocketchat:tap-i18n'; |
||||
|
||||
import { TranslationContext } from '../contexts/TranslationContext'; |
||||
import { useReactiveValue } from '../../hooks/useReactiveValue'; |
||||
|
||||
const createContextValue = (language) => { |
||||
const translate = (key, ...replaces) => { |
||||
if (typeof replaces[0] === 'object') { |
||||
const [options, lang_tag = language] = replaces; |
||||
return TAPi18next.t(key, { |
||||
ns: 'project', |
||||
lng: lang_tag, |
||||
...options, |
||||
}); |
||||
} |
||||
|
||||
if (replaces.length === 0) { |
||||
return TAPi18next.t(key, { ns: 'project', lng: language }); |
||||
} |
||||
|
||||
return TAPi18next.t(key, { |
||||
postProcess: 'sprintf', |
||||
sprintf: replaces, |
||||
ns: 'project', |
||||
lng: language, |
||||
}); |
||||
}; |
||||
|
||||
const has = (key, { lng = language, ...options } = {}) => TAPi18next.exists(key, { ...options, lng }); |
||||
|
||||
translate.has = has; |
||||
return translate; |
||||
}; |
||||
|
||||
export function TranslationProvider({ children }) { |
||||
const language = useReactiveValue(() => TAPi18n.getLanguage()); |
||||
|
||||
const contextValue = useMemo(() => createContextValue(language), [language]); |
||||
|
||||
return <TranslationContext.Provider value={contextValue}> |
||||
{children} |
||||
</TranslationContext.Provider>; |
||||
} |
@ -0,0 +1,55 @@ |
||||
import React, { useEffect, useState } from 'react'; |
||||
import i18next from 'i18next'; |
||||
|
||||
import { TranslationContext } from '../contexts/TranslationContext'; |
||||
|
||||
export function TranslationProvider({ children }) { |
||||
const [contextValue, setContextValue] = useState(); |
||||
|
||||
useEffect(() => { |
||||
const translate = (key, ...replaces) => { |
||||
if (typeof replaces[0] === 'object') { |
||||
const [options] = replaces; |
||||
return i18next.t(key, options); |
||||
} |
||||
|
||||
if (replaces.length === 0) { |
||||
return i18next.t(key); |
||||
} |
||||
|
||||
return i18next.t(key, { |
||||
postProcess: 'sprintf', |
||||
sprintf: replaces, |
||||
}); |
||||
}; |
||||
|
||||
const has = (key) => i18next.exists(key); |
||||
translate.has = has; |
||||
|
||||
const initializeI18next = async () => { |
||||
await i18next.init({ |
||||
fallbackLng: 'en', |
||||
defaultNS: 'project', |
||||
resources: { |
||||
en: { |
||||
project: require('../../../packages/rocketchat-i18n/i18n/en.i18n.json'), |
||||
}, |
||||
}, |
||||
interpolation: { |
||||
prefix: '__', |
||||
suffix: '__', |
||||
}, |
||||
}); |
||||
setContextValue(() => translate); |
||||
}; |
||||
initializeI18next(); |
||||
}, []); |
||||
|
||||
if (!contextValue) { |
||||
return <>{children}</>; |
||||
} |
||||
|
||||
return <TranslationContext.Provider value={contextValue}> |
||||
{children} |
||||
</TranslationContext.Provider>; |
||||
} |
@ -0,0 +1,3 @@ |
||||
import { useQueryStringParameter } from '../components/contexts/RouterContext'; |
||||
|
||||
export const useEmbeddedLayout = () => useQueryStringParameter('layout') === 'embedded'; |
@ -1,78 +0,0 @@ |
||||
import moment from 'moment'; |
||||
import s from 'underscore.string'; |
||||
|
||||
import { useTranslation } from './useTranslation'; |
||||
|
||||
export const useFormatters = () => { |
||||
const formatNumber = (number) => s.numberFormat(number, 2); |
||||
|
||||
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] }`; |
||||
}; |
||||
|
||||
const formatDate = (date) => { |
||||
if (!date) { |
||||
return null; |
||||
} |
||||
|
||||
return moment(date).format('LLL'); |
||||
}; |
||||
|
||||
const t = useTranslation(); |
||||
|
||||
const formatHumanReadableTime = (time) => { |
||||
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; |
||||
}; |
||||
|
||||
const formatCPULoad = (load) => { |
||||
if (!load) { |
||||
return null; |
||||
} |
||||
|
||||
const [oneMinute, fiveMinutes, fifteenMinutes] = load; |
||||
|
||||
return `${ formatNumber(oneMinute) }, ${ formatNumber(fiveMinutes) }, ${ formatNumber(fifteenMinutes) }`; |
||||
}; |
||||
|
||||
return { |
||||
formatNumber, |
||||
formatMemorySize, |
||||
formatDate, |
||||
formatHumanReadableTime, |
||||
formatCPULoad, |
||||
}; |
||||
}; |
@ -1,21 +0,0 @@ |
||||
import { TAPi18n } from 'meteor/rocketchat:tap-i18n'; |
||||
import { Tracker } from 'meteor/tracker'; |
||||
|
||||
import { useReactiveValue } from './useReactiveValue'; |
||||
|
||||
const translator = (key, ...replaces) => Tracker.nonreactive(() => { |
||||
if (typeof replaces[0] === 'object') { |
||||
return TAPi18n.__(key, ...replaces); |
||||
} |
||||
|
||||
return TAPi18n.__(key, { |
||||
postProcess: 'sprintf', |
||||
sprintf: replaces, |
||||
}); |
||||
}); |
||||
|
||||
export const useTranslation = () => { |
||||
useReactiveValue(() => TAPi18n.getLanguage()); |
||||
|
||||
return translator; |
||||
}; |
File diff suppressed because it is too large
Load Diff
Before Width: | Height: | Size: 1.5 KiB |
Before Width: | Height: | Size: 16 KiB |
Loading…
Reference in new issue