Skip to content

Commit 7c3a8e8

Browse files
committed
Add helpers for Vue and Vite extensions
- Create "defineExtensionConfig" function to setup Vite config for Connect Extensions. Export it in "/tools/build/vite.mjs". - Created a Vue plugin to use the toolkit app instance inside our extension components. Export it in "/tools/vue/toolkitPlugin.js".
1 parent 253c827 commit 7c3a8e8

File tree

9 files changed

+499
-5
lines changed

9 files changed

+499
-5
lines changed

components/src/widgets/complexTable/widget.spec.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,7 @@ describe('ComplexTable widget', () => {
160160

161161
await nextTick()
162162

163-
expect(wrapper.emitted('itemsLoaded'))
163+
expect(wrapper.emitted().itemsLoaded).toBeTruthy();
164164
});
165165
});
166166
});

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@
1111
"build:tools": "NODE_ENV=production webpack --config ./tools/webpack.config.js",
1212
"start": "NODE_ENV=development webpack serve --config ./webpack-dev.config.js",
1313
"start:https": "npm run start -- --server-type https",
14-
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --fix --ignore-path .gitignore",
14+
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --ignore-path .gitignore",
15+
"lint:fix": "npm run lint -- --fix",
1516
"test": "jest --config ./jest.config.js",
1617
"storybook": "storybook dev -p 6006",
1718
"build-storybook": "storybook build"
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
export default {
2+
// Custom plugin to flatten the output directory structure for the extension pages
3+
// Vite does not respect the name given to the input file (see https://vitejs.dev/guide/build.html#multi-page-app)
4+
// for decent reasons, but these reasons do not apply to Connect Extensions, as they are not run on dev mode.
5+
// See https://stackoverflow.com/a/77096400 for more info on this solution
6+
name: 'flatten-html-pages-directory',
7+
enforce: 'post',
8+
generateBundle(_, bundle) {
9+
Object.values(bundle).forEach((outputItem) => {
10+
if (outputItem.fileName.endsWith('.html')) {
11+
const pageName = outputItem.fileName.match(/([\w\-_]+)\/index\.html/)[1];
12+
outputItem.fileName = `${pageName}.html`;
13+
}
14+
});
15+
},
16+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import flattenHtmlPagesDirectory from './flatten-html-pages-directory';
2+
3+
4+
describe('#flattenHtmlPagesDirectory vite plugin', () => {
5+
it('exposes the correct properties', () => {
6+
expect(flattenHtmlPagesDirectory).toEqual(expect.objectContaining({
7+
name: 'flatten-html-pages-directory',
8+
enforce: 'post',
9+
generateBundle: expect.any(Function),
10+
}));
11+
});
12+
13+
describe('generateBundle function', () => {
14+
it('changes the fileName of items that are html files and leaves the rest as they are', () => {
15+
const bundle = {
16+
foo: { fileName: 'one/two/index.js', id: 'foo' },
17+
bar: { fileName: 'three/four/index.html', id: 'bar' },
18+
baz: { fileName: 'five/six/index.css', id: 'baz' },
19+
};
20+
21+
flattenHtmlPagesDirectory.generateBundle(null, bundle);
22+
23+
expect(bundle).toEqual({
24+
foo: { fileName: 'one/two/index.js', id: 'foo' },
25+
bar: { fileName: 'four.html', id: 'bar' },
26+
baz: { fileName: 'five/six/index.css', id: 'baz' },
27+
});
28+
});
29+
});
30+
});

tools/build/vite/index.js

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { fileURLToPath } from 'node:url';
2+
import { readdirSync } from 'node:fs';
3+
import { resolve } from 'node:path';
4+
5+
import flattenHtmlPagesDirectoryPlugin from './flatten-html-pages-directory';
6+
7+
8+
/**
9+
* Creates a valid vite config set up for a Connect extension that uses Vite + Vue
10+
*
11+
* @param {object} config - main configuration object
12+
* @param {string} config.srcDir - absolute path for the src folder
13+
* @param {URL} config.srcUrl - URL for the src folder, used for aliasing '~'
14+
* @param {string} config.outputDir - absolute path for the output directory
15+
* @param {object} config.vuePlugin - '@vitejs/vue' plugin instance
16+
* @param {object} viteOptions - your custom vite config options
17+
*
18+
* @returns {object} - Valid vite config set up for a connect extension
19+
*/
20+
export const defineExtensionConfig = (config, viteOptions = {}) => {
21+
const {
22+
srcDir,
23+
srcUrl,
24+
outputDir,
25+
vuePlugin,
26+
} = config;
27+
28+
if (!srcDir) throw new Error('"srcDir" is required');
29+
if (!outputDir) throw new Error('"outputDir" is required');
30+
if (!vuePlugin) throw new Error('"vuePlugin" is required');
31+
if (!srcUrl) throw new Error('"srcUrl" is required');
32+
33+
34+
return {
35+
...viteOptions,
36+
37+
resolve: {
38+
...viteOptions.resolve,
39+
40+
alias: {
41+
...viteOptions.resolve?.alias,
42+
43+
'~': fileURLToPath(srcUrl),
44+
},
45+
},
46+
47+
plugins: [
48+
vuePlugin,
49+
flattenHtmlPagesDirectoryPlugin,
50+
...(viteOptions.plugins || []),
51+
],
52+
53+
root: srcDir,
54+
base: '/static',
55+
56+
build: {
57+
...viteOptions.build,
58+
59+
outDir: outputDir,
60+
emptyOutDir: true,
61+
62+
rollupOptions: {
63+
...viteOptions.build?.rollupOptions,
64+
65+
// Load all pages in {{srcDir}}/pages/{{pageName}}/index.html as entrypoints
66+
input: readdirSync(resolve(srcDir, 'pages')).reduce((entryPoints, pageName) => {
67+
entryPoints[pageName] = resolve(srcDir, 'pages/', pageName, 'index.html');
68+
69+
return entryPoints;
70+
}, {}),
71+
72+
output: {
73+
...viteOptions.build?.rollupOptions?.output,
74+
75+
format: 'es',
76+
dir: outputDir,
77+
78+
// Split node_modules into a "vendor" chunk, and @cloudblueconnect modules into a "connect" chunk
79+
manualChunks(id) {
80+
if (id.includes('@cloudblueconnect')) return 'connect';
81+
if (id.includes('node_modules')) return 'vendor';
82+
},
83+
},
84+
},
85+
},
86+
};
87+
};

tools/build/vite/index.spec.js

Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
import { readdirSync } from 'node:fs';
2+
import { resolve } from 'node:path';
3+
4+
import { defineExtensionConfig } from './index';
5+
6+
7+
jest.mock('./flatten-html-pages-directory', () => 'flattenHtmlPagesDirectoryPluginStub');
8+
9+
jest.mock('node:url', () => ({
10+
fileURLToPath: jest.fn().mockReturnValue('urlFileUrlToPathStub'),
11+
}));
12+
13+
jest.mock('node:fs', () => ({
14+
readdirSync: jest.fn().mockReturnValue(['fsReaddirSyncStub']),
15+
}));
16+
17+
jest.mock('node:path', () => ({
18+
resolve: jest.fn().mockReturnValue('pathResolveStub'),
19+
}));
20+
21+
describe('#defineExtensionConfig function', () => {
22+
let result;
23+
24+
describe('required options', () => {
25+
it.each([
26+
// expectedErrorMessage, config
27+
[
28+
'"srcDir" is required',
29+
{
30+
srcDir: undefined,
31+
srcUrl: 'bar',
32+
outputDir: 'baz',
33+
vuePlugin: 'qux',
34+
},
35+
],
36+
[
37+
'"srcUrl" is required',
38+
{
39+
srcDir: 'foo',
40+
srcUrl: undefined,
41+
outputDir: 'baz',
42+
vuePlugin: 'qux',
43+
},
44+
],
45+
[
46+
'"outputDir" is required',
47+
{
48+
srcDir: 'foo',
49+
srcUrl: 'bar',
50+
outputDir: undefined,
51+
vuePlugin: 'qux',
52+
},
53+
],
54+
[
55+
'"vuePlugin" is required',
56+
{
57+
srcDir: 'foo',
58+
srcUrl: 'bar',
59+
outputDir: 'baz',
60+
vuePlugin: undefined,
61+
},
62+
],
63+
])(
64+
'throws an error with the message %s if config=%o',
65+
(expectedErrorMessage, config) => {
66+
let error;
67+
68+
try {
69+
defineExtensionConfig(config);
70+
} catch (e) {
71+
error = e;
72+
}
73+
74+
expect(error.message).toEqual(expectedErrorMessage);
75+
},
76+
);
77+
});
78+
79+
it('returns the base config', () => {
80+
const config = {
81+
srcDir: '/my/source/dir',
82+
srcUrl: 'file://my/source/dir',
83+
outputDir: '/my/output/dir',
84+
vuePlugin: { name: 'vuepluginstub' },
85+
};
86+
87+
result = defineExtensionConfig(config);
88+
89+
expect(result).toEqual({
90+
resolve: {
91+
alias: {
92+
'~': 'urlFileUrlToPathStub',
93+
},
94+
},
95+
plugins: [
96+
{ name: 'vuepluginstub' },
97+
'flattenHtmlPagesDirectoryPluginStub',
98+
],
99+
root: '/my/source/dir',
100+
base: '/static',
101+
build: {
102+
outDir: '/my/output/dir',
103+
emptyOutDir: true,
104+
rollupOptions: {
105+
input: {
106+
fsReaddirSyncStub: 'pathResolveStub',
107+
},
108+
output: {
109+
format: 'es',
110+
dir: '/my/output/dir',
111+
manualChunks: expect.any(Function),
112+
},
113+
},
114+
},
115+
});
116+
});
117+
118+
it('returns the base config merged with a custom Vite config', () => {
119+
const config = {
120+
srcDir: '/my/source/dir',
121+
srcUrl: 'file://my/source/dir',
122+
outputDir: '/my/output/dir',
123+
vuePlugin: { name: 'vuepluginstub' },
124+
};
125+
126+
const customViteConfig = {
127+
foo: 'bar',
128+
resolve: {
129+
one: 'two',
130+
alias: {
131+
'@': '/some/path',
132+
},
133+
},
134+
plugins: ['other-vite-plugin'],
135+
build: {
136+
someProperty: 'someValue',
137+
rollupOptions: {
138+
bar: 'baz',
139+
output: {
140+
baz: 'qux',
141+
},
142+
},
143+
},
144+
};
145+
146+
result = defineExtensionConfig(config, customViteConfig);
147+
148+
expect(result).toEqual({
149+
foo: 'bar',
150+
resolve: {
151+
one: 'two',
152+
alias: {
153+
'~': 'urlFileUrlToPathStub',
154+
'@': '/some/path',
155+
},
156+
},
157+
plugins: [
158+
{ name: 'vuepluginstub' },
159+
'flattenHtmlPagesDirectoryPluginStub',
160+
'other-vite-plugin'
161+
],
162+
root: '/my/source/dir',
163+
base: '/static',
164+
build: {
165+
someProperty: 'someValue',
166+
outDir: '/my/output/dir',
167+
emptyOutDir: true,
168+
rollupOptions: {
169+
bar: 'baz',
170+
input: {
171+
fsReaddirSyncStub: 'pathResolveStub',
172+
},
173+
output: {
174+
baz: 'qux',
175+
format: 'es',
176+
dir: '/my/output/dir',
177+
manualChunks: expect.any(Function),
178+
},
179+
},
180+
},
181+
});
182+
});
183+
184+
it('does proper input entrypoints resolution', () => {
185+
const config = {
186+
srcDir: '/my/source/dir',
187+
srcUrl: 'file://my/source/dir',
188+
outputDir: '/my/output/dir',
189+
vuePlugin: { name: 'vuepluginstub' },
190+
};
191+
192+
result = defineExtensionConfig(config);
193+
194+
expect(resolve).toHaveBeenCalledWith('/my/source/dir', 'pages');
195+
expect(readdirSync).toHaveBeenCalledWith('pathResolveStub');
196+
expect(resolve).toHaveBeenCalledWith('/my/source/dir', 'pages/', 'fsReaddirSyncStub', 'index.html');
197+
expect(result.build.rollupOptions.input).toEqual({
198+
fsReaddirSyncStub: 'pathResolveStub',
199+
});
200+
});
201+
202+
describe('#config.build.rollupOptions.output.manualChunks', () => {
203+
it.each([
204+
// expected, moduleId
205+
['connect', 'foo/bar/@cloudblueconnect/material-svg/baseline/googlePhoneBaseline.svg'],
206+
['connect', 'foo/bar/@cloudblueconnect/connect-ui-toolkit/tools/vue/toolkitPlugin.js'],
207+
['connect', 'node_modules/@cloudblueconnect/connect-ui-toolkit/index.js'],
208+
['vendor', 'node_modules/@cloudgreendisconnect/disconnect-backend-toolkit/index.js'],
209+
['vendor', 'node_modules/vue/index.js'],
210+
['vendor', 'foo/bar/baz/node_modules/vuex/index.js'],
211+
[undefined, 'foo/bar/baz/index.js'],
212+
[undefined, 'main.css'],
213+
])(
214+
'returns %s if the module id=%s',
215+
(expected, moduleId) => {
216+
const config = {
217+
srcDir: '/my/source/dir',
218+
srcUrl: 'file://my/source/dir',
219+
outputDir: '/my/output/dir',
220+
vuePlugin: { name: 'vuepluginstub' },
221+
};
222+
const manualChunksFn = defineExtensionConfig(config).build.rollupOptions.output.manualChunks;
223+
224+
result = manualChunksFn(moduleId);
225+
226+
expect(result).toEqual(expected);
227+
},
228+
);
229+
});
230+
});

0 commit comments

Comments
 (0)