mirror of
https://github.com/qlik-oss/nebula.js.git
synced 2025-12-19 17:58:43 -05:00
feat: hooks (#252)
This commit is contained in:
@@ -13,7 +13,7 @@ describe('type', () => {
|
||||
satisfies = sb.stub();
|
||||
[{ default: create }] = aw.mock(
|
||||
[
|
||||
[require.resolve('@nebula.js/supernova'), () => SNFactory],
|
||||
[require.resolve('@nebula.js/supernova'), () => ({ generator: SNFactory })],
|
||||
['**/semver/functions/satisfies.js', () => satisfies],
|
||||
['**/load.js', () => ({ load })],
|
||||
],
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import SNFactory from '@nebula.js/supernova';
|
||||
import { generator as SNFactory } from '@nebula.js/supernova';
|
||||
import satisfies from 'semver/functions/satisfies';
|
||||
import { load } from './load';
|
||||
|
||||
|
||||
413
apis/supernova/src/__tests__/hooks.spec.js
Normal file
413
apis/supernova/src/__tests__/hooks.spec.js
Normal file
@@ -0,0 +1,413 @@
|
||||
/* eslint no-underscore-dangle: 0 */
|
||||
import {
|
||||
hook,
|
||||
initiate,
|
||||
teardown,
|
||||
render,
|
||||
runSnaps,
|
||||
useState,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useModel,
|
||||
useApp,
|
||||
useGlobal,
|
||||
useElement,
|
||||
useSelections,
|
||||
useTheme,
|
||||
useLayout,
|
||||
useTranslator,
|
||||
onTakeSnapshot,
|
||||
} from '../hooks';
|
||||
|
||||
const frame = () => new Promise(resolve => setTimeout(resolve));
|
||||
|
||||
describe('hooks', () => {
|
||||
let c;
|
||||
let sandbox;
|
||||
let DEV;
|
||||
before(() => {
|
||||
sandbox = sinon.createSandbox();
|
||||
DEV = global.__NEBULA_DEV__;
|
||||
global.__NEBULA_DEV__ = true;
|
||||
if (!global.requestAnimationFrame) {
|
||||
global.requestAnimationFrame = setTimeout;
|
||||
global.cancelAnimationFrame = clearTimeout;
|
||||
}
|
||||
});
|
||||
after(() => {
|
||||
global.__NEBULA_DEV__ = DEV;
|
||||
});
|
||||
afterEach(() => {
|
||||
sandbox.restore();
|
||||
});
|
||||
|
||||
it('hook should bind hooks to file scope', () => {
|
||||
const fn = 'fn';
|
||||
const h = hook(fn);
|
||||
expect(h).to.eql({
|
||||
__hooked: true,
|
||||
fn: 'fn',
|
||||
initiate,
|
||||
render,
|
||||
teardown,
|
||||
runSnaps,
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw when hook is used outside method context', () => {
|
||||
const fn = () => useState(0);
|
||||
|
||||
expect(fn).to.throw('Invalid nebula hook call. Hooks can only be called inside a supernova component.');
|
||||
});
|
||||
|
||||
it('should throw when hooks are used outside top level of method context', async () => {
|
||||
c = {};
|
||||
initiate(c);
|
||||
const err = sandbox.stub(console, 'error');
|
||||
|
||||
c.fn = () => {
|
||||
useEffect(() => {
|
||||
useState(0);
|
||||
});
|
||||
};
|
||||
|
||||
render(c);
|
||||
await frame();
|
||||
expect(err.args[0][0].message).to.equal(
|
||||
'Invalid nebula hook call. Hooks can only be called inside a supernova component.'
|
||||
);
|
||||
});
|
||||
|
||||
describe('teardown', () => {
|
||||
it('should teardown hooks', () => {
|
||||
const spy = sandbox.spy();
|
||||
c = {
|
||||
__hooks: {
|
||||
list: [{ teardown: spy }],
|
||||
pendingEffects: [],
|
||||
},
|
||||
};
|
||||
|
||||
teardown(c);
|
||||
expect(spy.callCount).to.equal(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runSnaps', () => {
|
||||
it('should run snaps hooks', async () => {
|
||||
const take1 = layout => {
|
||||
return Promise.resolve({ take1: 'yes', ...layout });
|
||||
};
|
||||
|
||||
c = {
|
||||
__hooks: {
|
||||
snaps: [{ fn: take1 }],
|
||||
},
|
||||
};
|
||||
|
||||
const s = await runSnaps(c, { a: '1' });
|
||||
expect(s).to.eql({ a: '1', take1: 'yes' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('useState', () => {
|
||||
beforeEach(() => {
|
||||
c = {};
|
||||
initiate(c);
|
||||
});
|
||||
afterEach(() => {
|
||||
teardown(c);
|
||||
});
|
||||
|
||||
it('should initiate state with value', () => {
|
||||
let countValue;
|
||||
c.fn = () => {
|
||||
[countValue] = useState(7);
|
||||
};
|
||||
|
||||
render(c);
|
||||
expect(countValue).to.equal(7);
|
||||
});
|
||||
|
||||
it('should initiate state with function', () => {
|
||||
let countValue;
|
||||
c.fn = () => {
|
||||
[countValue] = useState(() => 7);
|
||||
};
|
||||
|
||||
render(c);
|
||||
expect(countValue).to.equal(7);
|
||||
});
|
||||
|
||||
it('should update state value', async () => {
|
||||
let setter;
|
||||
let countValue;
|
||||
c.fn = () => {
|
||||
[countValue, setter] = useState(7);
|
||||
};
|
||||
|
||||
render(c);
|
||||
expect(countValue).to.equal(7);
|
||||
setter(12);
|
||||
await frame();
|
||||
expect(countValue).to.equal(12);
|
||||
});
|
||||
|
||||
it('should throw error when setState is called on an unmounted component', async () => {
|
||||
let setter;
|
||||
let countValue;
|
||||
c.fn = () => {
|
||||
[countValue, setter] = useState(7);
|
||||
};
|
||||
|
||||
await render(c);
|
||||
c.__hooks.obsolete = true;
|
||||
expect(setter).to.throw(
|
||||
'Calling setState on an unmounted component is a no-op and indicates a memory leak in your component.'
|
||||
);
|
||||
expect(countValue).to.equal(7);
|
||||
});
|
||||
|
||||
it('should update state value based on previous', async () => {
|
||||
let setter;
|
||||
let countValue;
|
||||
c.fn = () => {
|
||||
[countValue, setter] = useState(7);
|
||||
};
|
||||
|
||||
render(c);
|
||||
expect(countValue).to.equal(7);
|
||||
setter(prev => prev + 2);
|
||||
await frame();
|
||||
expect(countValue).to.equal(9);
|
||||
});
|
||||
|
||||
it('should not re-render when state has not changed', async () => {
|
||||
let setter;
|
||||
let num = 0;
|
||||
c.fn = () => {
|
||||
[, setter] = useState(7);
|
||||
++num;
|
||||
};
|
||||
|
||||
render(c);
|
||||
expect(num).to.equal(1);
|
||||
setter(7);
|
||||
await frame();
|
||||
expect(num).to.equal(1);
|
||||
});
|
||||
|
||||
it('should re-render only once when multiple states have changed', async () => {
|
||||
let setA;
|
||||
let setB;
|
||||
let setC;
|
||||
let num = 0;
|
||||
c.fn = () => {
|
||||
[, setA] = useState(7);
|
||||
[, setB] = useState(-4);
|
||||
[, setC] = useState(0);
|
||||
++num;
|
||||
};
|
||||
|
||||
render(c);
|
||||
expect(num).to.equal(1);
|
||||
setA(8);
|
||||
setB(-3);
|
||||
setC(1);
|
||||
await frame();
|
||||
expect(num).to.equal(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useMemo', () => {
|
||||
beforeEach(() => {
|
||||
c = {};
|
||||
initiate(c);
|
||||
});
|
||||
afterEach(() => {
|
||||
teardown(c);
|
||||
});
|
||||
|
||||
it('should run only when deps change', () => {
|
||||
const stub = sandbox.stub();
|
||||
stub.onFirstCall().returns(5);
|
||||
stub.onSecondCall().returns(6);
|
||||
let dep = 'a';
|
||||
let value;
|
||||
c.fn = () => {
|
||||
value = useMemo(stub, [dep]);
|
||||
};
|
||||
|
||||
render(c);
|
||||
render(c);
|
||||
expect(value).to.equal(5);
|
||||
|
||||
dep = 'b';
|
||||
render(c);
|
||||
expect(value).to.equal(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useEffect', () => {
|
||||
beforeEach(() => {
|
||||
c = {};
|
||||
initiate(c);
|
||||
});
|
||||
afterEach(() => {
|
||||
teardown(c);
|
||||
});
|
||||
|
||||
it('without deps should run after every render', async () => {
|
||||
const spy = sandbox.spy();
|
||||
c.fn = () => {
|
||||
useEffect(spy);
|
||||
};
|
||||
|
||||
await render(c);
|
||||
await render(c);
|
||||
await render(c);
|
||||
expect(spy.callCount).to.equal(3);
|
||||
});
|
||||
|
||||
it('with empty deps should run after first render', async () => {
|
||||
const spy = sandbox.spy();
|
||||
c.fn = () => {
|
||||
useEffect(spy, []);
|
||||
};
|
||||
|
||||
await render(c);
|
||||
await render(c);
|
||||
await render(c);
|
||||
expect(spy.callCount).to.equal(1);
|
||||
});
|
||||
|
||||
it('with deps should run only when deps change', async () => {
|
||||
const spy = sandbox.spy();
|
||||
let dep1 = 'a';
|
||||
let dep2 = 0;
|
||||
c.fn = () => {
|
||||
useEffect(spy, [dep1, dep2]);
|
||||
};
|
||||
|
||||
await render(c);
|
||||
|
||||
dep1 = 'b';
|
||||
await render(c);
|
||||
expect(spy.callCount).to.equal(2);
|
||||
|
||||
dep2 = false;
|
||||
await render(c);
|
||||
expect(spy.callCount).to.equal(3);
|
||||
});
|
||||
|
||||
it('should cleanup previous', async () => {
|
||||
const spy = sandbox.spy();
|
||||
const f = () => {
|
||||
return spy;
|
||||
};
|
||||
c.fn = () => {
|
||||
useEffect(f);
|
||||
};
|
||||
|
||||
await render(c); // initial render
|
||||
await render(c); // should cleanup previous effects on second render
|
||||
expect(spy.callCount).to.equal(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('composed hooks', () => {
|
||||
beforeEach(() => {
|
||||
c = {};
|
||||
c.context = {
|
||||
model: 'model',
|
||||
app: 'app',
|
||||
global: 'global',
|
||||
element: 'element',
|
||||
selections: 'selections',
|
||||
theme: 'theme',
|
||||
layout: 'layout',
|
||||
};
|
||||
c.env = {
|
||||
translator: 'translator',
|
||||
};
|
||||
initiate(c);
|
||||
});
|
||||
afterEach(() => {
|
||||
teardown(c);
|
||||
});
|
||||
|
||||
it('useModel', () => {
|
||||
let value;
|
||||
c.fn = () => {
|
||||
value = useModel();
|
||||
};
|
||||
render(c);
|
||||
expect(value).to.equal('model');
|
||||
});
|
||||
it('useApp', () => {
|
||||
let value;
|
||||
c.fn = () => {
|
||||
value = useApp();
|
||||
};
|
||||
render(c);
|
||||
expect(value).to.equal('app');
|
||||
});
|
||||
it('useGlobal', () => {
|
||||
let value;
|
||||
c.fn = () => {
|
||||
value = useGlobal();
|
||||
};
|
||||
render(c);
|
||||
expect(value).to.equal('global');
|
||||
});
|
||||
it('useElement', () => {
|
||||
let value;
|
||||
c.fn = () => {
|
||||
value = useElement();
|
||||
};
|
||||
render(c);
|
||||
expect(value).to.equal('element');
|
||||
});
|
||||
it('useSelections', () => {
|
||||
let value;
|
||||
c.fn = () => {
|
||||
value = useSelections();
|
||||
};
|
||||
render(c);
|
||||
expect(value).to.equal('selections');
|
||||
});
|
||||
it('useTheme', () => {
|
||||
let value;
|
||||
c.fn = () => {
|
||||
value = useTheme();
|
||||
};
|
||||
render(c);
|
||||
expect(value).to.equal('theme');
|
||||
});
|
||||
it('useLayout', () => {
|
||||
let value;
|
||||
c.fn = () => {
|
||||
value = useLayout();
|
||||
};
|
||||
render(c);
|
||||
expect(value).to.equal('layout');
|
||||
});
|
||||
it('useTranslator', () => {
|
||||
let value;
|
||||
c.fn = () => {
|
||||
value = useTranslator();
|
||||
};
|
||||
render(c);
|
||||
expect(value).to.equal('translator');
|
||||
});
|
||||
it('onTakeSnapshot', () => {
|
||||
const spy = sandbox.spy();
|
||||
c.fn = () => {
|
||||
onTakeSnapshot(spy);
|
||||
};
|
||||
render(c);
|
||||
c.__hooks.snaps[0].fn();
|
||||
expect(spy.callCount).to.equal(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,8 @@ import EventEmitter from 'node-event-emitter';
|
||||
import JSONPatch from './json-patch';
|
||||
import actionhero from './action-hero';
|
||||
|
||||
import { hook, render } from './hooks';
|
||||
|
||||
/**
|
||||
* @interface SnComponent
|
||||
* @alias SnComponent
|
||||
@@ -45,7 +47,61 @@ const mixin = obj => {
|
||||
return obj;
|
||||
};
|
||||
|
||||
export default function create(generator, opts) {
|
||||
function createWithHooks(generator, opts, env) {
|
||||
if (__NEBULA_DEV__) {
|
||||
if (generator.component.render !== render) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('Detected multiple supernova modules, this might cause problems.');
|
||||
}
|
||||
}
|
||||
const c = {
|
||||
context: {
|
||||
element: undefined,
|
||||
layout: {},
|
||||
model: opts.model,
|
||||
app: opts.app,
|
||||
global: opts.global,
|
||||
selections: opts.selections,
|
||||
},
|
||||
env,
|
||||
fn: generator.component.fn,
|
||||
created() {},
|
||||
mounted(element) {
|
||||
generator.component.initiate(c);
|
||||
this.context = {
|
||||
...this.context,
|
||||
element,
|
||||
};
|
||||
},
|
||||
render(r) {
|
||||
this.context = {
|
||||
...this.context,
|
||||
...r.context,
|
||||
layout: r.layout,
|
||||
};
|
||||
|
||||
generator.component.render(this);
|
||||
|
||||
// TODO - deal with onRenderComplete
|
||||
},
|
||||
resize() {},
|
||||
willUnmount() {
|
||||
generator.component.teardown(this);
|
||||
},
|
||||
setSnapshotData(layout) {
|
||||
return generator.component.runSnaps(this, layout);
|
||||
},
|
||||
destroy() {},
|
||||
};
|
||||
|
||||
return [c, null];
|
||||
}
|
||||
|
||||
function createClassical(generator, opts) {
|
||||
if (__NEBULA_DEV__) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('Obsolete API - time to get hooked!');
|
||||
}
|
||||
const componentInstance = {
|
||||
...defaultComponent,
|
||||
};
|
||||
@@ -95,6 +151,18 @@ export default function create(generator, opts) {
|
||||
selections: opts.selections,
|
||||
});
|
||||
|
||||
return [componentInstance, hero];
|
||||
}
|
||||
|
||||
export default function create(generator, opts, env) {
|
||||
if (typeof generator.component === 'function') {
|
||||
generator.component = hook(generator.component);
|
||||
}
|
||||
const [componentInstance, hero] =
|
||||
generator.component && generator.component.__hooked
|
||||
? createWithHooks(generator, opts, env)
|
||||
: createClassical(generator, opts);
|
||||
|
||||
const teardowns = [];
|
||||
|
||||
if (generator.qae.properties.onChange) {
|
||||
@@ -149,7 +217,7 @@ export default function create(generator, opts) {
|
||||
generator,
|
||||
component: componentInstance,
|
||||
selectionToolbar: {
|
||||
items: hero.selectionToolbarItems,
|
||||
items: hero ? hero.selectionToolbarItems : [],
|
||||
},
|
||||
destroy() {
|
||||
teardowns.forEach(t => t());
|
||||
|
||||
267
apis/supernova/src/hooks.js
Normal file
267
apis/supernova/src/hooks.js
Normal file
@@ -0,0 +1,267 @@
|
||||
/* eslint no-underscore-dangle: 0 */
|
||||
/* eslint no-param-reassign: 0 */
|
||||
/* eslint no-console: 0 */
|
||||
|
||||
// Hooks implementation heavily inspired by prect hooks
|
||||
|
||||
let currentComponent;
|
||||
let currentIndex;
|
||||
|
||||
function depsChanged(prevDeps, deps) {
|
||||
if (!prevDeps) {
|
||||
return true;
|
||||
}
|
||||
if (deps.length !== prevDeps.length) {
|
||||
return true;
|
||||
}
|
||||
|
||||
for (let i = 0; i < deps.length; i++) {
|
||||
if (prevDeps[i] !== deps[i]) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function initiate(component) {
|
||||
component.__hooks = {
|
||||
obsolete: false,
|
||||
error: false,
|
||||
list: [],
|
||||
snaps: [],
|
||||
pendingEffects: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function teardown(component) {
|
||||
component.__hooks.list.forEach(fx => {
|
||||
try {
|
||||
typeof fx.teardown === 'function' ? fx.teardown() : null;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
});
|
||||
|
||||
component.__hooks.obsolete = true;
|
||||
component.__hooks.list.length = 0;
|
||||
component.__hooks.pendingEffects.length = 0;
|
||||
cancelAnimationFrame(component.__hooks.scheduled);
|
||||
}
|
||||
|
||||
export async function render(component) {
|
||||
if (component.__hooks.error || component.__hooks.obsolete) {
|
||||
return;
|
||||
}
|
||||
|
||||
currentIndex = -1;
|
||||
currentComponent = component;
|
||||
|
||||
let num = -1;
|
||||
|
||||
if (currentComponent.__hooks.initiated) {
|
||||
num = currentComponent.__hooks.list.length;
|
||||
}
|
||||
|
||||
try {
|
||||
currentComponent.fn.call(null);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
|
||||
currentComponent.__hooks.initiated = true;
|
||||
|
||||
if (__NEBULA_DEV__) {
|
||||
if (num > -1 && num !== currentComponent.__hooks.list.length) {
|
||||
console.warn('Detected a change in the order of hooks called.');
|
||||
}
|
||||
}
|
||||
|
||||
const pending = currentComponent.__hooks;
|
||||
pending.scheduled = null;
|
||||
|
||||
currentIndex = undefined;
|
||||
currentComponent = undefined;
|
||||
|
||||
// eslint-disable-next-line consistent-return
|
||||
return new Promise(resolve => {
|
||||
setTimeout(() => {
|
||||
afterRender(pending); // eslint-disable-line no-use-before-define
|
||||
resolve();
|
||||
}, 0);
|
||||
});
|
||||
}
|
||||
|
||||
export function runSnaps(component, layout) {
|
||||
try {
|
||||
return Promise.all(
|
||||
component.__hooks.snaps.map(h => {
|
||||
return Promise.resolve(h.fn(layout));
|
||||
})
|
||||
).then(snaps => {
|
||||
return snaps[snaps.length - 1];
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
function afterRender(hooks) {
|
||||
try {
|
||||
hooks.pendingEffects.forEach(fx => {
|
||||
// teardown existing
|
||||
typeof fx.teardown === 'function' ? fx.teardown() : null;
|
||||
|
||||
// update
|
||||
fx.teardown = fx.value[0]();
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
|
||||
hooks.pendingEffects.length = 0;
|
||||
}
|
||||
|
||||
function getHook(idx) {
|
||||
if (typeof currentComponent === 'undefined') {
|
||||
throw new Error('Invalid nebula hook call. Hooks can only be called inside a supernova component.');
|
||||
}
|
||||
const hooks = currentComponent.__hooks;
|
||||
if (idx >= hooks.list.length) {
|
||||
hooks.list.push({});
|
||||
}
|
||||
return hooks.list[idx];
|
||||
}
|
||||
|
||||
function schedule(component) {
|
||||
if (component.__hooks.scheduled) {
|
||||
return;
|
||||
}
|
||||
|
||||
component.__hooks.scheduled = requestAnimationFrame(() => {
|
||||
render(component, true);
|
||||
});
|
||||
}
|
||||
|
||||
function useInternalContext(name) {
|
||||
getHook(++currentIndex);
|
||||
const ctx = currentComponent.context;
|
||||
return ctx[name];
|
||||
}
|
||||
|
||||
function useInternalEnv(name) {
|
||||
getHook(++currentIndex);
|
||||
const { env } = currentComponent;
|
||||
return env[name];
|
||||
}
|
||||
|
||||
// ======== EXTERNAL =========
|
||||
|
||||
export function hook(cb) {
|
||||
return {
|
||||
__hooked: true,
|
||||
fn: cb,
|
||||
initiate,
|
||||
render,
|
||||
teardown,
|
||||
runSnaps,
|
||||
};
|
||||
}
|
||||
|
||||
export function useState(initial) {
|
||||
const h = getHook(++currentIndex);
|
||||
if (!h.value) {
|
||||
// initiate
|
||||
h.component = currentComponent;
|
||||
const setState = s => {
|
||||
if (h.component.__hooks.obsolete) {
|
||||
if (__NEBULA_DEV__) {
|
||||
throw new Error(
|
||||
'Calling setState on an unmounted component is a no-op and indicates a memory leak in your component.'
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const v = typeof s === 'function' ? s(h.value[0]) : s;
|
||||
if (v !== h.value[0]) {
|
||||
h.value[0] = v;
|
||||
schedule(h.component);
|
||||
}
|
||||
};
|
||||
h.value = [typeof initial === 'function' ? initial() : initial, setState];
|
||||
}
|
||||
return h.value;
|
||||
}
|
||||
|
||||
export function useEffect(cb, deps) {
|
||||
if (__NEBULA_DEV__) {
|
||||
if (typeof deps !== 'undefined' && !Array.isArray(deps)) {
|
||||
throw new Error('Invalid dependencies. Second argument must be an array.');
|
||||
}
|
||||
}
|
||||
const h = getHook(++currentIndex);
|
||||
if (depsChanged(h.value ? h.value[1] : undefined, deps)) {
|
||||
h.value = [cb, deps];
|
||||
currentComponent.__hooks.pendingEffects.push(h);
|
||||
}
|
||||
}
|
||||
|
||||
export function useMemo(cb, deps) {
|
||||
if (__NEBULA_DEV__) {
|
||||
if (!deps || !deps.length) {
|
||||
console.warn('useMemo called without dependencies.');
|
||||
}
|
||||
}
|
||||
const h = getHook(++currentIndex);
|
||||
if (depsChanged(h.value ? h.value[0] : undefined, deps)) {
|
||||
h.value = [deps, cb()];
|
||||
}
|
||||
return h.value[1];
|
||||
}
|
||||
|
||||
// ---- composed hooks ------
|
||||
export function useModel() {
|
||||
return useInternalContext('model');
|
||||
}
|
||||
|
||||
export function useApp() {
|
||||
return useInternalContext('app');
|
||||
}
|
||||
|
||||
export function useGlobal() {
|
||||
return useInternalContext('global');
|
||||
}
|
||||
|
||||
export function useElement() {
|
||||
return useInternalContext('element');
|
||||
}
|
||||
|
||||
export function useSelections() {
|
||||
return useInternalContext('selections');
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
return useInternalContext('theme');
|
||||
}
|
||||
|
||||
export function useLayout() {
|
||||
return useInternalContext('layout');
|
||||
}
|
||||
|
||||
export function useTranslator() {
|
||||
return useInternalEnv('translator');
|
||||
}
|
||||
|
||||
export function useBehaviour() {
|
||||
return useInternalContext('permissions');
|
||||
}
|
||||
|
||||
export function onTakeSnapshot(cb) {
|
||||
const h = getHook(++currentIndex);
|
||||
if (!h.value) {
|
||||
h.value = 1;
|
||||
currentComponent.__hooks.snaps.push(h);
|
||||
}
|
||||
h.fn = cb;
|
||||
}
|
||||
@@ -1,3 +1,21 @@
|
||||
import generator from './generator';
|
||||
|
||||
export default generator;
|
||||
export { generator };
|
||||
|
||||
// core hooks
|
||||
export { hook } from './hooks';
|
||||
export { useEffect } from './hooks';
|
||||
export { useState } from './hooks';
|
||||
export { useMemo } from './hooks';
|
||||
|
||||
// composed hooks
|
||||
export { useModel } from './hooks';
|
||||
export { useApp } from './hooks';
|
||||
export { useGlobal } from './hooks';
|
||||
export { useElement } from './hooks';
|
||||
export { useSelections } from './hooks';
|
||||
export { useTheme } from './hooks';
|
||||
export { useLayout } from './hooks';
|
||||
export { useTranslator } from './hooks';
|
||||
|
||||
export { onTakeSnapshot } from './hooks';
|
||||
|
||||
@@ -114,7 +114,7 @@ const config = (isEsm, dev = false) => {
|
||||
output: {
|
||||
file: path.resolve(targetDir, getFileName(isEsm ? 'esm' : '', dev)),
|
||||
format: isEsm ? 'esm' : 'umd',
|
||||
exports: 'default',
|
||||
exports: targetName === 'supernova' ? 'named' : 'default',
|
||||
name: umdName,
|
||||
sourcemap: false,
|
||||
banner,
|
||||
|
||||
46
test/component/hooks/hooked.fix.js
Normal file
46
test/component/hooks/hooked.fix.js
Normal file
@@ -0,0 +1,46 @@
|
||||
/* eslint import/no-extraneous-dependencies: 0 */
|
||||
|
||||
import { useState, useEffect, useLayout, useElement, useTheme, useTranslator } from '@nebula.js/supernova';
|
||||
|
||||
function sn() {
|
||||
return {
|
||||
component: () => {
|
||||
const [count, setCount] = useState(0);
|
||||
const element = useElement();
|
||||
const translator = useTranslator();
|
||||
const theme = useTheme();
|
||||
const layout = useLayout();
|
||||
|
||||
useEffect(() => {
|
||||
const listener = () => {
|
||||
setCount(prev => prev + 1);
|
||||
};
|
||||
element.addEventListener('click', listener);
|
||||
|
||||
return () => {
|
||||
element.removeEventListener('click', listener);
|
||||
};
|
||||
}, [element]);
|
||||
|
||||
element.innerHTML = `<div>
|
||||
<div class="state">${count}</div>
|
||||
<div class="layout">${layout.showTitles}</div>
|
||||
<div class="translator">${translator.get('Common.Cancel')}</div>
|
||||
<div class="theme">${theme.getColorPickerColor({ index: 2 })}</div>
|
||||
</div>
|
||||
`;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default function fixture() {
|
||||
return {
|
||||
type: 'sn-mounted',
|
||||
sn,
|
||||
snConfig: {
|
||||
context: {
|
||||
permissions: ['passive', 'interact'],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
42
test/component/hooks/sn.comp.js
Normal file
42
test/component/hooks/sn.comp.js
Normal file
@@ -0,0 +1,42 @@
|
||||
describe('hooks', () => {
|
||||
const snSelector = '.nebulajs-sn';
|
||||
|
||||
before(async () => {
|
||||
await page.goto(`${process.env.BASE_URL}/render/?fixture=./hooks/hooked.fix.js`);
|
||||
await page.waitForSelector(snSelector, { visible: true });
|
||||
});
|
||||
|
||||
it('should render with initial state', async () => {
|
||||
const text = await page.$eval(`${snSelector} .state`, el => el.textContent);
|
||||
expect(text).to.equal('0');
|
||||
});
|
||||
|
||||
it('should update count state after click', async () => {
|
||||
await page.click(snSelector);
|
||||
await page.waitForFunction(
|
||||
selector => document.querySelector(selector).textContent === '1',
|
||||
{},
|
||||
`${snSelector} .state`
|
||||
);
|
||||
const text = await page.$eval(`${snSelector} .state`, el => el.textContent);
|
||||
expect(text).to.equal('1');
|
||||
});
|
||||
|
||||
it('useLayout', async () => {
|
||||
await page.click(snSelector);
|
||||
const text = await page.$eval(`${snSelector} .layout`, el => el.textContent);
|
||||
expect(text).to.equal('true');
|
||||
});
|
||||
|
||||
it('useTranslator', async () => {
|
||||
await page.click(snSelector);
|
||||
const text = await page.$eval(`${snSelector} .translator`, el => el.textContent);
|
||||
expect(text).to.equal('Cancel');
|
||||
});
|
||||
|
||||
it('useTheme', async () => {
|
||||
await page.click(snSelector);
|
||||
const text = await page.$eval(`${snSelector} .theme`, el => el.textContent);
|
||||
expect(text).to.equal('#a54343');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user