Skip to content

Commit

Permalink
Merge pull request #214 from Financial-Times/x-interaction-rewrap
Browse files Browse the repository at this point in the history
new x-interaction features
  • Loading branch information
apaleslimghost authored Nov 6, 2018
2 parents a9bc8ed + 55b226c commit 0938538
Show file tree
Hide file tree
Showing 4 changed files with 118 additions and 42 deletions.
74 changes: 62 additions & 12 deletions components/x-interaction/__tests__/x-interaction.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,23 @@ describe('x-interaction', () => {
).toBe(10);
});

it('should update props of base using async updater function from action', async () => {
const Base = () => null;
const Wrapped = withActions({
foo: () => async ({bar}) => ({ bar: bar + 5 }),
})(Base);

const target = mount(<Wrapped bar={5} />);

await target.find(Base).prop('actions').foo();
target.update();

expect(
target.find(Base).prop('bar')
).toBe(10);
});


it('should wait for promises and apply resolved state updates', async () => {
const Base = () => null;
const Wrapped = withActions({
Expand Down Expand Up @@ -202,23 +219,42 @@ describe('x-interaction', () => {
).toBe(15);
});

it('should pass actions to actionsRef on mount and null on unmount', async () => {
const actionsRef = jest.fn();
describe('actionsRef', () => {
it('should pass actions to actionsRef on mount and null on unmount', async () => {
const actionsRef = jest.fn();

const Base = () => null;
const Wrapped = withActions({
foo() {},
})(Base);
const Base = () => null;
const Wrapped = withActions({
foo() {},
})(Base);

const target = mount(<Wrapped actionsRef={actionsRef} />);

expect(actionsRef).toHaveBeenCalled();
expect(actionsRef.mock.calls[0][0]).toHaveProperty('foo');

const target = mount(<Wrapped actionsRef={actionsRef} />);
target.unmount();

expect(actionsRef).toHaveBeenLastCalledWith(null);
});

expect(actionsRef).toHaveBeenCalled();
expect(actionsRef.mock.calls[0][0]).toHaveProperty('foo');
it('should pass all actions for rewrapped components', async () => {
const actionsRef = jest.fn();

target.unmount();
const Base = () => null;
const Wrapped = withActions({
bar() {},
})(withActions({
foo() {},
})(Base));

expect(actionsRef).toHaveBeenLastCalledWith(null);
});
mount(<Wrapped actionsRef={actionsRef} />);

expect(actionsRef).toHaveBeenCalled();
expect(actionsRef.mock.calls[0][0]).toHaveProperty('foo');
expect(actionsRef.mock.calls[0][0]).toHaveProperty('bar');
});
});

it(`shouldn't reset props when others change`, async () => {
const Base = () => null;
Expand All @@ -241,6 +277,20 @@ describe('x-interaction', () => {
target.find(Base).prop('quux')
).toBe(10);
});

it('should get default state from second argument', async () => {
const Base = () => null;
const Wrapped = withActions({}, {
foo: 5
})(Base);

const target = mount(<Wrapped />);

expect(
target.find(Base).prop('foo')
).toBe(5);
});

});

describe.skip('server rendering');
Expand Down
45 changes: 34 additions & 11 deletions components/x-interaction/src/Interaction.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,32 +7,55 @@ import { registerComponent } from './concerns/register-component';
// use the class version for clientside and the static version for server
const Interaction = typeof window !== 'undefined' ? InteractionClass : InteractionSSR;

export const withActions = (getActions) => (Component) => {
const invoke = (fnOrObj, ...args) => typeof fnOrObj === 'function'
? fnOrObj(...args)
: fnOrObj;

export const withActions = (getActions, getDefaultState = {}) => (Component) => {
const _wraps = { getActions, getDefaultState, Component };

// if the component we're wrapping is already wrapped, we don't want
// to wrap it further. so, discard its wrapper and rewrap the original
// component with the new actions on top
if(Component._wraps) {
const wrappedGetActions = Component._wraps.getActions;
const wrappedGetDefaultState = Component._wraps.getDefaultState;

Component = Component._wraps.Component;

getActions = initialState => Object.assign(
invoke(wrappedGetActions, initialState),
invoke(_wraps.getActions, initialState)
);

getDefaultState = initialState => Object.assign(
invoke(wrappedGetDefaultState, initialState),
invoke(_wraps.getDefaultState, initialState)
);
}

function Enhanced({
id,
actions: extraActions,
actionsRef,
serialiser,
...initialState
}) {
// support passing actions to withActions as an object or a function
// that's called with the initial state
const actions = typeof getActions === 'function'
? getActions(initialState)
: getActions;
const actions = invoke(getActions, initialState);
const defaultState = invoke(getDefaultState, initialState);

return <Interaction {...{
id,
Component,
initialState,
initialState: Object.assign({}, defaultState, initialState),
actionsRef,
serialiser,
// if extraActions is defined, those are from another level
// of wrapping with withActions, so those should take precedence
actions: Object.assign(actions, extraActions),
actions,
}} />;
}

// store what we're wrapping for later wrappers to replace
Enhanced._wraps = _wraps;

// set the displayName of the Enhanced component for debugging
wrapComponentName(Component, Enhanced);

Expand Down
38 changes: 19 additions & 19 deletions components/x-interaction/src/InteractionClass.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,32 +11,32 @@ export class InteractionClass extends Component {
inFlight: 0,
};

this.actions = mapValues(props.actions, (func) => (...args) => {
this.actions = mapValues(props.actions, (func) => async (...args) => {
// mark as loading one microtask later. if the action is synchronous then
// setting loading back to false will happen in the same microtask and no
// additional render will be scheduled.
Promise.resolve().then(() => {
this.setState(({ inFlight }) => ({ inFlight: inFlight + 1 }));
});

return Promise.resolve(func(...args)).then((next) => {
const updater = typeof next === 'function'
? ({state}) => ({state: Object.assign(
state,
next(Object.assign(
{},
props.initialState,
state
))
)})
: ({state}) => ({state: Object.assign(state, next)});

return new Promise(resolve =>
this.setState(updater, () => (
this.setState(({ inFlight }) => ({ inFlight: inFlight - 1 }), resolve)
))
);
});
const stateUpdate = await Promise.resolve(func(...args));

const nextState = typeof stateUpdate === 'function'
? Object.assign(
this.state.state,
await Promise.resolve(stateUpdate(Object.assign(
{},
props.initialState,
this.state.state
)))
)
: Object.assign(this.state.state, stateUpdate);

return new Promise(resolve =>
this.setState({state: nextState}, () => (
this.setState(({ inFlight }) => ({ inFlight: inFlight - 1 }), resolve)
))
);
});
}

Expand Down
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,14 @@
},
"dependencies": {
"@financial-times/athloi": "^1.0.0-beta.11",
"acorn": "^6.0.2",
"acorn-jsx": "^5.0.0",
"eslint": "^5.1.0",
"eslint-config-prettier": "^2.9.0",
"eslint-plugin-jest": "^21.17.0",
"eslint-plugin-jsx-a11y": "^6.1.1",
"eslint-plugin-react": "^7.10.0",
"espree": "^4.1.0",
"fetch-mock": "^6.5.2",
"jest": "^22.4.3",
"react": "^16.3.1",
Expand Down

0 comments on commit 0938538

Please sign in to comment.