From 395470547084cdfb1266f63c68582f56e1f720f5 Mon Sep 17 00:00:00 2001 From: Miralem Drek Date: Fri, 13 Dec 2019 13:25:47 +0100 Subject: [PATCH] feat: common instance context (#238) --- apis/locale/src/translator.js | 7 +- .../__tests__/unit/selectiontoolbar.spec.jsx | 16 +- apis/nucleus/src/__tests__/app-theme.spec.js | 16 +- apis/nucleus/src/__tests__/nucleus.spec.js | 48 +++- apis/nucleus/src/app-theme.js | 4 +- apis/nucleus/src/components/Cell.jsx | 6 +- .../src/components/LongRunningQuery.jsx | 4 +- apis/nucleus/src/components/NebulaApp.jsx | 45 ++-- .../src/components/SelectionToolbar.jsx | 4 +- apis/nucleus/src/components/Supernova.jsx | 19 +- .../src/components/__tests__/cell.spec.jsx | 8 +- .../__tests__/long-running-query.spec.jsx | 8 +- .../components/__tests__/nebulaapp.spec.jsx | 20 +- .../__tests__/selectiontoolbar.spec.jsx | 8 +- .../components/__tests__/supernova.spec.jsx | 26 +- .../src/components/listbox/ListBoxPopover.jsx | 8 +- .../src/components/listbox/ListBoxSearch.jsx | 4 +- .../__tests__/list-box-search.spec.jsx | 24 +- .../src/components/selections/MultiState.jsx | 4 +- .../nucleus/src/components/selections/Nav.jsx | 4 +- .../src/components/selections/OneField.jsx | 4 +- .../selections/__tests__/one-field.spec.jsx | 20 +- apis/nucleus/src/contexts/DirectionContext.js | 5 - apis/nucleus/src/contexts/InstanceContext.js | 8 + apis/nucleus/src/contexts/LocaleContext.js | 5 - apis/nucleus/src/index.js | 76 ++++-- apis/nucleus/src/locale/app-locale.js | 2 +- apis/snapshooter/src/renderer.js | 4 +- commands/serve/web/components/App.jsx | 254 ++++++++++-------- .../serve/web/contexts/DirectionContext.js | 5 - commands/serve/web/eRender.js | 12 +- packages/ui/icons/index.js | 3 +- test/mashup/snaps/configured.js | 4 +- 33 files changed, 389 insertions(+), 296 deletions(-) delete mode 100644 apis/nucleus/src/contexts/DirectionContext.js create mode 100644 apis/nucleus/src/contexts/InstanceContext.js delete mode 100644 apis/nucleus/src/contexts/LocaleContext.js delete mode 100644 commands/serve/web/contexts/DirectionContext.js diff --git a/apis/locale/src/translator.js b/apis/locale/src/translator.js index dde4a89aa..0f5f7a117 100644 --- a/apis/locale/src/translator.js +++ b/apis/locale/src/translator.js @@ -6,13 +6,16 @@ const format = (message = '', args = []) => { export default function translator({ initial = 'en-US', fallback = 'en-US' } = {}) { const dictionaries = {}; - const currentLocale = initial; + let currentLocale = initial; /** * @interface Translator */ const api = { - language: () => { + language: lang => { + if (lang) { + currentLocale = lang; + } return currentLocale; }, /** diff --git a/apis/nucleus/__tests__/unit/selectiontoolbar.spec.jsx b/apis/nucleus/__tests__/unit/selectiontoolbar.spec.jsx index 70bf86420..87b4dacac 100644 --- a/apis/nucleus/__tests__/unit/selectiontoolbar.spec.jsx +++ b/apis/nucleus/__tests__/unit/selectiontoolbar.spec.jsx @@ -24,11 +24,11 @@ describe('', () => { }, }; const STItem = () => ''; - const LocaleContext = React.createContext(); + const InstanceContext = React.createContext(); const [{ default: STB }] = aw.mock( [ ['**/SelectionToolbarItem.jsx', () => STItem], - ['**/LocaleContext.js', () => LocaleContext], + ['**/InstanceContext.js', () => InstanceContext], ], ['../../src/components/SelectionToolbar'] ); @@ -42,9 +42,9 @@ describe('', () => { translator.get.withArgs('Selection.Clear').returns('localized clear'); const c = renderer.create( - + - + ); items = c.root.findAllByType(STItem); @@ -113,11 +113,11 @@ describe('', () => { selectionToolbar: { items: [{ key: 'mine' }] }, }, }; - const LocaleContext = React.createContext(); + const InstanceContext = React.createContext(); const [{ default: STB }] = aw.mock( [ ['**/SelectionToolbarItem.jsx', () => () => ''], - ['**/LocaleContext.js', () => LocaleContext], + ['**/InstanceContext.js', () => InstanceContext], ], ['../../src/components/SelectionToolbar'] ); @@ -127,9 +127,9 @@ describe('', () => { }; const c = renderer.create( - + - + ); expect(c.toJSON()).to.deep.eql(['', '', '']); diff --git a/apis/nucleus/src/__tests__/app-theme.spec.js b/apis/nucleus/src/__tests__/app-theme.spec.js index 247fba45b..ff06ec8a6 100644 --- a/apis/nucleus/src/__tests__/app-theme.spec.js +++ b/apis/nucleus/src/__tests__/app-theme.spec.js @@ -30,7 +30,7 @@ describe('app-theme', () => { describe('custom', () => { it('should load and apply custom theme', async () => { - const root = { theme: sandbox.spy() }; + const root = { setMuiThemeName: sandbox.spy() }; const at = appThemeFn({ root, logger, @@ -46,7 +46,7 @@ describe('app-theme', () => { ], }); await at.setTheme('darkish'); - expect(root.theme).to.have.been.calledWithExactly('dark'); + expect(root.setMuiThemeName).to.have.been.calledWithExactly('dark'); expect(internalAPI.setTheme).to.have.been.calledWithExactly({ type: 'dark', color: 'red', @@ -54,7 +54,7 @@ describe('app-theme', () => { }); it('should timeout after 5sec', async () => { - const root = { theme: sinon.spy() }; + const root = { setMuiThemeName: sinon.spy() }; const at = appThemeFn({ root, logger, @@ -76,21 +76,21 @@ describe('app-theme', () => { describe('defaults', () => { it('should apply light theme on React root when themeName is not found', () => { - const root = { theme: sinon.spy() }; + const root = { setMuiThemeName: sinon.spy() }; const at = appThemeFn({ root }); at.setTheme('foo'); - expect(root.theme).to.have.been.calledWithExactly('light'); + expect(root.setMuiThemeName).to.have.been.calledWithExactly('light'); }); it('should apply dark theme on React root when themename is "dark"', () => { - const root = { theme: sinon.spy() }; + const root = { setMuiThemeName: sinon.spy() }; const at = appThemeFn({ root }); at.setTheme('dark'); - expect(root.theme).to.have.been.calledWithExactly('dark'); + expect(root.setMuiThemeName).to.have.been.calledWithExactly('dark'); }); it('should apply "light" as type on internal theme', () => { - const root = { theme: sinon.spy() }; + const root = { setMuiThemeName: sinon.spy() }; const at = appThemeFn({ root }); at.setTheme('light'); expect(internalAPI.setTheme).to.have.been.calledWithExactly({ diff --git a/apis/nucleus/src/__tests__/nucleus.spec.js b/apis/nucleus/src/__tests__/nucleus.spec.js index 23e301c6b..5a75d228a 100644 --- a/apis/nucleus/src/__tests__/nucleus.spec.js +++ b/apis/nucleus/src/__tests__/nucleus.spec.js @@ -4,16 +4,20 @@ describe('nucleus', () => { let createObject; let getObject; let sandbox; + let rootApp; + let translator; before(() => { sandbox = sinon.createSandbox({ useFakeTimers: true }); createObject = sandbox.stub(); getObject = sandbox.stub(); appThemeFn = sandbox.stub(); + rootApp = sandbox.stub(); + translator = { add: sandbox.stub(), language: sandbox.stub() }; [{ default: create }] = aw.mock( [ - ['**/locale/app-locale.js', () => () => ({ translator: () => ({ add: () => {} }) })], + ['**/locale/app-locale.js', () => () => ({ translator })], ['**/selections/index.js', () => ({ createAppSelectionAPI: () => ({}) })], - ['**/components/NebulaApp.jsx', () => () => [{}]], + ['**/components/NebulaApp.jsx', () => rootApp], ['**/components/selections/AppSelections.jsx', () => () => ({})], ['**/object/create-object.js', () => createObject], ['**/object/get-object.js', () => getObject], @@ -28,6 +32,8 @@ describe('nucleus', () => { beforeEach(() => { createObject.returns('created object'); getObject.returns('got object'); + appThemeFn.returns({ externalAPI: 'internal', setTheme: sandbox.stub() }); + rootApp.returns([{}]); }); afterEach(() => { @@ -76,4 +82,42 @@ describe('nucleus', () => { expect(waited).to.equal(true); expect(c).to.equal('got object'); }); + + it('should initite root app with context', () => { + create('app'); + expect(rootApp).to.have.been.calledWithExactly({ + app: 'app', + context: { + language: 'en-US', + theme: 'light', + permissions: ['idle', 'interact', 'select', 'fetch'], + translator, + }, + }); + }); + + it('should only update context when property is known and changed', async () => { + const root = { context: sandbox.stub() }; + const theme = { setTheme: sandbox.stub() }; + + rootApp.returns([root]); + appThemeFn.returns(theme); + + const nuked = create('app'); + expect(root.context.callCount).to.equal(0); + + nuked.context({ foo: 'a' }); + expect(root.context.callCount).to.equal(0); + + nuked.context({ permissions: 'a' }); + expect(root.context.callCount).to.equal(1); + + nuked.context({ language: 'sv-SE' }); + expect(root.context.callCount).to.equal(2); + expect(translator.language).to.have.been.calledWithExactly('sv-SE'); + + await nuked.context({ theme: 'sv-SE' }); + expect(root.context.callCount).to.equal(3); + expect(theme.setTheme).to.have.been.calledWithExactly('sv-SE'); + }); }); diff --git a/apis/nucleus/src/app-theme.js b/apis/nucleus/src/app-theme.js index e202e08cd..b2f36f712 100644 --- a/apis/nucleus/src/app-theme.js +++ b/apis/nucleus/src/app-theme.js @@ -19,7 +19,7 @@ export default function appTheme({ themes = [], logger, root } = {}) { } else { muiTheme = raw.type === 'dark' ? 'dark' : 'light'; theme.internalAPI.setTheme(raw); - root.theme(muiTheme); + root.setMuiThemeName(muiTheme); } } catch (e) { logger.error(e); @@ -28,7 +28,7 @@ export default function appTheme({ themes = [], logger, root } = {}) { theme.internalAPI.setTheme({ type: muiTheme, }); - root.theme(muiTheme); + root.setMuiThemeName(muiTheme); } }; diff --git a/apis/nucleus/src/components/Cell.jsx b/apis/nucleus/src/components/Cell.jsx index 862d9289e..0b4019383 100644 --- a/apis/nucleus/src/components/Cell.jsx +++ b/apis/nucleus/src/components/Cell.jsx @@ -13,7 +13,7 @@ import Supernova from './Supernova'; import useRect from '../hooks/useRect'; import useLayout from '../hooks/useLayout'; -import LocaleContext from '../contexts/LocaleContext'; +import InstanceContext from '../contexts/InstanceContext'; import { createObjectSelectionAPI } from '../selections'; const initialState = err => ({ @@ -165,7 +165,7 @@ const Cell = forwardRef(({ corona, model, initialSnContext, initialSnOptions, in }, } = corona; - const translator = useContext(LocaleContext); + const { translator, language } = useContext(InstanceContext); const theme = useTheme(); const [state, dispatch] = useReducer(contentReducer, initialState(initialError)); const [layout, validating, cancel, retry] = useLayout({ app, model }); @@ -220,7 +220,7 @@ const Cell = forwardRef(({ corona, model, initialSnContext, initialSnOptions, in load(layout, withVersion); return () => {}; - }, [types, state.sn, model, layout]); + }, [types, state.sn, model, layout, language]); // Long running query useEffect(() => { diff --git a/apis/nucleus/src/components/LongRunningQuery.jsx b/apis/nucleus/src/components/LongRunningQuery.jsx index b8db5e0d1..bd9b471c5 100644 --- a/apis/nucleus/src/components/LongRunningQuery.jsx +++ b/apis/nucleus/src/components/LongRunningQuery.jsx @@ -2,7 +2,7 @@ import React, { useState, useContext } from 'react'; import { makeStyles, Grid, Typography, Button } from '@material-ui/core'; import WarningTriangle from '@nebula.js/ui/icons/warning-triangle-2'; -import LocaleContext from '../contexts/LocaleContext'; +import InstanceContext from '../contexts/InstanceContext'; import Progress from './Progress'; @@ -65,7 +65,7 @@ export default function LongRunningQuery({ onCancel, onRetry }) { const { stripes, cancel, retry } = useStyles(); const [canCancel, setCanCancel] = useState(!!onCancel); const [canRetry, setCanRetry] = useState(!!onRetry); - const translator = useContext(LocaleContext); + const { translator } = useContext(InstanceContext); const handleCancel = () => { setCanCancel(false); diff --git a/apis/nucleus/src/components/NebulaApp.jsx b/apis/nucleus/src/components/NebulaApp.jsx index 51450b7a0..0a8381855 100644 --- a/apis/nucleus/src/components/NebulaApp.jsx +++ b/apis/nucleus/src/components/NebulaApp.jsx @@ -3,26 +3,25 @@ import ReactDOM from 'react-dom'; import { createTheme, ThemeProvider, StylesProvider, createGenerateClassName } from '@nebula.js/ui/theme'; -import LocaleContext from '../contexts/LocaleContext'; -import DirectionContext from '../contexts/DirectionContext'; +import InstanceContext from '../contexts/InstanceContext'; const THEME_PREFIX = (process.env.NEBULA_VERSION || '').replace(/[.-]/g, '_'); let counter = 0; -const NebulaApp = forwardRef(({ translator }, ref) => { - const [d, setDirection] = useState(); - const [tn, setThemeName] = useState(); +const NebulaApp = forwardRef(({ initialContext }, ref) => { + const [context, setContext] = useState(initialContext); + const [muiThemeName, setMuiThemeName] = useState(); const { theme, generator } = useMemo( () => ({ - theme: createTheme(tn), + theme: createTheme(muiThemeName), generator: createGenerateClassName({ productionPrefix: `${THEME_PREFIX}-`, disableGlobal: true, seed: `nebulajs-${counter++}`, }), }), - [tn] + [muiThemeName] ); const [components, setComponents] = useState([]); @@ -38,28 +37,26 @@ const NebulaApp = forwardRef(({ translator }, ref) => { setComponents([...components]); } }, - setThemeName(name) { - setThemeName(name); + setMuiThemeName(name) { + setMuiThemeName(name); }, - setDirection(dir) { - setDirection(dir); + setContext(ctx) { + setContext(ctx); }, })); return ( - - - <>{components} - - + + <>{components} + ); }); -export default function boot({ app, theme: themeName = 'light', translator, direction }) { +export default function boot({ app, context }) { let resolveRender; const rendered = new Promise(resolve => { resolveRender = resolve; @@ -71,11 +68,7 @@ export default function boot({ app, theme: themeName = 'light', translator, dire element.setAttribute('data-app-id', app.id); document.body.appendChild(element); - ReactDOM.render( - , - element, - resolveRender - ); + ReactDOM.render(, element, resolveRender); return [ { @@ -91,16 +84,16 @@ export default function boot({ app, theme: themeName = 'light', translator, dire appRef.current.removeComponent(component); })(); }, - theme(name) { + setMuiThemeName(themeName) { (async () => { await rendered; - appRef.current.setThemeName(name); + appRef.current.setMuiThemeName(themeName); })(); }, - direction(d) { + context(ctx) { (async () => { await rendered; - appRef.current.setDirection(d); + appRef.current.setContext(ctx); })(); }, }, diff --git a/apis/nucleus/src/components/SelectionToolbar.jsx b/apis/nucleus/src/components/SelectionToolbar.jsx index d5ff61eb7..f78be2b6e 100644 --- a/apis/nucleus/src/components/SelectionToolbar.jsx +++ b/apis/nucleus/src/components/SelectionToolbar.jsx @@ -3,7 +3,7 @@ import { close } from '@nebula.js/ui/icons/close'; import { tick } from '@nebula.js/ui/icons/tick'; import { clearSelections } from '@nebula.js/ui/icons/clear-selections'; -import LocaleContext from '../contexts/LocaleContext'; +import InstanceContext from '../contexts/InstanceContext'; import Item from './SelectionToolbarItem'; const SelectionToolbar = React.forwardRef(({ layout, items }, ref) => { @@ -17,7 +17,7 @@ const SelectionToolbar = React.forwardRef(({ layout, items }, ref) => { }); const SelectionToolbarWithDefault = ({ layout, api, xItems = [], onCancel = () => {}, onConfirm = () => {} }) => { - const translator = useContext(LocaleContext); + const { translator } = useContext(InstanceContext); const items = [ ...xItems, diff --git a/apis/nucleus/src/components/Supernova.jsx b/apis/nucleus/src/components/Supernova.jsx index e8983cccb..0cf81b42d 100644 --- a/apis/nucleus/src/components/Supernova.jsx +++ b/apis/nucleus/src/components/Supernova.jsx @@ -1,4 +1,5 @@ -import React, { useState, useEffect, useCallback } from 'react'; +import React, { useState, useEffect, useCallback, useContext, useRef } from 'react'; +import InstanceContext from '../contexts/InstanceContext'; import useRect from '../hooks/useRect'; const setStyle = (el, d) => { @@ -52,6 +53,8 @@ const constrainElement = ({ snNode, parentNode, sn, snContext, layout }) => { const Supernova = ({ sn, snOptions: options, snContext, layout }) => { const { component } = sn; + const { theme, language, permissions } = useContext(InstanceContext); + const renderDebouncer = useRef(null); const [renderCnt, setRenderCnt] = useState(0); const [snRef, snRect, snNode] = useRect(); const [logicalSize, setLogicalSize] = useState({ width: 0, height: 0 }); @@ -76,16 +79,14 @@ const Supernova = ({ sn, snOptions: options, snContext, layout }) => { }, }); - // Mount / Unmount / ThemeChanged + // Mount / Unmount useEffect(() => { if (!snNode || !parentNode || !snContext) return undefined; setLogicalSize(constrainElement({ snNode, parentNode, sn, snContext, layout })); component.created({ options, snContext }); component.mounted(snNode); - snContext.theme.on('changed', render); return () => { component.willUnmount(); - snContext.theme.removeListener('changed', render); }; }, [snNode, parentNode, snContext]); @@ -102,14 +103,18 @@ const Supernova = ({ sn, snOptions: options, snContext, layout }) => { setRenderCnt(renderCnt + 1); } else { // Debounce render - const handle = setTimeout(() => { + // TODO - consider requestAnimationFrame + if (renderDebouncer.current) { + clearTimeout(renderDebouncer.current); + } + renderDebouncer.current = setTimeout(() => { render(); setRenderCnt(renderCnt + 1); }, 100); - return () => clearTimeout(handle); + return () => clearTimeout(renderDebouncer.current); } return undefined; - }, [snRect, layout]); + }, [snRect, layout, theme, language, permissions]); return (
'long-running-query'; const CError = () => 'error'; const Supernova = () => 'supernova'; const Header = () => 'Header'; -const LocaleContext = React.createContext(); +const InstanceContext = React.createContext(); const [{ default: Cell }] = aw.mock( [ @@ -17,7 +17,7 @@ const [{ default: Cell }] = aw.mock( [require.resolve('../Error'), () => CError], [require.resolve('../Supernova'), () => Supernova], [require.resolve('../Header'), () => Header], - [require.resolve('../../contexts/LocaleContext'), () => LocaleContext], + [require.resolve('../../contexts/InstanceContext'), () => InstanceContext], ], ['../Cell'] ); @@ -79,7 +79,7 @@ describe('', () => { await act(async () => { renderer = create( - s, language: () => 'sv' }}> + s, language: () => 'sv' } }}> ', () => { initialSnOptions={initialSnOptions} onMount={onMount} /> - + , rendererOptions || null ); diff --git a/apis/nucleus/src/components/__tests__/long-running-query.spec.jsx b/apis/nucleus/src/components/__tests__/long-running-query.spec.jsx index da6189a9f..cad15c208 100644 --- a/apis/nucleus/src/components/__tests__/long-running-query.spec.jsx +++ b/apis/nucleus/src/components/__tests__/long-running-query.spec.jsx @@ -3,12 +3,12 @@ import { create, act } from 'react-test-renderer'; import { Grid, Button } from '@material-ui/core'; const Progress = () => 'progress'; -const LocaleContext = React.createContext(); +const InstanceContext = React.createContext(); const [{ default: LongRunningQuery, Cancel, Retry }] = aw.mock( [ [require.resolve('../Progress'), () => Progress], - [require.resolve('../../contexts/LocaleContext'), () => LocaleContext], + [require.resolve('../../contexts/InstanceContext'), () => InstanceContext], ], ['../LongRunningQuery'] ); @@ -22,9 +22,9 @@ describe('', () => { render = async (onCancel, onRetry) => { await act(async () => { renderer = create( - s }}> + s } }}> - + ); }); }; diff --git a/apis/nucleus/src/components/__tests__/nebulaapp.spec.jsx b/apis/nucleus/src/components/__tests__/nebulaapp.spec.jsx index 1b3834662..4e307e9bc 100644 --- a/apis/nucleus/src/components/__tests__/nebulaapp.spec.jsx +++ b/apis/nucleus/src/components/__tests__/nebulaapp.spec.jsx @@ -36,8 +36,8 @@ describe('Boot NebulaApp', () => { const [api] = boot({ app: { id: 'foo' } }); expect(api.add).to.be.a('function'); expect(api.remove).to.be.a('function'); - expect(api.theme).to.be.a('function'); - expect(api.direction).to.be.a('function'); + expect(api.setMuiThemeName).to.be.a('function'); + expect(api.context).to.be.a('function'); }); it('should add component', async () => { const app = { id: 'foo' }; @@ -69,35 +69,35 @@ describe('Boot NebulaApp', () => { }); expect(appRef.current.removeComponent.callCount).to.equal(1); }); - it('should set theme', async () => { + it('should set mui theme', async () => { const app = { id: 'foo' }; const translator = {}; const [api, appRef, rendered] = boot({ app, translator }); appRef.current = { - setThemeName: sandbox.spy(), + setMuiThemeName: sandbox.spy(), }; await act(() => { mockedReactDOM.render.callArg(2); - api.theme('wh0p'); + api.setMuiThemeName('wh0p'); return rendered; }); - expect(appRef.current.setThemeName.callCount).to.equal(1); + expect(appRef.current.setMuiThemeName.callCount).to.equal(1); }); - it('should set direction', async () => { + it('should set context', async () => { const app = { id: 'foo' }; const translator = {}; const [api, appRef, rendered] = boot({ app, translator }); appRef.current = { - setDirection: sandbox.spy(), + setContext: sandbox.spy(), }; await act(() => { mockedReactDOM.render.callArg(2); - api.direction('wingding'); + api.context('ctx'); return rendered; }); - expect(appRef.current.setDirection.callCount).to.equal(1); + expect(appRef.current.setContext.callCount).to.equal(1); }); }); diff --git a/apis/nucleus/src/components/__tests__/selectiontoolbar.spec.jsx b/apis/nucleus/src/components/__tests__/selectiontoolbar.spec.jsx index 2a8ab7823..d6e854e98 100644 --- a/apis/nucleus/src/components/__tests__/selectiontoolbar.spec.jsx +++ b/apis/nucleus/src/components/__tests__/selectiontoolbar.spec.jsx @@ -2,13 +2,13 @@ import React from 'react'; import { create, act } from 'react-test-renderer'; import { IconButton } from '@material-ui/core'; // const SelectionToolbarItem = () => 'selectiontoolbaritem'; -const LocaleContext = React.createContext(); +const InstanceContext = React.createContext(); const [{ default: SelectionToolbarWithDefault }] = aw.mock( [ // [require.resolve('../SelectionToolbarItem'), () => SelectionToolbarItem], [require.resolve('@nebula.js/ui/theme'), () => ({ makeStyles: () => () => ({ pallette: {} }) })], - [require.resolve('../../contexts/LocaleContext'), () => LocaleContext], + [require.resolve('../../contexts/InstanceContext'), () => InstanceContext], ], ['../SelectionToolbar'] ); @@ -22,7 +22,7 @@ describe('', () => { render = async (layout, api, xItems, onCancel, onConfirm) => { await act(async () => { renderer = create( - s }}> + s } }}> ', () => { onCancel={onCancel} onConfirm={onConfirm} /> - + ); }); }; diff --git a/apis/nucleus/src/components/__tests__/supernova.spec.jsx b/apis/nucleus/src/components/__tests__/supernova.spec.jsx index dffd0b2e5..fb2f6e5f8 100644 --- a/apis/nucleus/src/components/__tests__/supernova.spec.jsx +++ b/apis/nucleus/src/components/__tests__/supernova.spec.jsx @@ -48,18 +48,12 @@ describe('', () => { render: sandbox.spy(), willUnmount: sandbox.spy(), }; - const theme = { - on: sandbox.spy(), - removeListener: sandbox.spy(), - }; await render({ sn: { logicalSize, component, }, - snContext: { - theme, - }, + snContext: {}, rendererOptions: { createNodeMock: () => { return { @@ -71,7 +65,6 @@ describe('', () => { }); expect(component.created.callCount).to.equal(1); expect(component.mounted.callCount).to.equal(1); - expect(theme.on.callCount).to.equal(1); }); it('should constrain element', async () => { const logicalSize = sandbox.stub().returns({ width: 1024, height: 768 }); @@ -123,10 +116,6 @@ describe('', () => { render: sandbox.spy(), willUnmount: sandbox.spy(), }; - const theme = { - on: sandbox.spy(), - removeListener: sandbox.spy(), - }; const snOptions = { onInitialRender() { initialRenderResolve(true); @@ -138,9 +127,7 @@ describe('', () => { component, }, snOptions, - snContext: { - theme, - }, + snContext: {}, layout: {}, rendererOptions: { createNodeMock: () => { @@ -154,7 +141,6 @@ describe('', () => { global.window.addEventListener.callArg(1); expect(component.created.callCount).to.equal(1); expect(component.mounted.callCount).to.equal(1); - expect(theme.on.callCount).to.equal(1); expect(await initialRender).to.equal(true); expect(component.render.callCount).to.equal(1); }); @@ -167,10 +153,6 @@ describe('', () => { render: sandbox.spy(), willUnmount: sandbox.spy(), }; - const theme = { - on: sandbox.spy(), - removeListener: sandbox.spy(), - }; const getBoundingClientRect = sandbox.stub(); getBoundingClientRect.returns({ left: 100, top: 200, width: 300, height: 400 }); await render({ @@ -178,9 +160,7 @@ describe('', () => { logicalSize, component, }, - snContext: { - theme, - }, + snContext: {}, layout: {}, rendererOptions: { createNodeMock: () => { diff --git a/apis/nucleus/src/components/listbox/ListBoxPopover.jsx b/apis/nucleus/src/components/listbox/ListBoxPopover.jsx index 8accfd636..72d7c556f 100644 --- a/apis/nucleus/src/components/listbox/ListBoxPopover.jsx +++ b/apis/nucleus/src/components/listbox/ListBoxPopover.jsx @@ -16,8 +16,7 @@ import createListboxSelectionToolbar from './listbox-selection-toolbar'; import { createObjectSelectionAPI } from '../../selections'; import SelectionToolbarWithDefault, { SelectionToolbar } from '../SelectionToolbar'; -import LocaleContext from '../../contexts/LocaleContext'; -import DirectionContext from '../../contexts/DirectionContext'; +import InstanceContext from '../../contexts/InstanceContext'; import ListBoxSearch from './ListBoxSearch'; @@ -67,8 +66,7 @@ export default function ListBoxPopover({ alignTo, show, close, app, fieldName, s const [layout] = useLayout({ model }); - const translator = useContext(LocaleContext); - const direction = useContext(DirectionContext); + const { translator } = useContext(InstanceContext); const moreAlignTo = useRef(); const [showSelectionsMenu, setShowSelectionsMenu] = useState(false); @@ -152,7 +150,7 @@ export default function ListBoxPopover({ alignTo, show, close, app, fieldName, s
- + {showSelectionsMenu && ( ({ root: { @@ -17,7 +17,7 @@ const useStyles = makeStyles(theme => ({ const TREE_PATH = '/qListObjectDef'; export default function ListBoxSearch({ model }) { - const translator = useContext(LocaleContext); + const { translator } = useContext(InstanceContext); const [value, setValue] = useState(''); const onChange = e => { setValue(e.target.value); diff --git a/apis/nucleus/src/components/listbox/__tests__/list-box-search.spec.jsx b/apis/nucleus/src/components/listbox/__tests__/list-box-search.spec.jsx index 6a730ab12..375728b4b 100644 --- a/apis/nucleus/src/components/listbox/__tests__/list-box-search.spec.jsx +++ b/apis/nucleus/src/components/listbox/__tests__/list-box-search.spec.jsx @@ -2,10 +2,10 @@ import React from 'react'; import renderer from 'react-test-renderer'; import { OutlinedInput } from '@material-ui/core'; -const LocaleContext = React.createContext(); +const InstanceContext = React.createContext(); const [{ default: ListBoxSearch }] = aw.mock( [ - [require.resolve('../../../contexts/LocaleContext'), () => LocaleContext], + [require.resolve('../../../contexts/InstanceContext'), () => InstanceContext], [require.resolve('@nebula.js/ui/theme'), () => ({ makeStyles: () => () => ({}) })], ], ['../ListBoxSearch'] @@ -19,9 +19,9 @@ describe('', () => { abortListObjectSearch: sinon.spy(), }; const testRenderer = renderer.create( - 'Search' }}> + 'Search' } }}> - + ); const testInstance = testRenderer.root; const types = testInstance.findAllByType(OutlinedInput); @@ -40,17 +40,17 @@ describe('', () => { abortListObjectSearch: sinon.spy(), }; const testRenderer = renderer.create( - 'Search' }}> + 'Search' } }}> - + ); const testInstance = testRenderer.root; let type = testInstance.findByType(OutlinedInput); type.props.onChange({ target: { value: 'foo' } }); testRenderer.update( - 'Search' }}> + 'Search' } }}> - + ); expect(model.searchListObjectFor).to.have.been.calledWith('/qListObjectDef', 'foo'); type = testInstance.findByType(OutlinedInput); @@ -63,9 +63,9 @@ describe('', () => { abortListObjectSearch: sinon.spy(), }; const testRenderer = renderer.create( - 'Search' }}> + 'Search' } }}> - + ); const testInstance = testRenderer.root; const type = testInstance.findByType(OutlinedInput); @@ -82,9 +82,9 @@ describe('', () => { abortListObjectSearch: sinon.spy(), }; const testRenderer = renderer.create( - 'Search' }}> + 'Search' } }}> - + ); const testInstance = testRenderer.root; const type = testInstance.findByType(OutlinedInput); diff --git a/apis/nucleus/src/components/selections/MultiState.jsx b/apis/nucleus/src/components/selections/MultiState.jsx index e8967ecb2..e420db82d 100644 --- a/apis/nucleus/src/components/selections/MultiState.jsx +++ b/apis/nucleus/src/components/selections/MultiState.jsx @@ -4,7 +4,7 @@ import { makeStyles } from '@nebula.js/ui/theme'; import DownArrow from '@nebula.js/ui/icons/down-arrow'; import OneField from './OneField'; -import LocaleContext from '../../contexts/LocaleContext'; +import InstanceContext from '../../contexts/InstanceContext'; import ListBoxPopover from '../listbox/ListBoxPopover'; @@ -31,7 +31,7 @@ export default function MultiState({ field, api }) { const [showStateIx, setShowStateIx] = useState(-1); const [anchorEl, setAnchorEl] = useState(null); const alignTo = useRef(); - const translator = useContext(LocaleContext); + const { translator } = useContext(InstanceContext); const clearAllStates = translator.get('Selection.ClearAllStates'); const handleShowFields = e => { diff --git a/apis/nucleus/src/components/selections/Nav.jsx b/apis/nucleus/src/components/selections/Nav.jsx index ee7d5d09c..4dff3e6ec 100644 --- a/apis/nucleus/src/components/selections/Nav.jsx +++ b/apis/nucleus/src/components/selections/Nav.jsx @@ -6,10 +6,10 @@ import SelectionsBack from '@nebula.js/ui/icons/selections-back'; import SelectionsForward from '@nebula.js/ui/icons/selections-forward'; import ClearSelections from '@nebula.js/ui/icons/clear-selections'; -import LocaleContext from '../../contexts/LocaleContext'; +import InstanceContext from '../../contexts/InstanceContext'; export default function Nav({ api }) { - const translator = useContext(LocaleContext); + const { translator } = useContext(InstanceContext); const [state, setState] = useState({ forward: api.canGoForward(), diff --git a/apis/nucleus/src/components/selections/OneField.jsx b/apis/nucleus/src/components/selections/OneField.jsx index 1ea9718e7..57649369a 100644 --- a/apis/nucleus/src/components/selections/OneField.jsx +++ b/apis/nucleus/src/components/selections/OneField.jsx @@ -9,7 +9,7 @@ import { makeStyles, useTheme } from '@nebula.js/ui/theme'; import ListBoxPopover from '../listbox/ListBoxPopover'; -import LocaleContext from '../../contexts/LocaleContext'; +import InstanceContext from '../../contexts/InstanceContext'; const useStyles = makeStyles(theme => ({ item: { @@ -24,7 +24,7 @@ const useStyles = makeStyles(theme => ({ })); export default function OneField({ field, api, stateIx = 0, skipHandleShowListBoxPopover = false }) { - const translator = useContext(LocaleContext); + const { translator } = useContext(InstanceContext); const alignTo = useRef(); const theme = useTheme(); const [showListBoxPopover, setShowListBoxPopover] = useState(false); diff --git a/apis/nucleus/src/components/selections/__tests__/one-field.spec.jsx b/apis/nucleus/src/components/selections/__tests__/one-field.spec.jsx index 031895832..9e511448e 100644 --- a/apis/nucleus/src/components/selections/__tests__/one-field.spec.jsx +++ b/apis/nucleus/src/components/selections/__tests__/one-field.spec.jsx @@ -2,10 +2,10 @@ import React from 'react'; import renderer from 'react-test-renderer'; import { IconButton, Typography } from '@material-ui/core'; -const LocaleContext = React.createContext(); +const InstanceContext = React.createContext(); const [{ default: OneField }] = aw.mock( [ - [require.resolve('../../../contexts/LocaleContext'), () => LocaleContext], + [require.resolve('../../../contexts/InstanceContext'), () => InstanceContext], [ require.resolve('@nebula.js/ui/theme'), () => ({ @@ -39,9 +39,9 @@ describe('', () => { states: ['$'], }; const testRenderer = renderer.create( - 'ALL' }}> + 'ALL' } }}> - + ); const testInstance = testRenderer.root; const types = testInstance.findAllByType(Typography); @@ -76,9 +76,9 @@ describe('', () => { states: ['$'], }; const testRenderer = renderer.create( - 'Of' }}> + 'Of' } }}> - + ); const testInstance = testRenderer.root; const types = testInstance.findAllByType(Typography); @@ -105,9 +105,9 @@ describe('', () => { states: ['$'], }; const testRenderer = renderer.create( - 'Clear' }}> + 'Clear' } }}> - + ); const testInstance = testRenderer.root; const types = testInstance.findAllByType(IconButton); @@ -126,9 +126,9 @@ describe('', () => { states: ['$'], }; const testRenderer = renderer.create( - 'Lock' }}> + 'Lock' } }}> - + ); const testInstance = testRenderer.root; const types = testInstance.findAllByType(IconButton); diff --git a/apis/nucleus/src/contexts/DirectionContext.js b/apis/nucleus/src/contexts/DirectionContext.js deleted file mode 100644 index 0145903fc..000000000 --- a/apis/nucleus/src/contexts/DirectionContext.js +++ /dev/null @@ -1,5 +0,0 @@ -import React from 'react'; - -const DirectionContext = React.createContext(); - -export default DirectionContext; diff --git a/apis/nucleus/src/contexts/InstanceContext.js b/apis/nucleus/src/contexts/InstanceContext.js new file mode 100644 index 000000000..3871eaf4b --- /dev/null +++ b/apis/nucleus/src/contexts/InstanceContext.js @@ -0,0 +1,8 @@ +import React from 'react'; + +export default React.createContext({ + language: null, + theme: null, + translator: null, + permissions: [], +}); diff --git a/apis/nucleus/src/contexts/LocaleContext.js b/apis/nucleus/src/contexts/LocaleContext.js deleted file mode 100644 index 90c581436..000000000 --- a/apis/nucleus/src/contexts/LocaleContext.js +++ /dev/null @@ -1,5 +0,0 @@ -import React from 'react'; - -const LocaleContext = React.createContext(); - -export default LocaleContext; diff --git a/apis/nucleus/src/index.js b/apis/nucleus/src/index.js index 3cce3cd0c..1bd0e4514 100644 --- a/apis/nucleus/src/index.js +++ b/apis/nucleus/src/index.js @@ -26,14 +26,15 @@ import loggerFn from './utils/logger'; * @alias Configuration */ const DEFAULT_CONFIG = { - theme: 'light', + context: { + theme: 'light', + language: 'en-US', + permissions: ['idle', 'interact', 'select', 'fetch'], + }, /** * */ load: () => undefined, - locale: { - language: 'en-US', - }, log: { level: 1, }, @@ -70,12 +71,11 @@ const DEFAULT_CONFIG = { }; const mergeConfigs = (base, c) => ({ - direction: c.direction || base.direction, - theme: c.theme || base.theme, - load: c.load || base.load, - locale: { - language: (c.locale ? c.locale.language : '') || base.locale.language, + context: { + ...base.context, + ...(c.context || {}), }, + load: c.load || base.load, snapshot: { ...(c.snapshot || base.snapshot), }, @@ -101,7 +101,7 @@ const mergeConfigs = (base, c) => ({ function nuked(configuration = {}) { const logger = loggerFn(configuration.log); - const locale = appLocaleFn(configuration.locale); + const locale = appLocaleFn(configuration.context.language); /** * Initiates a new `nebbie` instance using the specified `app`. @@ -121,10 +121,14 @@ function nuked(configuration = {}) { createAppSelectionAPI(app); + let currentContext = { + ...configuration.context, + translator: locale.translator, + }; + const [root] = bootNebulaApp({ app, - translator: locale.translator, - direction: configuration.direction, + context: currentContext, }); const appTheme = appThemeFn({ @@ -135,9 +139,8 @@ function nuked(configuration = {}) { const publicAPIs = { env: { - Promise, // TODO - deprecate translator: locale.translator, - nucleus, // eslint-disable-line no-use-before-define + nucleus, }, theme: appTheme.externalAPI, translator: locale.translator, @@ -150,6 +153,7 @@ function nuked(configuration = {}) { logger, config: configuration, public: publicAPIs, + context: currentContext, nebbie: null, }; @@ -168,7 +172,7 @@ function nuked(configuration = {}) { ) ); - let currentThemePromise = appTheme.setTheme(configuration.theme); + let currentThemePromise = appTheme.setTheme(configuration.context.theme); let selectionsApi = null; let selectionsComponentReference = null; @@ -196,14 +200,44 @@ function nuked(configuration = {}) { await currentThemePromise; return create(createCfg, vizConfig, corona); }, - theme(themeName) { - currentThemePromise = appTheme.setTheme(themeName); - }, /** - * @param {'ltr'|'rtl'} d + * @param {object} ctx + * @param {string} ctx.theme + * @param {string} ctx.language + * @param {string[]} ctx.permissions */ - direction(d) { - root.direction(d); + context: async ctx => { + // filter valid values to avoid triggering unnecessary rerender + let changes; + ['theme', 'language', 'permissions'].forEach(key => { + if (ctx[key] && ctx[key] !== currentContext[key]) { + if (!changes) { + changes = {}; + } + changes[key] = ctx[key]; + } + }); + if (!changes) { + return; + } + currentContext = { + ...currentContext, + ...changes, + translator: locale.translator, + }; + + corona.context = currentContext; + + if (changes.theme) { + currentThemePromise = appTheme.setTheme(changes.theme); + await currentThemePromise; + } + + if (changes.language) { + corona.public.translator.language(changes.language); + } + + root.context(currentContext); }, /** * @returns {AppSelections} diff --git a/apis/nucleus/src/locale/app-locale.js b/apis/nucleus/src/locale/app-locale.js index 4915ecc4e..cabbaca93 100644 --- a/apis/nucleus/src/locale/app-locale.js +++ b/apis/nucleus/src/locale/app-locale.js @@ -1,7 +1,7 @@ import localeFn from '@nebula.js/locale'; import all from './translations/all.json'; -export default function appLocaleFn({ language }) { +export default function appLocaleFn(language) { const l = localeFn({ initial: language, }); diff --git a/apis/snapshooter/src/renderer.js b/apis/snapshooter/src/renderer.js index e24a674fe..c3d4e5312 100644 --- a/apis/snapshooter/src/renderer.js +++ b/apis/snapshooter/src/renderer.js @@ -57,8 +57,8 @@ async function renderSnapshot({ nucleus, element }) { }; const nebbie = await nucleus(app, { - theme, - locale: { + context: { + theme, language, }, }); diff --git a/commands/serve/web/components/App.jsx b/commands/serve/web/components/App.jsx index 9423dd3b8..36db4afe3 100644 --- a/commands/serve/web/components/App.jsx +++ b/commands/serve/web/components/App.jsx @@ -6,7 +6,7 @@ import SvgIcon from '@nebula.js/ui/icons/SvgIcon'; import { createTheme, ThemeProvider } from '@nebula.js/ui/theme'; -import { WbSunny, Brightness3, ColorLens } from '@nebula.js/ui/icons'; +import { WbSunny, Brightness3, ColorLens, Language } from '@nebula.js/ui/icons'; import { Grid, @@ -29,7 +29,6 @@ import Collection from './Collection'; import AppContext from '../contexts/AppContext'; import NebulaContext from '../contexts/NebulaContext'; -import DirectionContext from '../contexts/DirectionContext'; import VizContext from '../contexts/VizContext'; const rtlShape = { @@ -47,6 +46,24 @@ const directionShape = { rtl: rtlShape, }; +const languages = [ + 'en-US', + 'it-IT', + 'zh-CN', + 'zh-TW', + 'ko-KR', + 'de-DE', + 'sv-SE', + 'es-ES', + 'pt-BR', + 'ja-JP', + 'fr-FR', + 'nl-NL', + 'tr-TR', + 'pl-PL', + 'ru-RU', +]; + const storageFn = app => { const stored = window.localStorage.getItem('nebula-dev'); const parsed = stored ? JSON.parse(stored) : {}; @@ -80,6 +97,7 @@ export default function App({ app, info }) { const [sn, setSupernova] = useState(null); const [isReadCacheEnabled, setReadCacheEnabled] = useState(storage.get('readFromCache') !== false); const [currentThemeName, setCurrentThemeName] = useState(storage.get('themeName')); + const [currentLanguage, setCurrentLanguage] = useState(storage.get('language') || 'en-US'); const [currentMuiThemeName, setCurrentMuiThemeName] = useState('light'); const [objectListMode, setObjectListMode] = useState(storage.get('objectListMode') === true); const [direction, setDirection] = useState('ltr'); @@ -87,6 +105,7 @@ export default function App({ app, info }) { const uid = useRef(); const [currentId, setCurrentId] = useState(); const [themeChooserAnchorEl, setThemeChooserAnchorEl] = React.useState(null); + const [languageChooserAnchorEl, setLanguageChooserAnchorEl] = React.useState(null); const customThemes = info.themes && info.themes.length ? ['light', 'dark', ...info.themes] : []; @@ -105,9 +124,11 @@ export default function App({ app, info }) { const nebbie = useMemo(() => { const n = nucleus(app, { - load: (type, config) => config.Promise.resolve(window[type.name]), - theme: currentThemeName, - direction, + context: { + theme: currentThemeName, + language: currentLanguage, + }, + load: type => Promise.resolve(window[type.name]), themes: info.themes ? info.themes.map(t => ({ key: t, @@ -126,7 +147,7 @@ export default function App({ app, info }) { }, [app]); useLayoutEffect(() => { - nebbie.theme(currentThemeName); + nebbie.context({ theme: currentThemeName }); if (currentThemeName === 'light' || currentThemeName === 'dark') { setCurrentMuiThemeName(currentThemeName); } @@ -180,6 +201,13 @@ export default function App({ app, info }) { setCurrentThemeName(t); }; + const handleLanguageChange = lang => { + setLanguageChooserAnchorEl(null); + storage.save('language', lang); + setCurrentLanguage(lang); + nebbie.context({ language: lang }); + }; + const toggleDarkMode = () => { const v = currentThemeName === 'dark' ? 'light' : 'dark'; storage.save('themeName', v); @@ -195,7 +223,6 @@ export default function App({ app, info }) { nextDir = 'ltr'; } document.body.setAttribute('dir', nextDir); - nebbie.direction(nextDir); return nextDir; }); }; @@ -213,115 +240,130 @@ export default function App({ app, info }) { return ( - - - - - - - - - - - - Create} value={0} /> - Edit} value={1} /> - - + Go to Hub + + + + + Create} value={0} /> + Edit} value={1} /> + + + - - Cache - - - - {customThemes.length ? ( - <> - setThemeChooserAnchorEl(e.currentTarget)}> - - - setThemeChooserAnchorEl(null)} - > - {customThemes.map(t => ( - handleThemeChange(t)} - > - {t} - - ))} - - - ) : ( - - {currentThemeName === 'dark' ? ( - - ) : ( - - )} + Cache + + + + {customThemes.length ? ( + <> + setThemeChooserAnchorEl(e.currentTarget)}> + - )} - - - - {SvgIcon(directionShape[direction])} + setThemeChooserAnchorEl(null)} + > + {customThemes.map(t => ( + handleThemeChange(t)}> + {t} + + ))} + + + ) : ( + + {currentThemeName === 'dark' ? ( + + ) : ( + + )} - + )} + + + + setLanguageChooserAnchorEl(null)} + > + {languages.map(t => ( + handleLanguageChange(t)}> + {t} + + ))} + + + + + {SvgIcon(directionShape[direction])} + - - - - -
- - - - - {sn ? ( - - - {objectListMode ? ( - - ) : ( - - )} - - - {activeViz && } - + + + + + +
+ + + + + {sn ? ( + + + {objectListMode ? ( + + ) : ( + + )} - ) : ( - - - - + + {activeViz && } - )} - - + + ) : ( + + + + + + )} + - - + + ); diff --git a/commands/serve/web/contexts/DirectionContext.js b/commands/serve/web/contexts/DirectionContext.js deleted file mode 100644 index 0145903fc..000000000 --- a/commands/serve/web/contexts/DirectionContext.js +++ /dev/null @@ -1,5 +0,0 @@ -import React from 'react'; - -const DirectionContext = React.createContext(); - -export default DirectionContext; diff --git a/commands/serve/web/eRender.js b/commands/serve/web/eRender.js index 664ddcd81..b67985bc1 100644 --- a/commands/serve/web/eRender.js +++ b/commands/serve/web/eRender.js @@ -12,13 +12,13 @@ const nuke = async ({ app, supernova: { name }, themes, theme, language }) => { load: async () => (await fetch(`/theme/${t}`)).json(), })) : undefined, - theme, - locale: { + context: { + theme, language, }, }); const nebbie = nuked(app, { - load: (type, config) => config.Promise.resolve(window[type.name]), + load: type => Promise.resolve(window[type.name]), types: [ { name, @@ -80,7 +80,7 @@ async function renderSnapshot() { : undefined, types: [ { - load: (type, config) => config.Promise.resolve(window[type.name]), + load: type => Promise.resolve(window[type.name]), name: supernova.name, }, ], @@ -107,8 +107,8 @@ const renderFixture = async () => { load: async () => (await fetch(`/theme/${t}`)).json(), })) : undefined, - theme, - locale: { + context: { + theme, language: params.language, }, }; diff --git a/packages/ui/icons/index.js b/packages/ui/icons/index.js index 1cba3b16d..d22a255a4 100644 --- a/packages/ui/icons/index.js +++ b/packages/ui/icons/index.js @@ -4,5 +4,6 @@ import Brightness3 from '@material-ui/icons/Brightness3'; import WbSunny from '@material-ui/icons/WbSunny'; import ExpandMore from '@material-ui/icons/ExpandMore'; import ColorLens from '@material-ui/icons/ColorLens'; +import Language from '@material-ui/icons/Language'; -export { ChevronRight, ChevronLeft, Brightness3, WbSunny, ExpandMore, ColorLens }; +export { ChevronRight, ChevronLeft, Brightness3, WbSunny, ExpandMore, ColorLens, Language }; diff --git a/test/mashup/snaps/configured.js b/test/mashup/snaps/configured.js index 907fa4ee1..88c5242ae 100644 --- a/test/mashup/snaps/configured.js +++ b/test/mashup/snaps/configured.js @@ -24,8 +24,8 @@ const bar = function(env) { // eslint-disable-next-line const configured = nucleus.configured({ - theme: 'dark', - locale: { + context: { + theme: 'dark', language: 'sv-SE', }, types: [