feat: useAction hook (#261)

This commit is contained in:
Miralem Drek
2020-01-17 17:32:04 +01:00
committed by GitHub
parent 990ed00202
commit e2e075c652
6 changed files with 181 additions and 10 deletions

View File

@@ -7,11 +7,13 @@ import {
teardown,
run,
runSnaps,
observeActions,
useState,
useEffect,
useLayoutEffect,
useMemo,
usePromise,
useAction,
useRect,
useModel,
useApp,
@@ -57,6 +59,7 @@ describe('hooks', () => {
run,
teardown,
runSnaps,
observeActions,
});
});
@@ -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,
});
});
});
@@ -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;
@@ -576,6 +656,7 @@ describe('hooks', () => {
run(c);
expect(value).to.equal('model');
});
it('useApp', () => {
let value;
c.fn = () => {

View File

@@ -33,6 +33,7 @@ const defaultComponent = /** @lends SnComponent */ {
getViewState: () => {},
// temporary
observeActions() {},
setSnapshotData: snapshot => Promise.resolve(snapshot),
};
@@ -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,
@@ -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];
}

View File

@@ -35,6 +35,7 @@ export function initiate(component) {
},
list: [],
snaps: [],
actions: [],
pendingEffects: [],
pendingLayoutEffects: [],
pendingPromises: [],
@@ -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);
@@ -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.');
@@ -203,6 +218,7 @@ export function hook(cb) {
run,
teardown,
runSnaps,
observeActions,
};
}
@@ -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.');
}
}
@@ -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(() => {

View File

@@ -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';

View File

@@ -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 {
@@ -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(
() =>
@@ -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>
`;
},

View File

@@ -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',
@@ -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');