From c7aceba1b90a42b13e6574eca396771d4f924cc9 Mon Sep 17 00:00:00 2001 From: Yogev Ben David Date: Thu, 7 Jan 2021 11:53:15 +0200 Subject: [PATCH] Resolve component static options before calling layout processors (#6886) We found out that [LayoutProcessors](https://wix.github.io/react-native-navigation/next/docs/style-theme/#conditional-themes-with-layout-processor) was missing the static options from the components. This PR fixes it. --- lib/src/Navigation.ts | 6 +- lib/src/commands/Commands.test.ts | 41 ++- lib/src/commands/Commands.ts | 11 +- lib/src/commands/LayoutTreeCrawler.test.ts | 158 +--------- lib/src/commands/LayoutTreeCrawler.ts | 25 -- lib/src/commands/OptionsCrawler.test.ts | 348 +++++++++++++++++++++ lib/src/commands/OptionsCrawler.ts | 85 +++++ lib/src/interfaces/Layout.ts | 4 +- 8 files changed, 485 insertions(+), 193 deletions(-) create mode 100644 lib/src/commands/OptionsCrawler.test.ts create mode 100644 lib/src/commands/OptionsCrawler.ts diff --git a/lib/src/Navigation.ts b/lib/src/Navigation.ts index 58520f47577..e36662ef3d6 100644 --- a/lib/src/Navigation.ts +++ b/lib/src/Navigation.ts @@ -26,6 +26,7 @@ import { ProcessorSubscription } from './interfaces/ProcessorSubscription'; import { LayoutProcessor } from './processors/LayoutProcessor'; import { LayoutProcessorsStore } from './processors/LayoutProcessorsStore'; import { CommandName } from './interfaces/CommandName'; +import { OptionsCrawler } from './commands/OptionsCrawler'; export class NavigationRoot { public readonly TouchablePreview = TouchablePreview; @@ -44,6 +45,7 @@ export class NavigationRoot { private readonly commandsObserver: CommandsObserver; private readonly componentEventsObserver: ComponentEventsObserver; private readonly componentWrapper: ComponentWrapper; + private readonly optionsCrawler: OptionsCrawler; constructor() { this.componentWrapper = new ComponentWrapper(); @@ -76,6 +78,7 @@ export class NavigationRoot { this.layoutTreeCrawler = new LayoutTreeCrawler(this.store, optionsProcessor); this.nativeCommandsSender = new NativeCommandsSender(); this.commandsObserver = new CommandsObserver(this.uniqueIdProvider); + this.optionsCrawler = new OptionsCrawler(this.store); this.commands = new Commands( this.store, this.nativeCommandsSender, @@ -84,7 +87,8 @@ export class NavigationRoot { this.commandsObserver, this.uniqueIdProvider, optionsProcessor, - layoutProcessor + layoutProcessor, + this.optionsCrawler ); this.eventsRegistry = new EventsRegistry( this.nativeEventsReceiver, diff --git a/lib/src/commands/Commands.test.ts b/lib/src/commands/Commands.test.ts index ed5eea6a421..65f685ee565 100644 --- a/lib/src/commands/Commands.test.ts +++ b/lib/src/commands/Commands.test.ts @@ -15,6 +15,8 @@ import { Options } from '../interfaces/Options'; import { LayoutProcessor } from '../processors/LayoutProcessor'; import { LayoutProcessorsStore } from '../processors/LayoutProcessorsStore'; import { CommandName } from '../interfaces/CommandName'; +import { OptionsCrawler } from './OptionsCrawler'; +import React from 'react'; describe('Commands', () => { let uut: Commands; @@ -47,7 +49,8 @@ describe('Commands', () => { commandsObserver, uniqueIdProvider, optionsProcessor, - layoutProcessor + layoutProcessor, + new OptionsCrawler(instance(mockedStore)) ); }); @@ -135,7 +138,30 @@ describe('Commands', () => { root: { component: { name: 'com.example.MyScreen' } }, }); expect(layoutProcessor.process).toBeCalledWith( - { component: { name: 'com.example.MyScreen' } }, + { component: { name: 'com.example.MyScreen', options: {} } }, + CommandName.SetRoot + ); + }); + + it('pass component static options to layoutProcessor', () => { + when(mockedStore.getComponentClassForName('com.example.MyScreen')).thenReturn( + () => + class extends React.Component { + static options(): Options { + return { + topBar: { + visible: false, + }, + }; + } + } + ); + + uut.setRoot({ + root: { component: { name: 'com.example.MyScreen' } }, + }); + expect(layoutProcessor.process).toBeCalledWith( + { component: { name: 'com.example.MyScreen', options: { topBar: { visible: false } } } }, CommandName.SetRoot ); }); @@ -208,7 +234,7 @@ describe('Commands', () => { it('process layout with layoutProcessor', () => { uut.showModal({ component: { name: 'com.example.MyScreen' } }); expect(layoutProcessor.process).toBeCalledWith( - { component: { name: 'com.example.MyScreen' } }, + { component: { name: 'com.example.MyScreen', options: {} } }, CommandName.ShowModal ); }); @@ -293,7 +319,7 @@ describe('Commands', () => { it('process layout with layoutProcessor', () => { uut.push('theComponentId', { component: { name: 'com.example.MyScreen' } }); expect(layoutProcessor.process).toBeCalledWith( - { component: { name: 'com.example.MyScreen' } }, + { component: { name: 'com.example.MyScreen', options: {} } }, CommandName.Push ); }); @@ -390,7 +416,7 @@ describe('Commands', () => { it('process layout with layoutProcessor', () => { uut.setStackRoot('theComponentId', [{ component: { name: 'com.example.MyScreen' } }]); expect(layoutProcessor.process).toBeCalledWith( - { component: { name: 'com.example.MyScreen' } }, + { component: { name: 'com.example.MyScreen', options: {} } }, CommandName.SetStackRoot ); }); @@ -436,7 +462,7 @@ describe('Commands', () => { it('process layout with layoutProcessor', () => { uut.showOverlay({ component: { name: 'com.example.MyScreen' } }); expect(layoutProcessor.process).toBeCalledWith( - { component: { name: 'com.example.MyScreen' } }, + { component: { name: 'com.example.MyScreen', options: {} } }, CommandName.ShowOverlay ); }); @@ -490,7 +516,8 @@ describe('Commands', () => { commandsObserver, instance(anotherMockedUniqueIdProvider), instance(mockedOptionsProcessor), - new LayoutProcessor(new LayoutProcessorsStore()) + new LayoutProcessor(new LayoutProcessorsStore()), + new OptionsCrawler(instance(mockedStore)) ); }); diff --git a/lib/src/commands/Commands.ts b/lib/src/commands/Commands.ts index 219b3245064..fc52cd0c219 100644 --- a/lib/src/commands/Commands.ts +++ b/lib/src/commands/Commands.ts @@ -12,6 +12,7 @@ import { OptionsProcessor } from './OptionsProcessor'; import { Store } from '../components/Store'; import { LayoutProcessor } from '../processors/LayoutProcessor'; import { CommandName } from '../interfaces/CommandName'; +import { OptionsCrawler } from './OptionsCrawler'; export class Commands { constructor( @@ -22,20 +23,24 @@ export class Commands { private readonly commandsObserver: CommandsObserver, private readonly uniqueIdProvider: UniqueIdProvider, private readonly optionsProcessor: OptionsProcessor, - private readonly layoutProcessor: LayoutProcessor + private readonly layoutProcessor: LayoutProcessor, + private readonly optionsCrawler: OptionsCrawler ) {} public setRoot(simpleApi: LayoutRoot) { const input = cloneLayout(simpleApi); + this.optionsCrawler.crawl(input.root); const processedRoot = this.layoutProcessor.process(input.root, CommandName.SetRoot); const root = this.layoutTreeParser.parse(processedRoot); const modals = map(input.modals, (modal) => { + this.optionsCrawler.crawl(modal); const processedModal = this.layoutProcessor.process(modal, CommandName.SetRoot); return this.layoutTreeParser.parse(processedModal); }); const overlays = map(input.overlays, (overlay: any) => { + this.optionsCrawler.crawl(overlay); const processedOverlay = this.layoutProcessor.process(overlay, CommandName.SetRoot); return this.layoutTreeParser.parse(processedOverlay); }); @@ -81,6 +86,7 @@ export class Commands { public showModal(layout: Layout) { const layoutCloned = cloneLayout(layout); + this.optionsCrawler.crawl(layoutCloned); const layoutProcessed = this.layoutProcessor.process(layoutCloned, CommandName.ShowModal); const layoutNode = this.layoutTreeParser.parse(layoutProcessed); @@ -112,6 +118,7 @@ export class Commands { public push(componentId: string, simpleApi: Layout) { const input = cloneLayout(simpleApi); + this.optionsCrawler.crawl(input); const layoutProcessed = this.layoutProcessor.process(input, CommandName.Push); const layout = this.layoutTreeParser.parse(layoutProcessed); @@ -146,6 +153,7 @@ export class Commands { public setStackRoot(componentId: string, children: Layout[]) { const input = map(cloneLayout(children), (simpleApi) => { + this.optionsCrawler.crawl(simpleApi); const layoutProcessed = this.layoutProcessor.process(simpleApi, CommandName.SetStackRoot); const layout = this.layoutTreeParser.parse(layoutProcessed); return layout; @@ -167,6 +175,7 @@ export class Commands { public showOverlay(simpleApi: Layout) { const input = cloneLayout(simpleApi); + this.optionsCrawler.crawl(input); const layoutProcessed = this.layoutProcessor.process(input, CommandName.ShowOverlay); const layout = this.layoutTreeParser.parse(layoutProcessed); diff --git a/lib/src/commands/LayoutTreeCrawler.test.ts b/lib/src/commands/LayoutTreeCrawler.test.ts index 28720cf67b8..d34ec14115d 100644 --- a/lib/src/commands/LayoutTreeCrawler.test.ts +++ b/lib/src/commands/LayoutTreeCrawler.test.ts @@ -1,11 +1,8 @@ -import * as React from 'react'; - import { LayoutType } from './LayoutType'; import { LayoutTreeCrawler } from './LayoutTreeCrawler'; import { Store } from '../components/Store'; -import { mock, instance, verify, deepEqual, when } from 'ts-mockito'; +import { mock, instance, verify, deepEqual } from 'ts-mockito'; import { OptionsProcessor } from './OptionsProcessor'; -import { Options } from '../interfaces/Options'; import { CommandName } from '../interfaces/CommandName'; describe('LayoutTreeCrawler', () => { @@ -37,111 +34,11 @@ describe('LayoutTreeCrawler', () => { verify(mockedStore.updateProps('testId', deepEqual({ myProp: 123 }))).called(); }); - it('Components: injects options from original component class static property', () => { - when(mockedStore.getComponentClassForName('theComponentName')).thenReturn( - () => - class extends React.Component { - static options(): Options { - return { popGesture: true }; - } - } - ); - const node = { - id: 'testId', - type: LayoutType.Component, - data: { name: 'theComponentName', options: {} }, - children: [], - }; - uut.crawl(node, CommandName.SetRoot); - expect(node.data.options).toEqual({ popGesture: true }); - }); - - it('Components: crawl does not cache options', () => { - when(mockedStore.getComponentClassForName('theComponentName')).thenReturn( - () => - class extends React.Component { - static options(props: { title?: string }) { - return { topBar: { title: { text: props.title } } }; - } - } - ); - const node = { - id: 'testId', - type: LayoutType.Component, - data: { name: 'theComponentName', options: {}, passProps: { title: 'title' } }, - children: [], - }; - uut.crawl(node, CommandName.SetRoot); - expect(node.data.options).toEqual({ topBar: { title: { text: 'title' } } }); - - const node2 = { - id: 'testId', - type: LayoutType.Component, - data: { name: 'theComponentName', options: {} }, - children: [], - }; - uut.crawl(node2, CommandName.SetRoot); - expect(node2.data.options).toEqual({ topBar: { title: {} } }); - }); - - it('Components: merges options from component class static property with passed options, favoring passed options', () => { - when(mockedStore.getComponentClassForName('theComponentName')).thenReturn( - () => - class extends React.Component { - static options() { - return { - bazz: 123, - inner: { foo: 'this gets overriden' }, - opt: 'exists only in static', - }; - } - } - ); - - const node = { - id: 'testId', - type: LayoutType.Component, - data: { - name: 'theComponentName', - options: { - aaa: 'exists only in passed', - bazz: 789, - inner: { foo: 'this should override same keys' }, - }, - }, - children: [], - }; - - uut.crawl(node, CommandName.SetRoot); - - expect(node.data.options).toEqual({ - aaa: 'exists only in passed', - bazz: 789, - inner: { foo: 'this should override same keys' }, - opt: 'exists only in static', - }); - }); - it('Components: must contain data name', () => { const node = { type: LayoutType.Component, data: {}, children: [], id: 'testId' }; expect(() => uut.crawl(node, CommandName.SetRoot)).toThrowError('Missing component data.name'); }); - it('Components: options default obj', () => { - when(mockedStore.getComponentClassForName('theComponentName')).thenReturn( - () => class extends React.Component {} - ); - - const node = { - id: 'testId', - type: LayoutType.Component, - data: { name: 'theComponentName', options: {} }, - children: [], - }; - uut.crawl(node, CommandName.SetRoot); - expect(node.data.options).toEqual({}); - }); - it('Components: omits passProps after processing so they are not passed over the bridge', () => { const node = { id: 'testId', @@ -155,57 +52,4 @@ describe('LayoutTreeCrawler', () => { uut.crawl(node, CommandName.SetRoot); expect(node.data.passProps).toBeUndefined(); }); - - it('componentId is included in props passed to options generator', () => { - let componentIdInProps: String = ''; - - when(mockedStore.getComponentClassForName('theComponentName')).thenReturn( - () => - class extends React.Component { - static options(props: any) { - componentIdInProps = props.componentId; - return {}; - } - } - ); - const node = { - id: 'testId', - type: LayoutType.Component, - data: { - name: 'theComponentName', - passProps: { someProp: 'here' }, - }, - children: [], - }; - uut.crawl(node, CommandName.SetRoot); - expect(componentIdInProps).toEqual('testId'); - }); - - it('componentId does not override componentId in passProps', () => { - let componentIdInProps: String = ''; - - when(mockedStore.getComponentClassForName('theComponentName')).thenReturn( - () => - class extends React.Component { - static options(props: any) { - componentIdInProps = props.componentId; - return {}; - } - } - ); - const node = { - id: 'testId', - type: LayoutType.Component, - data: { - name: 'theComponentName', - passProps: { - someProp: 'here', - componentId: 'compIdFromPassProps', - }, - }, - children: [], - }; - uut.crawl(node, CommandName.SetRoot); - expect(componentIdInProps).toEqual('compIdFromPassProps'); - }); }); diff --git a/lib/src/commands/LayoutTreeCrawler.ts b/lib/src/commands/LayoutTreeCrawler.ts index eb99e199b28..907eb512eef 100644 --- a/lib/src/commands/LayoutTreeCrawler.ts +++ b/lib/src/commands/LayoutTreeCrawler.ts @@ -1,9 +1,6 @@ -import merge from 'lodash/merge'; -import isFunction from 'lodash/isFunction'; import { LayoutType } from './LayoutType'; import { OptionsProcessor } from './OptionsProcessor'; import { Store } from '../components/Store'; -import { Options } from '../interfaces/Options'; import { CommandName } from '../interfaces/CommandName'; export interface Data { @@ -18,8 +15,6 @@ export interface LayoutNode { children: LayoutNode[]; } -type ComponentWithOptions = React.ComponentType & { options(passProps: any): Options }; - export class LayoutTreeCrawler { constructor(public readonly store: Store, private readonly optionsProcessor: OptionsProcessor) { this.crawl = this.crawl.bind(this); @@ -36,7 +31,6 @@ export class LayoutTreeCrawler { private handleComponent(node: LayoutNode) { this.assertComponentDataName(node); this.savePropsToStore(node); - this.applyStaticOptions(node); node.data.passProps = undefined; } @@ -44,25 +38,6 @@ export class LayoutTreeCrawler { this.store.updateProps(node.id, node.data.passProps); } - private isComponentWithOptions(component: any): component is ComponentWithOptions { - return (component as ComponentWithOptions).options !== undefined; - } - - private applyStaticOptions(node: LayoutNode) { - node.data.options = merge({}, this.staticOptionsIfPossible(node), node.data.options); - } - - private staticOptionsIfPossible(node: LayoutNode) { - const foundReactGenerator = this.store.getComponentClassForName(node.data.name!); - const reactComponent = foundReactGenerator ? foundReactGenerator() : undefined; - if (reactComponent && this.isComponentWithOptions(reactComponent)) { - return isFunction(reactComponent.options) - ? reactComponent.options({ componentId: node.id, ...node.data.passProps } || {}) - : reactComponent.options; - } - return {}; - } - private assertComponentDataName(component: LayoutNode) { if (!component.data.name) { throw new Error('Missing component data.name'); diff --git a/lib/src/commands/OptionsCrawler.test.ts b/lib/src/commands/OptionsCrawler.test.ts new file mode 100644 index 00000000000..48b8497f9c4 --- /dev/null +++ b/lib/src/commands/OptionsCrawler.test.ts @@ -0,0 +1,348 @@ +import * as React from 'react'; + +import { Store } from '../components/Store'; +import { mock, instance, when } from 'ts-mockito'; +import { Options } from '../interfaces/Options'; +import { OptionsCrawler } from './OptionsCrawler'; +import { Layout } from '../interfaces/Layout'; + +describe('OptionsCrawler', () => { + let uut: OptionsCrawler; + let mockedStore: Store; + + beforeEach(() => { + mockedStore = mock(Store); + uut = new OptionsCrawler(instance(mockedStore)); + }); + + it('Components: injects options object', () => { + when(mockedStore.getComponentClassForName('theComponentName')).thenReturn( + () => + class extends React.Component { + static options = { popGesture: true }; + } + ); + const layout: Layout = { + component: { + id: 'testId', + name: 'theComponentName', + }, + }; + + uut.crawl(layout); + expect(layout.component!.options).toEqual({ popGesture: true }); + }); + + it('Components: injects options from original component class static property', () => { + when(mockedStore.getComponentClassForName('theComponentName')).thenReturn( + () => + class extends React.Component { + static options(): Options { + return { popGesture: true }; + } + } + ); + const layout: Layout = { + component: { + id: 'testId', + name: 'theComponentName', + }, + }; + + uut.crawl(layout); + expect(layout.component!.options).toEqual({ popGesture: true }); + }); + + it('ExternalComponent: does nothing as there is no React component for external component', () => { + const layout: Layout = { + externalComponent: { + id: 'testId', + name: 'theComponentName', + }, + }; + + uut.crawl(layout); + expect(layout.externalComponent!.options).toEqual(undefined); + }); + + it('ExternalComponent: merge options with passed options', () => { + const layout: Layout = { + externalComponent: { + id: 'testId', + name: 'theComponentName', + options: { + popGesture: false, + }, + }, + }; + + uut.crawl(layout); + expect(layout.externalComponent!.options).toEqual({ popGesture: false }); + }); + + it('Stack: injects options from original component class static property', () => { + when(mockedStore.getComponentClassForName('theComponentName')).thenReturn( + () => + class extends React.Component { + static options(): Options { + return { popGesture: true }; + } + } + ); + const layout: Layout = { + stack: { + children: [ + { + component: { + id: 'testId', + name: 'theComponentName', + }, + }, + ], + }, + }; + + uut.crawl(layout); + expect(layout.stack!.children![0].component!.options).toEqual({ popGesture: true }); + }); + + it('SideMenu: injects options from original component class static property', () => { + when(mockedStore.getComponentClassForName('theComponentName')).thenReturn( + () => + class extends React.Component { + static options(): Options { + return { popGesture: true }; + } + } + ); + const layout: Layout = { + sideMenu: { + left: { + component: { + id: 'testId', + name: 'theComponentName', + }, + }, + center: { + component: { + id: 'testId', + name: 'theComponentName', + }, + }, + right: { + component: { + id: 'testId', + name: 'theComponentName', + }, + }, + }, + }; + + uut.crawl(layout); + expect(layout.sideMenu!.center!.component!.options).toEqual({ popGesture: true }); + expect(layout.sideMenu!.left!.component!.options).toEqual({ popGesture: true }); + expect(layout.sideMenu!.right!.component!.options).toEqual({ popGesture: true }); + }); + + it('SplitView: injects options from original component class static property', () => { + when(mockedStore.getComponentClassForName('theComponentName')).thenReturn( + () => + class extends React.Component { + static options(): Options { + return { popGesture: true }; + } + } + ); + const layout: Layout = { + splitView: { + master: { + component: { + id: 'testId', + name: 'theComponentName', + }, + }, + detail: { + component: { + id: 'testId', + name: 'theComponentName', + }, + }, + }, + }; + + uut.crawl(layout); + expect(layout.splitView!.master!.component!.options).toEqual({ popGesture: true }); + expect(layout.splitView!.detail!.component!.options).toEqual({ popGesture: true }); + }); + + it('BottomTabs: injects options from original component class static property', () => { + when(mockedStore.getComponentClassForName('theComponentName')).thenReturn( + () => + class extends React.Component { + static options(): Options { + return { popGesture: true }; + } + } + ); + const layout: Layout = { + bottomTabs: { + children: [ + { + component: { + id: 'testId', + name: 'theComponentName', + }, + }, + { + component: { + id: 'testId', + name: 'theComponentName', + }, + }, + ], + }, + }; + + uut.crawl(layout); + expect(layout.bottomTabs!.children![0].component!.options).toEqual({ popGesture: true }); + expect(layout.bottomTabs!.children![1].component!.options).toEqual({ popGesture: true }); + }); + + it('TopTabs: injects options from original component class static property', () => { + when(mockedStore.getComponentClassForName('theComponentName')).thenReturn( + () => + class extends React.Component { + static options(): Options { + return { popGesture: true }; + } + } + ); + const layout: Layout = { + topTabs: { + children: [ + { + component: { + id: 'testId', + name: 'theComponentName', + }, + }, + { + component: { + id: 'testId', + name: 'theComponentName', + }, + }, + ], + }, + }; + + uut.crawl(layout); + expect(layout.topTabs!.children![0].component!.options).toEqual({ popGesture: true }); + expect(layout.topTabs!.children![1].component!.options).toEqual({ popGesture: true }); + }); + + it('Components: merges options from component class static property with passed options, favoring passed options', () => { + when(mockedStore.getComponentClassForName('theComponentName')).thenReturn( + () => + class extends React.Component { + static options(): Options { + return { + topBar: { + title: { text: 'this gets overriden' }, + subtitle: { text: 'exists only in static' }, + }, + }; + } + } + ); + + const node = { + component: { + id: 'testId', + name: 'theComponentName', + options: { + topBar: { + title: { + text: 'exists only in passed', + }, + }, + }, + }, + }; + + uut.crawl(node); + + expect(node.component.options).toEqual({ + topBar: { + title: { + text: 'exists only in passed', + }, + subtitle: { + text: 'exists only in static', + }, + }, + }); + }); + + it('Components: options default obj', () => { + when(mockedStore.getComponentClassForName('theComponentName')).thenReturn( + () => class extends React.Component {} + ); + + const node = { + component: { name: 'theComponentName', options: {}, id: 'testId' }, + children: [], + }; + uut.crawl(node); + expect(node.component.options).toEqual({}); + }); + + it('componentId is included in props passed to options generator', () => { + let componentIdInProps: String = ''; + + when(mockedStore.getComponentClassForName('theComponentName')).thenReturn( + () => + class extends React.Component { + static options(props: any) { + componentIdInProps = props.componentId; + return {}; + } + } + ); + const node = { + component: { + id: 'testId', + name: 'theComponentName', + passProps: { someProp: 'here' }, + }, + }; + uut.crawl(node); + expect(componentIdInProps).toEqual('testId'); + }); + + it('componentId does not override componentId in passProps', () => { + let componentIdInProps: String = ''; + + when(mockedStore.getComponentClassForName('theComponentName')).thenReturn( + () => + class extends React.Component { + static options(props: any) { + componentIdInProps = props.componentId; + return {}; + } + } + ); + const node = { + component: { + id: 'testId', + name: 'theComponentName', + passProps: { + someProp: 'here', + componentId: 'compIdFromPassProps', + }, + }, + }; + uut.crawl(node); + expect(componentIdInProps).toEqual('compIdFromPassProps'); + }); +}); diff --git a/lib/src/commands/OptionsCrawler.ts b/lib/src/commands/OptionsCrawler.ts new file mode 100644 index 00000000000..bfe25abe9c4 --- /dev/null +++ b/lib/src/commands/OptionsCrawler.ts @@ -0,0 +1,85 @@ +import merge from 'lodash/merge'; +import isFunction from 'lodash/isFunction'; +import { Store } from '../components/Store'; +import { Options } from '../interfaces/Options'; +import { + Layout, + LayoutBottomTabs, + LayoutComponent, + LayoutSideMenu, + LayoutSplitView, + LayoutStack, + LayoutTopTabs, +} from '../interfaces/Layout'; + +type ComponentWithOptions = React.ComponentType & { options(passProps: any): Options }; + +export class OptionsCrawler { + constructor(public readonly store: Store) { + this.crawl = this.crawl.bind(this); + } + + crawl(api?: Layout): void { + if (!api) return; + if (api.topTabs) { + this.topTabs(api.topTabs); + } else if (api.sideMenu) { + return this.sideMenu(api.sideMenu); + } else if (api.bottomTabs) { + return this.bottomTabs(api.bottomTabs); + } else if (api.stack) { + return this.stack(api.stack); + } else if (api.component) { + return this.component(api.component); + } else if (api.splitView) { + return this.splitView(api.splitView); + } + } + + private topTabs(api: LayoutTopTabs): void { + api.children?.map(this.crawl); + } + + private sideMenu(sideMenu: LayoutSideMenu): void { + this.crawl(sideMenu.center); + this.crawl(sideMenu.left); + this.crawl(sideMenu.right); + } + + private bottomTabs(bottomTabs: LayoutBottomTabs): void { + bottomTabs.children?.map(this.crawl); + } + + private stack(stack: LayoutStack): void { + stack.children?.map(this.crawl); + } + + private splitView(splitView: LayoutSplitView): void { + splitView.detail && this.crawl(splitView.detail); + splitView.master && this.crawl(splitView.master); + } + + private component(component: LayoutComponent): void { + this.applyStaticOptions(component); + } + + private isComponentWithOptions(component: any): component is ComponentWithOptions { + return (component as ComponentWithOptions).options !== undefined; + } + + private applyStaticOptions(layout: LayoutComponent) { + const staticOptions = this.staticOptionsIfPossible(layout); + layout.options = merge({}, staticOptions, layout.options); + } + + private staticOptionsIfPossible(layout: LayoutComponent) { + const foundReactGenerator = this.store.getComponentClassForName(layout.name!); + const reactComponent = foundReactGenerator ? foundReactGenerator() : undefined; + if (reactComponent && this.isComponentWithOptions(reactComponent)) { + return isFunction(reactComponent.options) + ? reactComponent.options({ componentId: layout.id, ...layout.passProps } || {}) + : reactComponent.options; + } + return {}; + } +} diff --git a/lib/src/interfaces/Layout.ts b/lib/src/interfaces/Layout.ts index aee17a02ded..076cb56c9c0 100644 --- a/lib/src/interfaces/Layout.ts +++ b/lib/src/interfaces/Layout.ts @@ -90,7 +90,7 @@ export interface LayoutSideMenu { /** * Set the left side bar */ - left?: LayoutStackChildren; + left?: Layout; /** * Set the center view */ @@ -98,7 +98,7 @@ export interface LayoutSideMenu { /** * Set the right side bar */ - right?: LayoutStackChildren; + right?: Layout; /** * Set the bottom tabs options */