Skip to content

Commit

Permalink
Allow to update props for a specific component (#5612)
Browse files Browse the repository at this point in the history
This commit adds support to update props of screen or custom button/title via the mergeOptions api.

```js
Navigation.mergeOptions('myComponentId', {
  passProps: {
    text: 'new value'
  }
});
```
  • Loading branch information
justtal authored and guyca committed Oct 29, 2019
1 parent 094b9a7 commit 291f161
Show file tree
Hide file tree
Showing 18 changed files with 268 additions and 111 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
dist
.vscode/
package-lock.json
.history/

############
# Node
Expand Down
4 changes: 2 additions & 2 deletions docs/api/Store.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,9 @@

---

## cleanId
## clearComponent

`cleanId(id: string): void`
`clearComponent(id: string): void`

[source](https://github.com/wix/react-native-navigation/blob/v2/lib/src/components/Store.ts#L23)

Expand Down
48 changes: 48 additions & 0 deletions e2e/Buttons.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
const Utils = require('./Utils');
const TestIDs = require('../playground/src/testIDs');

const { elementById, elementByLabel } = Utils;

describe('Buttons', () => {
beforeEach(async () => {
await device.relaunchApp();
await elementById(TestIDs.OPTIONS_TAB).tap();
await elementById(TestIDs.GOTO_BUTTONS_SCREEN).tap();
});

it('sets right buttons', async () => {
await expect(elementById(TestIDs.BUTTON_ONE)).toBeVisible();
await expect(elementById(TestIDs.ROUND_BUTTON)).toBeVisible();
});

it('set left buttons', async () => {
await expect(elementById(TestIDs.LEFT_BUTTON)).toBeVisible();
});

it('pass props to custom button component', async () => {
await expect(elementByLabel('Two')).toExist();
});

it('pass props to custom button component should exist after push pop', async () => {
await expect(elementByLabel('Two')).toExist();
await elementById(TestIDs.PUSH_BTN).tap();
await elementById(TestIDs.POP_BTN).tap();
await expect(elementByLabel('Two')).toExist();
});

it('custom button is clickable', async () => {
await elementByLabel('Two').tap();
await expect(elementByLabel('Thanks for that :)')).toExist();
});

it(':ios: Reseting buttons should unmount button react view', async () => {
await elementById(TestIDs.SHOW_LIFECYCLE_BTN).tap();
await elementById(TestIDs.RESET_BUTTONS).tap();
await expect(elementByLabel('Button component unmounted')).toBeVisible();
});

it('change button props without rendering all buttons', async () => {
await elementById(TestIDs.CHANGE_BUTTON_PROPS).tap();
await expect(elementByLabel('Three')).toBeVisible();
});
});
31 changes: 0 additions & 31 deletions e2e/Options.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,31 +27,6 @@ describe('Options', () => {
await expect(elementById(TestIDs.TOP_BAR)).toBeVisible();
});

it('sets right buttons', async () => {
await expect(elementById(TestIDs.BUTTON_ONE)).toBeVisible();
await expect(elementById(TestIDs.ROUND_BUTTON)).toBeVisible();
});

it('set left buttons', async () => {
await expect(elementById(TestIDs.LEFT_BUTTON)).toBeVisible();
});

it('pass props to custom button component', async () => {
await expect(elementByLabel('Two')).toExist();
});

it('pass props to custom button component should exist after push pop', async () => {
await expect(elementByLabel('Two')).toExist();
await elementById(TestIDs.PUSH_BTN).tap();
await elementById(TestIDs.POP_BTN).tap();
await expect(elementByLabel('Two')).toExist();
});

it('custom button is clickable', async () => {
await elementByLabel('Two').tap();
await expect(elementByLabel('Thanks for that :)')).toExist();
});

it('default options should apply to all screens in stack', async () => {
await elementById(TestIDs.HIDE_TOPBAR_DEFAULT_OPTIONS).tap();
await expect(elementById(TestIDs.TOP_BAR)).toBeVisible();
Expand Down Expand Up @@ -88,12 +63,6 @@ describe('Options', () => {
await expect(elementByLabel('Styling Options')).toBeVisible();
});

it(':ios: Reseting buttons should unmount button react view', async () => {
await elementById(TestIDs.SHOW_LIFECYCLE_BTN).tap();
await elementById(TestIDs.RESET_BUTTONS).tap();
await expect(elementByLabel('Button component unmounted')).toBeVisible();
});

xit('hides topBar onScroll down and shows it on scroll up', async () => {
await elementById(TestIDs.PUSH_OPTIONS_BUTTON).tap();
await elementById(TestIDs.SCROLLVIEW_SCREEN_BUTTON).tap();
Expand Down
4 changes: 2 additions & 2 deletions lib/src/commands/Commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ export class Commands {

public mergeOptions(componentId: string, options: Options) {
const input = _.cloneDeep(options);
this.optionsProcessor.processOptions(input);
this.optionsProcessor.processOptions(input, componentId);

this.nativeCommandsSender.mergeOptions(componentId, input);
this.commandsObserver.notify('mergeOptions', { componentId, options });
Expand All @@ -64,7 +64,7 @@ export class Commands {
public showModal(layout: Layout) {
const layoutCloned = _.cloneDeep(layout);
const layoutNode = this.layoutTreeParser.parse(layoutCloned);

const commandId = this.uniqueIdProvider.generate('showModal');
this.commandsObserver.notify('showModal', { commandId, layout: layoutNode });
this.layoutTreeCrawler.crawl(layoutNode);
Expand Down
10 changes: 10 additions & 0 deletions lib/src/commands/OptionsProcessor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,4 +135,14 @@ describe('navigation options', () => {
expect(options.topBar.title.component.passProps).toBeUndefined();
expect(options.topBar.background.component.passProps).toBeUndefined();
});

it('calls store when component has passProps component id and values', () => {
const props = { prop: 'updated prop' };
const options = { passProps: props };

uut.processOptions(options, 'component1');

verify(mockedStore.setPropsForId('component1', props)).called();
expect(options.passProps).toBeUndefined();
});
});
15 changes: 12 additions & 3 deletions lib/src/commands/OptionsProcessor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,17 +14,19 @@ export class OptionsProcessor {
private assetService: AssetService,
) {}

public processOptions(options: Options) {
this.processObject(options);
public processOptions(options: Options, componentId?: string) {
this.processObject(options, componentId);
}

private processObject(objectToProcess: object) {
private processObject(objectToProcess: object, componentId?: string) {
_.forEach(objectToProcess, (value, key) => {
this.processColor(key, value, objectToProcess);

if (!value) {
return;
}

this.processProps(key, value, objectToProcess, componentId);
this.processComponent(key, value, objectToProcess);
this.processImage(key, value, objectToProcess);
this.processButtonsPassProps(key, value);
Expand Down Expand Up @@ -72,4 +74,11 @@ export class OptionsProcessor {
options[key].passProps = undefined;
}
}

private processProps(key: string, value: any, options: Record<string, any>, componentId?: string) {
if (key === 'passProps' && componentId && value) {
this.store.setPropsForId(componentId, value);
options[key] = undefined;
}
}
}
19 changes: 15 additions & 4 deletions lib/src/components/ComponentWrapper.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,13 +96,18 @@ describe('ComponentWrapper', () => {

it('updates props from store into inner component', () => {
const NavigationComponent = uut.wrap(componentName, () => MyComponent, store, componentEventsObserver);
const tree = renderer.create(<TestParent ChildClass={NavigationComponent} />);
renderer.create(<TestParent ChildClass={NavigationComponent} />);
expect(myComponentProps.myProp).toEqual(undefined);
store.setPropsForId('component1', { myProp: 'hello' });
expect(myComponentProps.myProp).toEqual('hello');
});

it('updates props from state into inner component', () => {
const NavigationComponent = uut.wrap(componentName, () => MyComponent, store, componentEventsObserver);
const tree = renderer.create(<TestParent ChildClass={NavigationComponent} />);
expect(myComponentProps.foo).toEqual(undefined);
expect(myComponentProps.myProp).toEqual(undefined);
(tree.getInstance() as any).setState({ propsFromState: { foo: 'yo' } });
expect(myComponentProps.foo).toEqual('yo');
expect(myComponentProps.myProp).toEqual('hello');
});

it('protects id from change', () => {
Expand Down Expand Up @@ -157,6 +162,12 @@ describe('ComponentWrapper', () => {
expect(tree.root.findByType(MyComponent).props).toEqual({componentId: 'component123'});
});

it('sets component instance in store when constructed', () => {
const NavigationComponent = uut.wrap(componentName, () => MyComponent, store, componentEventsObserver);
renderer.create(<NavigationComponent componentId={'component1'} />);
expect(store.getComponentInstance('component1')).toBeTruthy();
});

describe(`register with redux store`, () => {
class MyReduxComp extends React.Component<any> {
static options() {
Expand All @@ -179,7 +190,7 @@ describe('ComponentWrapper', () => {
const ConnectedComp = require('react-redux').connect(mapStateToProps)(MyReduxComp);
const ReduxProvider = require('react-redux').Provider;
const initialState: RootState = { txt: 'it just works' };
const reduxStore = require('redux').createStore((state = initialState) => state);
const reduxStore = require('redux').createStore((state: RootState = initialState) => state);

it(`wraps the component with a react-redux provider with passed store`, () => {
const NavigationComponent = uut.wrap(componentName, () => ConnectedComp, store, componentEventsObserver, undefined, ReduxProvider, reduxStore);
Expand Down
11 changes: 10 additions & 1 deletion lib/src/components/ComponentWrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ import { ComponentEventsObserver } from '../events/ComponentEventsObserver';
interface HocState { componentId: string; allProps: {}; }
interface HocProps { componentId: string; }

export interface IWrappedComponent extends React.Component {
setProps(newProps: Record<string, any>): void;
}

export class ComponentWrapper {
wrap(
componentName: string | number,
Expand All @@ -35,10 +39,15 @@ export class ComponentWrapper {
componentId: props.componentId,
allProps: {}
};
store.setComponentInstance(props.componentId, this);
}

public setProps(newProps: any) {
this.setState({allProps: newProps});
}

componentWillUnmount() {
store.cleanId(this.state.componentId);
store.clearComponent(this.state.componentId);
componentEventsObserver.unmounted(this.state.componentId);
}

Expand Down
30 changes: 27 additions & 3 deletions lib/src/components/Store.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as React from 'react';
import { Store } from './Store';
import { IWrappedComponent } from './ComponentWrapper';

describe('Store', () => {
let uut: Store;
Expand Down Expand Up @@ -28,11 +29,34 @@ describe('Store', () => {
expect(uut.getComponentClassForName('example.mycomponent')).toEqual(MyWrappedComponent);
});

it('clean by component id', () => {
it('clear props by component id when clear component', () => {
uut.setPropsForId('refUniqueId', { foo: 'bar' });
uut.clearComponent('refUniqueId');
expect(uut.getPropsForId('refUniqueId')).toEqual({});
});

uut.cleanId('refUniqueId');
it('clear instance by component id when clear component', () => {
uut.setComponentInstance('refUniqueId', ({} as IWrappedComponent));
uut.clearComponent('refUniqueId');
expect(uut.getComponentInstance('refUniqueId')).toEqual(undefined);
});

expect(uut.getPropsForId('refUniqueId')).toEqual({});
it('holds component instance by id', () => {
uut.setComponentInstance('component1', ({} as IWrappedComponent));
expect(uut.getComponentInstance('component1')).toEqual({});
});

it('calls component setProps when set props by id', () => {
const instance: any = {setProps: jest.fn()};
const props = { foo: 'bar' };

uut.setComponentInstance('component1', instance);
uut.setPropsForId('component1', props);

expect(instance.setProps).toHaveBeenCalledWith(props);
});

it('not throw exeption when set props by id component not found', () => {
expect(() => uut.setPropsForId('component1', { foo: 'bar' })).not.toThrow();
});
});
18 changes: 17 additions & 1 deletion lib/src/components/Store.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,27 @@
import { ComponentProvider } from 'react-native';
import { IWrappedComponent } from './ComponentWrapper';

export class Store {
private componentsByName: Record<string, ComponentProvider> = {};
private propsById: Record<string, any> = {};
private componentsInstancesById: Record<string, IWrappedComponent> = {};

setPropsForId(componentId: string, props: any) {
this.propsById[componentId] = props;
const component = this.componentsInstancesById[componentId];

if (component) {
this.componentsInstancesById[componentId].setProps(props);
}
}

getPropsForId(componentId: string) {
return this.propsById[componentId] || {};
}

cleanId(componentId: string) {
clearComponent(componentId: string) {
delete this.propsById[componentId];
delete this.componentsInstancesById[componentId];
}

setComponentClassForName(componentName: string | number, ComponentClass: ComponentProvider) {
Expand All @@ -23,4 +31,12 @@ export class Store {
getComponentClassForName(componentName: string | number): ComponentProvider | undefined {
return this.componentsByName[componentName.toString()];
}

setComponentInstance(id: string, component: IWrappedComponent): void {
this.componentsInstancesById[id] = component;
}

getComponentInstance(id: string): IWrappedComponent {
return this.componentsInstancesById[id];
}
}
12 changes: 8 additions & 4 deletions lib/src/interfaces/Options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -536,15 +536,15 @@ export interface OptionsBottomTabs {

export interface DotIndicatorOptions {
// default red
color?: Color,
color?: Color;
// default 6
size?: number,
size?: number;
// default false
visible?: boolean,
visible?: boolean;
}

export interface OptionsBottomTab {
dotIndicator?: DotIndicatorOptions,
dotIndicator?: DotIndicatorOptions;

/**
* Set the text to display below the icon
Expand Down Expand Up @@ -1019,4 +1019,8 @@ setRoot: {
* @default false
*/
blurOnUnmount?: boolean;
/**
* Props to pass to a component
*/
passProps?: Record<string, any>;
}
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"typings": "lib/dist/index.d.ts",
"scripts": {
"build": "rm -rf ./lib/dist && tsc",
"watch": "rm -rf ./lib/dist && tsc --watch",
"xcode": "open playground/ios/playground.xcodeproj",
"install-android": "node ./scripts/install-android",
"uninstall-android": "cd playground/android && ./gradlew uninstallAll",
Expand Down
Loading

0 comments on commit 291f161

Please sign in to comment.