From ebb93cfd0f4be3f35ef4d6e27bdaf1b9a4a00c71 Mon Sep 17 00:00:00 2001 From: Arnau Giralt Date: Wed, 14 Feb 2024 18:58:33 +0100 Subject: [PATCH] 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". --- .github/workflows/build.yml | 2 + .../src/widgets/complexTable/widget.spec.js | 2 +- package.json | 3 +- .../vite/flatten-html-pages-directory.js | 16 ++ .../vite/flatten-html-pages-directory.spec.js | 30 +++ tools/build/vite/index.js | 87 +++++++ tools/build/vite/index.spec.js | 230 ++++++++++++++++++ tools/vue/toolkit.js | 40 +++ tools/vue/toolkit.spec.js | 78 ++++++ tools/webpack.config.js | 18 +- 10 files changed, 501 insertions(+), 5 deletions(-) create mode 100644 tools/build/vite/flatten-html-pages-directory.js create mode 100644 tools/build/vite/flatten-html-pages-directory.spec.js create mode 100644 tools/build/vite/index.js create mode 100644 tools/build/vite/index.spec.js create mode 100644 tools/vue/toolkit.js create mode 100644 tools/vue/toolkit.spec.js diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f2af95b9..d914d511 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -25,6 +25,8 @@ jobs: node-version: ${{ matrix.node }} - name: Install dependencies run: npm install + - name: Lint + run: npm run lint - name: Testing run: npm test sonar: diff --git a/components/src/widgets/complexTable/widget.spec.js b/components/src/widgets/complexTable/widget.spec.js index fa5a4775..075e7e30 100644 --- a/components/src/widgets/complexTable/widget.spec.js +++ b/components/src/widgets/complexTable/widget.spec.js @@ -160,7 +160,7 @@ describe('ComplexTable widget', () => { await nextTick() - expect(wrapper.emitted('itemsLoaded')) + expect(wrapper.emitted().itemsLoaded).toBeTruthy(); }); }); }); diff --git a/package.json b/package.json index 49807399..0379b199 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,8 @@ "build:tools": "NODE_ENV=production webpack --config ./tools/webpack.config.js", "start": "NODE_ENV=development webpack serve --config ./webpack-dev.config.js", "start:https": "npm run start -- --server-type https", - "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --fix --ignore-path .gitignore", + "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --ignore-path .gitignore", + "lint:fix": "npm run lint -- --fix", "test": "jest --config ./jest.config.js", "storybook": "storybook dev -p 6006", "build-storybook": "storybook build" diff --git a/tools/build/vite/flatten-html-pages-directory.js b/tools/build/vite/flatten-html-pages-directory.js new file mode 100644 index 00000000..e4b88a91 --- /dev/null +++ b/tools/build/vite/flatten-html-pages-directory.js @@ -0,0 +1,16 @@ +export default { + // Custom plugin to flatten the output directory structure for the extension pages + // Vite does not respect the name given to the input file (see https://vitejs.dev/guide/build.html#multi-page-app) + // for decent reasons, but these reasons do not apply to Connect Extensions, as they are not run on dev mode. + // See https://stackoverflow.com/a/77096400 for more info on this solution + name: 'flatten-html-pages-directory', + enforce: 'post', + generateBundle(_, bundle) { + Object.values(bundle).forEach((outputItem) => { + if (outputItem.fileName.endsWith('.html')) { + const pageName = outputItem.fileName.match(/([\w\-_]+)\/index\.html/)[1]; + outputItem.fileName = `${pageName}.html`; + } + }); + }, +} diff --git a/tools/build/vite/flatten-html-pages-directory.spec.js b/tools/build/vite/flatten-html-pages-directory.spec.js new file mode 100644 index 00000000..305dd7b2 --- /dev/null +++ b/tools/build/vite/flatten-html-pages-directory.spec.js @@ -0,0 +1,30 @@ +import flattenHtmlPagesDirectory from './flatten-html-pages-directory'; + + +describe('#flattenHtmlPagesDirectory vite plugin', () => { + it('exposes the correct properties', () => { + expect(flattenHtmlPagesDirectory).toEqual(expect.objectContaining({ + name: 'flatten-html-pages-directory', + enforce: 'post', + generateBundle: expect.any(Function), + })); + }); + + describe('generateBundle function', () => { + it('changes the fileName of items that are html files and leaves the rest as they are', () => { + const bundle = { + foo: { fileName: 'one/two/index.js', id: 'foo' }, + bar: { fileName: 'three/four/index.html', id: 'bar' }, + baz: { fileName: 'five/six/index.css', id: 'baz' }, + }; + + flattenHtmlPagesDirectory.generateBundle(null, bundle); + + expect(bundle).toEqual({ + foo: { fileName: 'one/two/index.js', id: 'foo' }, + bar: { fileName: 'four.html', id: 'bar' }, + baz: { fileName: 'five/six/index.css', id: 'baz' }, + }); + }); + }); +}); diff --git a/tools/build/vite/index.js b/tools/build/vite/index.js new file mode 100644 index 00000000..29715cf0 --- /dev/null +++ b/tools/build/vite/index.js @@ -0,0 +1,87 @@ +import { fileURLToPath } from 'node:url'; +import { readdirSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import flattenHtmlPagesDirectoryPlugin from './flatten-html-pages-directory'; + + +/** + * Creates a valid vite config set up for a Connect extension that uses Vite + Vue + * + * @param {object} config - main configuration object + * @param {string} config.srcDir - absolute path for the src folder + * @param {URL} config.srcUrl - URL for the src folder, used for aliasing '~' + * @param {string} config.outputDir - absolute path for the output directory + * @param {object} config.vuePlugin - '@vitejs/vue' plugin instance + * @param {object} viteOptions - your custom vite config options + * + * @returns {object} - Valid vite config set up for a connect extension + */ +export const defineExtensionConfig = (config, viteOptions = {}) => { + const { + srcDir, + srcUrl, + outputDir, + vuePlugin, + } = config; + + if (!srcDir) throw new Error('"srcDir" is required'); + if (!outputDir) throw new Error('"outputDir" is required'); + if (!vuePlugin) throw new Error('"vuePlugin" is required'); + if (!srcUrl) throw new Error('"srcUrl" is required'); + + + return { + ...viteOptions, + + resolve: { + ...viteOptions.resolve, + + alias: { + ...viteOptions.resolve?.alias, + + '~': fileURLToPath(srcUrl), + }, + }, + + plugins: [ + vuePlugin, + flattenHtmlPagesDirectoryPlugin, + ...(viteOptions.plugins || []), + ], + + root: srcDir, + base: '/static', + + build: { + ...viteOptions.build, + + outDir: outputDir, + emptyOutDir: true, + + rollupOptions: { + ...viteOptions.build?.rollupOptions, + + // Load all pages in {{srcDir}}/pages/{{pageName}}/index.html as entrypoints + input: readdirSync(resolve(srcDir, 'pages')).reduce((entryPoints, pageName) => { + entryPoints[pageName] = resolve(srcDir, 'pages/', pageName, 'index.html'); + + return entryPoints; + }, {}), + + output: { + ...viteOptions.build?.rollupOptions?.output, + + format: 'es', + dir: outputDir, + + // Split node_modules into a "vendor" chunk, and @cloudblueconnect modules into a "connect" chunk + manualChunks(id) { + if (id.includes('@cloudblueconnect')) return 'connect'; + if (id.includes('node_modules')) return 'vendor'; + }, + }, + }, + }, + }; +}; diff --git a/tools/build/vite/index.spec.js b/tools/build/vite/index.spec.js new file mode 100644 index 00000000..1f69a102 --- /dev/null +++ b/tools/build/vite/index.spec.js @@ -0,0 +1,230 @@ +import { readdirSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { defineExtensionConfig } from './index'; + + +jest.mock('./flatten-html-pages-directory', () => 'flattenHtmlPagesDirectoryPluginStub'); + +jest.mock('node:url', () => ({ + fileURLToPath: jest.fn().mockReturnValue('urlFileUrlToPathStub'), +})); + +jest.mock('node:fs', () => ({ + readdirSync: jest.fn().mockReturnValue(['fsReaddirSyncStub']), +})); + +jest.mock('node:path', () => ({ + resolve: jest.fn().mockReturnValue('pathResolveStub'), +})); + +describe('#defineExtensionConfig function', () => { + let result; + + describe('required options', () => { + it.each([ + // expectedErrorMessage, config + [ + '"srcDir" is required', + { + srcDir: undefined, + srcUrl: 'bar', + outputDir: 'baz', + vuePlugin: 'qux', + }, + ], + [ + '"srcUrl" is required', + { + srcDir: 'foo', + srcUrl: undefined, + outputDir: 'baz', + vuePlugin: 'qux', + }, + ], + [ + '"outputDir" is required', + { + srcDir: 'foo', + srcUrl: 'bar', + outputDir: undefined, + vuePlugin: 'qux', + }, + ], + [ + '"vuePlugin" is required', + { + srcDir: 'foo', + srcUrl: 'bar', + outputDir: 'baz', + vuePlugin: undefined, + }, + ], + ])( + 'throws an error with the message %s if config=%o', + (expectedErrorMessage, config) => { + let error; + + try { + defineExtensionConfig(config); + } catch (e) { + error = e; + } + + expect(error.message).toEqual(expectedErrorMessage); + }, + ); + }); + + it('returns the base config', () => { + const config = { + srcDir: '/my/source/dir', + srcUrl: 'file://my/source/dir', + outputDir: '/my/output/dir', + vuePlugin: { name: 'vuepluginstub' }, + }; + + result = defineExtensionConfig(config); + + expect(result).toEqual({ + resolve: { + alias: { + '~': 'urlFileUrlToPathStub', + }, + }, + plugins: [ + { name: 'vuepluginstub' }, + 'flattenHtmlPagesDirectoryPluginStub', + ], + root: '/my/source/dir', + base: '/static', + build: { + outDir: '/my/output/dir', + emptyOutDir: true, + rollupOptions: { + input: { + fsReaddirSyncStub: 'pathResolveStub', + }, + output: { + format: 'es', + dir: '/my/output/dir', + manualChunks: expect.any(Function), + }, + }, + }, + }); + }); + + it('returns the base config merged with a custom Vite config', () => { + const config = { + srcDir: '/my/source/dir', + srcUrl: 'file://my/source/dir', + outputDir: '/my/output/dir', + vuePlugin: { name: 'vuepluginstub' }, + }; + + const customViteConfig = { + foo: 'bar', + resolve: { + one: 'two', + alias: { + '@': '/some/path', + }, + }, + plugins: ['other-vite-plugin'], + build: { + someProperty: 'someValue', + rollupOptions: { + bar: 'baz', + output: { + baz: 'qux', + }, + }, + }, + }; + + result = defineExtensionConfig(config, customViteConfig); + + expect(result).toEqual({ + foo: 'bar', + resolve: { + one: 'two', + alias: { + '~': 'urlFileUrlToPathStub', + '@': '/some/path', + }, + }, + plugins: [ + { name: 'vuepluginstub' }, + 'flattenHtmlPagesDirectoryPluginStub', + 'other-vite-plugin' + ], + root: '/my/source/dir', + base: '/static', + build: { + someProperty: 'someValue', + outDir: '/my/output/dir', + emptyOutDir: true, + rollupOptions: { + bar: 'baz', + input: { + fsReaddirSyncStub: 'pathResolveStub', + }, + output: { + baz: 'qux', + format: 'es', + dir: '/my/output/dir', + manualChunks: expect.any(Function), + }, + }, + }, + }); + }); + + it('does proper input entrypoints resolution', () => { + const config = { + srcDir: '/my/source/dir', + srcUrl: 'file://my/source/dir', + outputDir: '/my/output/dir', + vuePlugin: { name: 'vuepluginstub' }, + }; + + result = defineExtensionConfig(config); + + expect(resolve).toHaveBeenCalledWith('/my/source/dir', 'pages'); + expect(readdirSync).toHaveBeenCalledWith('pathResolveStub'); + expect(resolve).toHaveBeenCalledWith('/my/source/dir', 'pages/', 'fsReaddirSyncStub', 'index.html'); + expect(result.build.rollupOptions.input).toEqual({ + fsReaddirSyncStub: 'pathResolveStub', + }); + }); + + describe('#config.build.rollupOptions.output.manualChunks', () => { + it.each([ + // expected, moduleId + ['connect', 'foo/bar/@cloudblueconnect/material-svg/baseline/googlePhoneBaseline.svg'], + ['connect', 'foo/bar/@cloudblueconnect/connect-ui-toolkit/tools/vue/toolkitPlugin.js'], + ['connect', 'node_modules/@cloudblueconnect/connect-ui-toolkit/index.js'], + ['vendor', 'node_modules/@cloudgreendisconnect/disconnect-backend-toolkit/index.js'], + ['vendor', 'node_modules/vue/index.js'], + ['vendor', 'foo/bar/baz/node_modules/vuex/index.js'], + [undefined, 'foo/bar/baz/index.js'], + [undefined, 'main.css'], + ])( + 'returns %s if the module id=%s', + (expected, moduleId) => { + const config = { + srcDir: '/my/source/dir', + srcUrl: 'file://my/source/dir', + outputDir: '/my/output/dir', + vuePlugin: { name: 'vuepluginstub' }, + }; + const manualChunksFn = defineExtensionConfig(config).build.rollupOptions.output.manualChunks; + + result = manualChunksFn(moduleId); + + expect(result).toEqual(expected); + }, + ); + }); +}); diff --git a/tools/vue/toolkit.js b/tools/vue/toolkit.js new file mode 100644 index 00000000..8e358ee1 --- /dev/null +++ b/tools/vue/toolkit.js @@ -0,0 +1,40 @@ +import { inject, reactive } from 'vue'; + + +export const toolkitPlugin = { + /** + * Installs the toolkit plugin, which can be accessed via 'this.$toolkit', + * via Vue injection (options api: { inject: ['toolkit'] } or composition api: inject('toolkit')) + * or via the useToolkit hook (const toolkit = useToolkit()) + * + * @param {object} app – Vue instance + * @param {object} toolkitInstance – Connect UI Toolkit instance + */ + install: (app, toolkitInstance) => { + const sharedContext = reactive({}); + + toolkitInstance.watch( + '*', + (data = {}) => { + Object.entries(data).forEach(([key, value]) => { + sharedContext[key] = value; + }); + }, + { immediate: true }, + ); + + const $toolkit = { + ...toolkitInstance, + get sharedContext() { + return sharedContext; + }, + }; + + app.provide('toolkit', $toolkit); + app.config.globalProperties.$toolkit = $toolkit; + }, +}; + +export const useToolkit = () => { + return inject('toolkit'); +}; diff --git a/tools/vue/toolkit.spec.js b/tools/vue/toolkit.spec.js new file mode 100644 index 00000000..dec572f9 --- /dev/null +++ b/tools/vue/toolkit.spec.js @@ -0,0 +1,78 @@ +import { toolkitPlugin, useToolkit } from './toolkit'; +import { inject } from 'vue'; + + +jest.mock('vue', () => { + const actualModule = jest.requireActual('vue'); + + return { + ...actualModule, + inject: jest.fn().mockReturnValue('injectStub'), + }; +}); + +describe('Toolkit Vue plugin', () => { + let result; + + describe('#useToolkit', () => { + beforeEach(() => { + result = useToolkit(); + }); + + it('calls inject with "toolkit" as its argument', () => { + expect(inject).toHaveBeenCalledWith('toolkit'); + }); + + it('returns the result of the inject call', () => { + expect(result).toEqual('injectStub'); + }); + }); + + describe('#toolkitPlugin', () => { + it('exposes the correct plugin object', () => { + expect(toolkitPlugin).toEqual({ install: expect.any(Function) }); + }); + + describe('plugin install function', () => { + let vueApp; + let toolkitInstance; + + beforeEach(() => { + vueApp = { + provide: jest.fn(), + config: { globalProperties: {} }, + }; + toolkitInstance = { + watch: jest.fn().mockImplementation((_, callback) => callback({ foo: 'bar' })), + navigateTo: jest.fn(), + }; + + toolkitPlugin.install(vueApp, toolkitInstance); + }); + + it('provides the toolkit to the vue app instance', () => { + expect(vueApp.provide).toHaveBeenCalledWith('toolkit', { + watch: expect.any(Function), + navigateTo: expect.any(Function), + sharedContext: expect.any(Object), + }); + }); + + it('adds the toolkit to the vue app as a global property', () => { + expect(vueApp.config.globalProperties.$toolkit).toEqual({ + watch: expect.any(Function), + navigateTo: expect.any(Function), + sharedContext: expect.any(Object), + }); + }); + + it('calls the toolkit\'s watch method to watch for data changes', () => { + expect(toolkitInstance.watch).toHaveBeenCalledWith('*', expect.any(Function), { immediate: true }); + }); + + it('updates the sharedContext object when the toolkit watch callback is called', () => { + expect(vueApp.config.globalProperties.$toolkit.sharedContext).toEqual({ foo: 'bar' }); + }); + }); + }); +}); diff --git a/tools/webpack.config.js b/tools/webpack.config.js index 6e049e98..d2950e92 100644 --- a/tools/webpack.config.js +++ b/tools/webpack.config.js @@ -17,6 +17,15 @@ module.exports = { import: path.resolve(__dirname, 'api/fastApi/vue-composable.js'), filename: 'tools/fastApi/vue.js', }, + createViteConfig: { + import: path.resolve(__dirname, 'build/vite/index.js'), + // export as .mjs until the toolkit package is defined as ES module + filename: 'tools/build/vite.mjs', + }, + toolkitVuePlugin: { + import: path.resolve(__dirname, 'vue/toolkit.js'), + filename: 'tools/vue/toolkitPlugin.js', + }, }, output: { @@ -36,7 +45,10 @@ module.exports = { ], }, - externals: { - vue: 'vue', - }, + externals: [ + { + vue: 'vue', + }, + /node:\w*/, + ], };