Skip to content

Commit e45e044

Browse files
authored
Merge pull request #5 from AntonHinz/boiler
Refactor core + unit tests; Preparation for moving core parts away
2 parents 304a6eb + f6e26c5 commit e45e044

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

45 files changed

+3371
-2193
lines changed

package-lock.json

Lines changed: 1009 additions & 984 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@
99
"test": "jest"
1010
},
1111
"dependencies": {
12-
"vue": "^3.2.33"
12+
"vue": "^3.2.33",
13+
"object-hash": "^3.0.0"
1314
},
1415
"devDependencies": {
1516
"@babel/core": "^7.18.0",
@@ -37,4 +38,4 @@
3738
"webpack-cli": "^4.9.2",
3839
"webpack-dev-server": "^4.9.1"
3940
}
40-
}
41+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export default bus => app => app.provide('$bus', bus);
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import plugin from './bus-vue-plugin';
2+
3+
describe('bus-vue-plugin', () => {
4+
it('should call provide a bus as $bus', () => {
5+
const app = { provide: jest.fn() };
6+
const bus = 'BUS';
7+
plugin(bus)(app);
8+
expect(app.provide).toHaveBeenCalledWith('$bus', 'BUS');
9+
});
10+
});
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { createApp } from 'vue/dist/vue.esm-bundler';
2+
import {
3+
reactive,
4+
} from 'vue';
5+
6+
export const rootEmit = (name, detail) => window.dispatchEvent(new CustomEvent(name, { detail }));
7+
8+
export const observe = component => {
9+
return Object.keys(component.props || {});
10+
}
11+
12+
export const create = (boiler, component, storeInstaller) => {
13+
const customs = [...(boiler?.settings?.customs || []), 'content'];
14+
const state = reactive(boiler.getState());
15+
const app = createApp({
16+
template: '<widget v-bind="state"></widget>',
17+
computed: { state: () => state },
18+
});
19+
20+
storeInstaller(app);
21+
app.config.unwrapInjectedRef = true;
22+
app.config.compilerOptions.isCustomElement = t => customs.includes(t);
23+
app.component('widget', component);
24+
app.component('content', { template: boiler.content });
25+
app.provide('$boiler', boiler);
26+
app.provide('$injector', (type, data) => rootEmit('$injector', { type, data }));
27+
28+
return {
29+
watch: (prop, newVal) => (state[prop] = newVal),
30+
mount: () => app.mount(boiler.element),
31+
unmount: () => app.unmount(),
32+
}
33+
}
34+
35+
export default {
36+
create,
37+
observe,
38+
};
Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
import { createApp } from 'vue/dist/vue.esm-bundler';
2+
import { reactive } from 'vue';
3+
import {
4+
rootEmit,
5+
observe,
6+
create,
7+
} from './vue-plugin';
8+
9+
10+
jest.mock('vue/dist/vue.esm-bundler', () => {
11+
const app = {
12+
component: jest.fn(),
13+
provide: jest.fn(),
14+
mount: jest.fn(() => app),
15+
unmount: jest.fn(),
16+
config: {
17+
unwrapInjectedRef: false,
18+
compilerOptions: {
19+
isCustomElement: () => {},
20+
},
21+
},
22+
};
23+
24+
return {
25+
createApp: jest.fn().mockReturnValue(app),
26+
};
27+
});
28+
29+
jest.mock('vue', () => ({
30+
reactive: jest.fn(v => v),
31+
}));
32+
33+
describe('rootEmit', () => {
34+
beforeEach(() => {
35+
global.window.dispatchEvent = jest.fn();
36+
37+
global.CustomEvent = jest.fn().mockImplementation(
38+
(name, options) => ({ name, options })
39+
);
40+
41+
rootEmit('foo', 'bar');
42+
});
43+
44+
it('should create correct CustomEvent', () => {
45+
expect(CustomEvent).toHaveBeenCalledWith('foo', { detail: 'bar' });
46+
});
47+
48+
it('should dispatch requested event on window', () => {
49+
expect(window.dispatchEvent).toHaveBeenCalledWith({ name: 'foo', options: { detail: 'bar' } });
50+
});
51+
});
52+
53+
describe('observe', () => {
54+
it('should return keys of component props if present', () => {
55+
expect(observe({ props: { foo: 'bar', fuz: 'buz' } })).toEqual(['foo', 'fuz']);
56+
});
57+
58+
it('should fallback to an empty array if no props detected', () => {
59+
expect(observe({})).toEqual([]);
60+
});
61+
});
62+
63+
describe('create', () => {
64+
let call;
65+
let result;
66+
let boiler;
67+
let component;
68+
let storeInstaller;
69+
70+
beforeEach(() => {
71+
component = 'COMPONENT';
72+
storeInstaller = jest.fn();
73+
74+
boiler = {
75+
settings: {
76+
customs: ['foo'],
77+
},
78+
getState: jest.fn(() => ({ foo: 'bar' })),
79+
content: '<foo>bar</foo>',
80+
element: 'ELEMENT',
81+
};
82+
83+
call = () => {
84+
result = create(boiler, component, storeInstaller);
85+
}
86+
});
87+
88+
it('should set app config.unwrapInjectedRef to true', () => {
89+
call();
90+
const app = result.mount();
91+
expect(app.config.unwrapInjectedRef).toBeTruthy();
92+
});
93+
94+
describe('customs', () => {
95+
it.each([
96+
[{ customs: ['foo', 'bar'] }, 'foo', true],
97+
[{ customs: ['foo', 'bar'] }, 'bar', true],
98+
[{ customs: ['foo', 'bar'] }, 'content', true],
99+
[{ customs: ['foo'] }, 'bar', false],
100+
[{}, 'content', true],
101+
[null, 'content', true],
102+
])('for %j customs %j should be %j', (settings, tag, res) => {
103+
boiler.settings = settings;
104+
call();
105+
const app = result.mount();
106+
expect(app.config.compilerOptions.isCustomElement(tag)).toBe(res);
107+
})
108+
});
109+
110+
describe('state', () => {
111+
beforeEach(() => {
112+
call();
113+
});
114+
115+
it('should receive state from boiler', () => {
116+
expect(boiler.getState).toHaveBeenCalled();
117+
});
118+
119+
it('should vue-based reactivity for requested state', () => {
120+
expect(reactive).toHaveBeenCalledWith({ foo: 'bar' });
121+
});
122+
123+
it('should bind state to a newly created app', () => {
124+
expect(createApp).toHaveBeenCalledWith({
125+
template: '<widget v-bind="state"></widget>',
126+
computed: { state: expect.any(Function) },
127+
});
128+
});
129+
130+
it('should return state as computed defined in wrapper - to grant correct binding', () => {
131+
expect(createApp.mock.calls[0][0].computed.state()).toEqual({ foo: 'bar' });
132+
});
133+
134+
it('should be updated with watch() callback', () => {
135+
result.watch('foo', 'buz');
136+
expect(createApp.mock.calls[0][0].computed.state()).toEqual({ foo: 'buz' });
137+
});
138+
});
139+
140+
describe('storeInstaller', () => {
141+
it('should call storeInstaller with app passed', () => {
142+
call();
143+
const app = result.mount();
144+
expect(storeInstaller).toHaveBeenCalledWith(app);
145+
});
146+
});
147+
148+
describe('Adding serving components', () => {
149+
let app;
150+
151+
beforeEach(() => {
152+
call();
153+
app = result.mount();
154+
});
155+
156+
it('should add system "widget" component', () => {
157+
expect(app.component).toHaveBeenCalledWith('widget', 'COMPONENT');
158+
});
159+
160+
it('should add system "content" component', () => {
161+
expect(app.component).toHaveBeenCalledWith('content', { template: '<foo>bar</foo>' });
162+
});
163+
});
164+
165+
describe('Providing tools to an app', () => {
166+
let app;
167+
168+
beforeEach(() => {
169+
call();
170+
app = result.mount();
171+
});
172+
173+
it('should provide boiler as $boiler', () => {
174+
expect(app.provide).toHaveBeenCalledWith('$boiler', boiler);
175+
});
176+
177+
it('should provide $injector emitter', () => {
178+
expect(app.provide).toHaveBeenCalledWith('$injector', expect.any(Function));
179+
});
180+
181+
it('should provide rootEmit decorator as an $injector', () => {
182+
global.window.dispatchEvent = jest.fn();
183+
184+
global.CustomEvent = jest.fn().mockImplementation(
185+
(name, options) => ({ name, options })
186+
);
187+
188+
app.provide.mock.calls[1][1]('foo', 'bar');
189+
190+
expect(window.dispatchEvent).toHaveBeenCalledWith(new CustomEvent('$injector', {
191+
detail: {
192+
type: 'foo',
193+
data: 'bar',
194+
},
195+
}));
196+
});
197+
});
198+
199+
describe('unmount', () => {
200+
it('should trigger app.unmount', () => {
201+
call();
202+
const app = result.mount();
203+
result.unmount();
204+
expect(app.unmount).toHaveBeenCalled();
205+
});
206+
});
207+
});
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import {
2+
callAll,
3+
$pickAttributes,
4+
$updateAttribute,
5+
$catch,
6+
$emit,
7+
} from './helpers';
8+
9+
export default (element, component, plugin) => {
10+
const subscribers = {};
11+
const observed = plugin.observe(component);
12+
const settings = element?.$settings;
13+
const content = element?.innerHTML;
14+
15+
return {
16+
element,
17+
observed,
18+
settings,
19+
content,
20+
21+
// Parent allows subscription
22+
publishes(name) {
23+
$catch(
24+
element,
25+
`$subscribe:${name}`,
26+
(handler) => {
27+
if (!subscribers[name]) subscribers[name] = [];
28+
subscribers[name].push(handler);
29+
},
30+
);
31+
},
32+
33+
// Child subscribes for parent (Listen up)
34+
subscribe(name, handler) {
35+
$emit(element, `$subscribe:${name}`, handler);
36+
},
37+
38+
// Parent publishes for children (Event down)
39+
publish(name, data) {
40+
if (!subscribers[name]) return;
41+
callAll(subscribers[name], data);
42+
},
43+
44+
// Parent subscribes for child (Listen down)
45+
listen(name, handler) {
46+
$catch(element, `$dispatch:${name}`, handler);
47+
},
48+
49+
// Child emits to parent (Event up)
50+
dispatch(name, data) {
51+
$emit(element, `$dispatch:${name}`, data);
52+
},
53+
54+
// Apply custom style for a wrapping element
55+
style(style) {
56+
const stylesObj = typeof style === 'function' ? style(element) : style;
57+
58+
for (const prop in stylesObj) element.style[prop] = stylesObj[prop];
59+
},
60+
61+
getState(defaults = {}) {
62+
return { ...$pickAttributes(element, observed), ...defaults };
63+
},
64+
65+
// Update attributes from inside an app
66+
raiseState(state) {
67+
Object.keys(state).forEach((attr) => {
68+
if (observed.includes(attr)) {
69+
$updateAttribute(element, attr, state[attr]);
70+
71+
const event = new CustomEvent(`update:${attr}`, { bubbles: false, detail: state[attr] });
72+
element.dispatchEvent(event);
73+
}
74+
});
75+
76+
const event = new CustomEvent('update', { bubbles: false, detail: $pickAttributes(element, observed) });
77+
element.dispatchEvent(event);
78+
},
79+
};
80+
}

0 commit comments

Comments
 (0)