Skip to content

Commit

Permalink
feat: useAction hook (#261)
Browse files Browse the repository at this point in the history
  • Loading branch information
miralemd committed Jan 17, 2020
1 parent 990ed00 commit e2e075c
Show file tree
Hide file tree
Showing 6 changed files with 181 additions and 10 deletions.
85 changes: 83 additions & 2 deletions apis/supernova/src/__tests__/hooks.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,13 @@ import {
teardown,
run,
runSnaps,
observeActions,
useState,
useEffect,
useLayoutEffect,
useMemo,
usePromise,
useAction,
useRect,
useModel,
useApp,
Expand Down Expand Up @@ -57,6 +59,7 @@ describe('hooks', () => {
run,
teardown,
runSnaps,
observeActions,
});
});

Expand Down Expand Up @@ -92,13 +95,23 @@ describe('hooks', () => {
c = {
__hooks: {
list: [{ teardown: spy }],
pendingEffects: [],
pendingLayoutEffects: [],
pendingEffects: ['a'],
pendingLayoutEffects: ['a'],
actions: ['a'],
},
};

teardown(c);
expect(spy.callCount).to.equal(1);

expect(c.__hooks).to.eql({
obsolete: true,
list: [],
pendingEffects: [],
pendingLayoutEffects: [],
actions: [],
dispatchActions: null,
});
});
});

Expand Down Expand Up @@ -422,6 +435,73 @@ describe('hooks', () => {
});
});

describe('useAction', () => {
beforeEach(() => {
c = {};
initiate(c);
});
afterEach(() => {
teardown(c);
});

it('should execute callback', async () => {
let act;
const stub = sandbox.stub();
const spy = sandbox.spy();
stub.returns({
action: spy,
});
c.fn = () => {
[act] = useAction(stub, []);
};

run(c);
expect(stub.callCount).to.eql(1);
expect(spy.callCount).to.eql(0);

act();
expect(spy.callCount).to.eql(1);
});

it('should maintain reference', async () => {
const stub = sandbox.stub();
stub.returns({
action: 'action',
icon: 'ic',
active: true,
enabled: true,
});
c.fn = () => {
useAction(stub, []);
};

run(c);

const ref = c.__hooks.list[0].value[0];

expect(ref.active).to.eql(true);
expect(ref.getSvgIconShape()).to.eql('ic');
expect(ref.enabled).to.eql(true);
});

it('should dispatch actions', async () => {
const spy = sandbox.spy();
c.fn = () => {
useAction(() => ({ key: 'nyckel' }), []);
};

observeActions(c, spy);
expect(spy.callCount).to.eql(1);

run(c);
expect(spy.callCount).to.eql(2);

const actions = spy.getCall(1).args[0];

expect(actions[0].key).to.eql('nyckel');
});
});

describe('useRect', () => {
let element;

Expand Down Expand Up @@ -576,6 +656,7 @@ describe('hooks', () => {
run(c);
expect(value).to.equal('model');
});

it('useApp', () => {
let value;
c.fn = () => {
Expand Down
11 changes: 10 additions & 1 deletion apis/supernova/src/creator.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ const defaultComponent = /** @lends SnComponent */ {
getViewState: () => {},

// temporary
observeActions() {},
setSnapshotData: snapshot => Promise.resolve(snapshot),
};

Expand All @@ -54,13 +55,14 @@ function createWithHooks(generator, opts, env) {
console.warn('Detected multiple supernova modules, this might cause problems.');
}
}
const qGlobal = opts.app && opts.app.session ? opts.app.session.getObjectApi({ handle: -1 }) : null;
const c = {
context: {
element: undefined,
layout: {},
model: opts.model,
app: opts.app,
global: opts.global,
global: qGlobal,
selections: opts.selections,
},
env,
Expand Down Expand Up @@ -92,8 +94,15 @@ function createWithHooks(generator, opts, env) {
return generator.component.runSnaps(this, layout);
},
destroy() {},
observeActions(callback) {
generator.component.observeActions(this, callback);
},
};

Object.assign(c, {
selections: opts.selections,
});

return [c, null];
}

Expand Down
44 changes: 43 additions & 1 deletion apis/supernova/src/hooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export function initiate(component) {
},
list: [],
snaps: [],
actions: [],
pendingEffects: [],
pendingLayoutEffects: [],
pendingPromises: [],
Expand All @@ -48,6 +49,8 @@ export function teardown(component) {
component.__hooks.list.length = 0;
component.__hooks.pendingEffects.length = 0;
component.__hooks.pendingLayoutEffects.length = 0;
component.__hooks.actions.length = 0;
component.__hooks.dispatchActions = null;

clearTimeout(component.__hooks.micro);
cancelAnimationFrame(component.__hooks.macro);
Expand Down Expand Up @@ -149,6 +152,18 @@ export function runSnaps(component, layout) {
return Promise.resolve();
}

function dispatchActions(component) {
component._dispatchActions && component._dispatchActions(component.__hooks.actions.slice());
}

export function observeActions(component, callback) {
component._dispatchActions = callback;

if (component.__hooks) {
dispatchActions(component);
}
}

function getHook(idx) {
if (typeof currentComponent === 'undefined') {
throw new Error('Invalid nebula hook call. Hooks can only be called inside a supernova component.');
Expand Down Expand Up @@ -203,6 +218,7 @@ export function hook(cb) {
run,
teardown,
runSnaps,
observeActions,
};
}

Expand Down Expand Up @@ -261,7 +277,7 @@ export function useLayoutEffect(cb, deps) {

export function useMemo(cb, deps) {
if (__NEBULA_DEV__) {
if (!deps || !deps.length) {
if (!deps) {
console.warn('useMemo called without dependencies.');
}
}
Expand Down Expand Up @@ -335,6 +351,32 @@ export function usePromise(p, deps) {
}

// ---- composed hooks ------
export function useAction(fn, deps) {
const [ref] = useState({
action() {
ref._config.action.call(null);
},
});

if (!ref.component) {
ref.component = currentComponent;
currentComponent.__hooks.actions.push(ref);
}
useMemo(() => {
const a = fn();
ref._config = a;

ref.active = a.active || false;
ref.enabled = a.enabled !== false;
ref.getSvgIconShape = a.icon ? () => a.icon : undefined;

ref.key = a.key || ref.component.__hooks.actions.length;
dispatchActions(ref.component);
}, deps);

return [ref.action];
}

export function useRect() {
const element = useElement();
const [rect, setRect] = useState(() => {
Expand Down
1 change: 1 addition & 0 deletions apis/supernova/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export { useMemo } from './hooks';
export { usePromise } from './hooks';

// composed hooks
export { useAction } from './hooks';
export { useRect } from './hooks';
export { useModel } from './hooks';
export { useApp } from './hooks';
Expand Down
31 changes: 28 additions & 3 deletions test/component/hooks/hooked.fix.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
/* eslint import/no-extraneous-dependencies: 0 */

import { useState, useEffect, useLayout, useElement, useTheme, useTranslator, usePromise } from '@nebula.js/supernova';
import {
useState,
useEffect,
useLayout,
useElement,
useTheme,
useTranslator,
usePromise,
useAction,
} from '@nebula.js/supernova';

function sn() {
return {
Expand All @@ -11,16 +20,31 @@ function sn() {
const theme = useTheme();
const layout = useLayout();

const [acted, setActed] = useState(false);

const [act] = useAction(
() => ({
action() {
setActed(true);
},
}),
[]
);

useEffect(() => {
const listener = () => {
setCount(prev => prev + 1);
if (count >= 1) {
act();
} else {
setCount(prev => prev + 1);
}
};
element.addEventListener('click', listener);

return () => {
element.removeEventListener('click', listener);
};
}, [element]);
}, [element, count]);

const [v] = usePromise(
() =>
Expand All @@ -38,6 +62,7 @@ function sn() {
<div class="translator">${translator.get('Common.Cancel')}</div>
<div class="theme">${theme.getColorPickerColor({ index: 2 })}</div>
<div class="promise">${v || 'pending'}</div>
<div class="action">${acted}</div>
</div>
`;
},
Expand Down
19 changes: 16 additions & 3 deletions test/component/hooks/sn.comp.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,13 @@ describe('hooks', () => {
});

it('should render with initial state', async () => {
const text = await page.$eval(`${snSelector} .state`, el => el.textContent);
expect(text).to.equal('0');
const state = await page.$eval(`${snSelector} .state`, el => el.textContent);
expect(state).to.equal('0');
const action = await page.$eval(`${snSelector} .action`, el => el.textContent);
expect(action).to.equal('false');
});

it('should update count state after click', async () => {
it('should update count state after first click', async () => {
await page.click(snSelector);
await page.waitForFunction(
selector => document.querySelector(selector).textContent === '1',
Expand All @@ -31,6 +33,17 @@ describe('hooks', () => {
expect(text).to.equal('1');
});

it('should update action state after second click', async () => {
await page.click(snSelector);
await page.waitForFunction(
selector => document.querySelector(selector).textContent === 'true',
{},
`${snSelector} .action`
);
const text = await page.$eval(`${snSelector} .action`, el => el.textContent);
expect(text).to.equal('true');
});

it('useLayout', async () => {
const text = await page.$eval(`${snSelector} .layout`, el => el.textContent);
expect(text).to.equal('true');
Expand Down

0 comments on commit e2e075c

Please sign in to comment.