From a8c9ddd9292c868323dffd9840554821bcaa4fd1 Mon Sep 17 00:00:00 2001 From: Arnau Giralt Date: Mon, 5 Feb 2024 16:26:16 +0100 Subject: [PATCH] Create fastApi helper functions and update project config to export tooling files - Created fastApiTableAdapter and fastApiTableAdapterComposable functions to act as an interface between the table component and a FastAPI-based backend - Updated webpack, jest, eslint and babel config to manage programs in the /tools folder - Fixed some lint issues --- .eslintrc.js | 43 ++- .storybook/main.js | 2 +- components/jest.config.js | 17 +- components/src/core/helpers.spec.js | 4 +- .../src/core/injector/core/launcher.spec.js | 6 + components/src/core/injector/index.spec.js | 13 +- components/src/index.spec.js | 1 + components/src/widgets/icon/widget.spec.js | 2 +- components/src/widgets/icon/widget.vue | 6 +- components/tasks/start.js | 22 -- components/webpack.config.js | 31 +- jest.config.js | 13 + package-lock.json | 41 +++ package.json | 16 +- sonar-project.properties | 4 +- tools/api/fastApi/adapter.js | 104 +++++++ tools/api/fastApi/adapter.spec.js | 207 ++++++++++++++ tools/api/fastApi/index.js | 2 + tools/api/fastApi/vue-composable.js | 56 ++++ tools/api/fastApi/vue-composable.spec.js | 269 ++++++++++++++++++ tools/babel.config.js | 5 + tools/jest.config.js | 24 ++ tools/webpack.config.js | 34 +++ webpack.config.js | 24 ++ 24 files changed, 870 insertions(+), 76 deletions(-) delete mode 100644 components/tasks/start.js create mode 100644 jest.config.js create mode 100644 tools/api/fastApi/adapter.js create mode 100644 tools/api/fastApi/adapter.spec.js create mode 100644 tools/api/fastApi/index.js create mode 100644 tools/api/fastApi/vue-composable.js create mode 100644 tools/api/fastApi/vue-composable.spec.js create mode 100644 tools/babel.config.js create mode 100644 tools/jest.config.js create mode 100644 tools/webpack.config.js diff --git a/.eslintrc.js b/.eslintrc.js index 391710d3..62d8936e 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,7 +1,14 @@ -/* eslint-env node */ module.exports = { root: true, - ignorePatterns: ['*.config.js', '*.spec.js', 'dist/*'], + + // Ignore every file but the patterns specified after '/*' + ignorePatterns: [ + '/*', + '!/components', // all files inside /components + '!/tools', // all files inside /tools + '!/*.js', // all JS files in root dir + ], + extends: [ 'plugin:vue/vue3-recommended', 'eslint:recommended', @@ -9,12 +16,36 @@ module.exports = { ], plugins: ['vue'], - - env: { - 'vue/setup-compiler-macros': true, - }, + parser: 'vue-eslint-parser', rules: { 'vue/multi-word-component-names': 'off', }, + + overrides: [ + // Config for unit tests + { + files: ['*.spec.js'], + plugins: ['jest'], + extends: [ + 'plugin:jest/recommended', + 'plugin:jest-formatting/strict', + ], + env: { + jest: true, + 'jest/globals': true, + }, + globals: { + global: 'writable', + }, + }, + + // Config for files that run in node env (config files, etc) + { + files: ['*.config.js', '.eslintrc.js'], + env: { + node: true, + }, + }, + ], }; diff --git a/.storybook/main.js b/.storybook/main.js index 2d8148fb..3586fb09 100644 --- a/.storybook/main.js +++ b/.storybook/main.js @@ -1,4 +1,4 @@ -const path = require('path'); +const path = require('node:path'); module.exports = { diff --git a/components/jest.config.js b/components/jest.config.js index b8871840..153ac52e 100644 --- a/components/jest.config.js +++ b/components/jest.config.js @@ -1,5 +1,8 @@ +/** @type {import('jest').Config} */ module.exports = { rootDir: __dirname, + displayName: 'components', + moduleFileExtensions: [ 'js', 'json', @@ -26,22 +29,16 @@ module.exports = { clearMocks: true, - testMatch: [ - '/(**/*\\.spec.(js|jsx|ts|tsx)|**/__tests__/*.(js|jsx|ts|tsx))', - ], - - collectCoverage: true, - collectCoverageFrom: [ - 'src/**/*.{js,vue}', + '/src/**/*.{js,vue}', ], - coverageDirectory: '/test/coverage/', + testMatch: [ + '/(**/*\\.spec.(js|jsx|ts|tsx)|**/__tests__/*.(js|jsx|ts|tsx))', + ], testEnvironment: 'jsdom', testEnvironmentOptions: { url: 'http://localhost/', }, - - coverageProvider: 'v8', }; diff --git a/components/src/core/helpers.spec.js b/components/src/core/helpers.spec.js index b17614f1..af8deff5 100644 --- a/components/src/core/helpers.spec.js +++ b/components/src/core/helpers.spec.js @@ -18,11 +18,11 @@ describe('clone', () => { }); describe('has', () => { - it('should check if object has an own property of key', () => { + it('returns true if the object has the property', () => { expect(has('prop', { prop: undefined })).toBeTruthy(); }); - it('should check if object has an own property of key', () => { + it('returns false if the object does not have the property', () => { expect(has('prop1', { prop: undefined })).toBeFalsy(); }); }); diff --git a/components/src/core/injector/core/launcher.spec.js b/components/src/core/injector/core/launcher.spec.js index 0e8fd399..ce0ad1dc 100644 --- a/components/src/core/injector/core/launcher.spec.js +++ b/components/src/core/injector/core/launcher.spec.js @@ -44,26 +44,31 @@ describe('$init', () => { it('handler should set proper id to state', () => { handler({}, { $id: 'XXX' }); + expect(core.id).toBe('XXX'); }); it('should handler should set passed data to state', () => { handler({ foo: 'BAR' }, {}); + expect(core.state).toEqual({ foo: 'BAR' }); }); it('should emit "$size" event', () => { handler({}, {}); + expect(injector.emit.mock.calls[2]).toEqual(['$size', 'SIZE']); }); it('should get sizes from $size method', () => { handler({}, {}); + expect(core.size).toHaveBeenCalled(); }); it('should set interval', () => { handler({}, {}); + expect(setInterval).toHaveBeenCalledWith(expect.any(Function), 300); }); @@ -71,6 +76,7 @@ describe('$init', () => { handler({}, {}); injector.emit.mock.calls = []; setInterval.mock.calls[0][0](); + expect(injector.emit).toHaveBeenCalledWith('$size', 'SIZE'); }); }); diff --git a/components/src/core/injector/index.spec.js b/components/src/core/injector/index.spec.js index 8a3110ea..cfb33133 100644 --- a/components/src/core/injector/index.spec.js +++ b/components/src/core/injector/index.spec.js @@ -42,7 +42,16 @@ describe('createInjector on launcher error', () => { launcher.mockImplementation(() => Promise.reject(new Error('ERROR'))); }); - it('should throw an error', () => { - expect(createInjector()).rejects.toThrow('ERROR'); + it('should throw an error', async () => { + let error; + + try { + await createInjector(); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(Error); + expect(error.message).toEqual('ERROR'); }); }); diff --git a/components/src/index.spec.js b/components/src/index.spec.js index 5c0dbc59..b0fed92f 100644 --- a/components/src/index.spec.js +++ b/components/src/index.spec.js @@ -1,3 +1,4 @@ +/* eslint-disable vue/one-component-per-file */ import createApp from './index'; import injector from '~core/injector'; import registerWidget from '~core/registerWidget'; diff --git a/components/src/widgets/icon/widget.spec.js b/components/src/widgets/icon/widget.spec.js index 24a81d32..ca97cfdd 100644 --- a/components/src/widgets/icon/widget.spec.js +++ b/components/src/widgets/icon/widget.spec.js @@ -59,7 +59,7 @@ describe('Icon', () => { expect(result).toEqual('12px'); }); - it('shouldn\`t add px if size prop is passed as String with "px"', () => { + it('shouldn\'t add px if size prop is passed as String with "px"', () => { const component = Icon.setup( {size: '12px'}, {expose: () => 'mock reqired for composition api'} diff --git a/components/src/widgets/icon/widget.vue b/components/src/widgets/icon/widget.vue index 71f61b19..14786cbd 100644 --- a/components/src/widgets/icon/widget.vue +++ b/components/src/widgets/icon/widget.vue @@ -12,7 +12,7 @@ import * as icons from '@cloudblueconnect/material-svg'; defineOptions({ name: 'Icon', - }) + }); const props = defineProps({ iconName: { type: String, @@ -26,7 +26,7 @@ type: [Number, String], default: '24', } - }) + }); const addUnits = (value) => { const regex = /^-?\d+$/; if (!regex.test(value)) return value; @@ -39,7 +39,7 @@ width: addUnits(props.size), } }) - + const icon = computed(() => { return icons[props.iconName]; }) diff --git a/components/tasks/start.js b/components/tasks/start.js deleted file mode 100644 index fedc61c8..00000000 --- a/components/tasks/start.js +++ /dev/null @@ -1,22 +0,0 @@ -const webpack = require('webpack'); -const WebpackDevServer = require('webpack-dev-server'); -const webpackConfig = require('../webpack.config'); - - -const HOST = '0.0.0.0'; -const PORT = process.env.PORT || 3003; - - -const compiler = webpack({ ...webpackConfig, entry: webpackConfig.entry }); - -const devServerOptions = { - ...webpackConfig.devServer, - port: PORT, - host: HOST, -}; - -const server = new WebpackDevServer(devServerOptions, compiler); - -server.startCallback(() => { - console.log(`Serving library (port ${PORT}):`); -}); diff --git a/components/webpack.config.js b/components/webpack.config.js index 65f63fb9..244bdcbb 100644 --- a/components/webpack.config.js +++ b/components/webpack.config.js @@ -1,7 +1,7 @@ +const path = require('node:path'); const { VueLoaderPlugin } = require('vue-loader'); const ESLintPlugin = require('eslint-webpack-plugin'); -const { resolve } = require("path"); module.exports = { mode: process.env.NODE_ENV, @@ -10,10 +10,10 @@ module.exports = { outputModule: true, }, - entry: resolve(__dirname, 'src/index.js'), + entry: path.resolve(__dirname, './src/index.js'), output: { - path: resolve(__dirname, '..', 'dist'), + path: path.resolve(__dirname, '..', 'dist'), filename: 'index.js', library: { type: 'module', @@ -37,7 +37,7 @@ module.exports = { { test: /\.js$/, loader: 'babel-loader', - include: [resolve('app'), resolve('test')], + include: [path.resolve('app'), path.resolve('test')], exclude: /node_modules/, }, { @@ -53,7 +53,7 @@ module.exports = { type: 'asset/source', loader: 'svgo-loader', options: { - configFile: resolve(__dirname, 'svgo.config.js'), + configFile: path.resolve(__dirname, 'svgo.config.js'), }, }, ], @@ -61,9 +61,9 @@ module.exports = { resolve: { alias: { - '~core': resolve(__dirname, './src/core'), - '~widgets': resolve(__dirname, './src/widgets'), - '~constants': resolve(__dirname, './src/constants'), + '~core': path.resolve(__dirname, './src/core'), + '~widgets': path.resolve(__dirname, './src/widgets'), + '~constants': path.resolve(__dirname, './src/constants'), }, }, @@ -74,19 +74,4 @@ module.exports = { extensions: ['js', 'vue'], }), ], - devServer: { - hot: true, - - allowedHosts: 'all', - - headers: { - "Access-Control-Allow-Origin": "*", - }, - - static: ['dist'], - - historyApiFallback: { - rewrites: [{ from: /./, to: '/index.js' }], - }, - }, }; diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 00000000..e3158148 --- /dev/null +++ b/jest.config.js @@ -0,0 +1,13 @@ +/** @type {import('jest').Config} */ +module.exports = { + rootDir: __dirname, + + collectCoverage: true, + coverageDirectory: '/test/coverage/', + coverageProvider: 'v8', + + projects: [ + '/components/jest.config.js', + '/tools/jest.config.js', + ], +}; diff --git a/package-lock.json b/package-lock.json index e40065af..952823d5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "devDependencies": { "@babel/core": "^7.23.9", "@babel/eslint-parser": "^7.23.10", + "@babel/preset-env": "^7.23.9", "@storybook/addon-actions": "^7.6.12", "@storybook/addon-designs": "^7.0.9", "@storybook/addon-essentials": "^7.6.12", @@ -35,6 +36,8 @@ "chromatic": "^9.1.0", "css-loader": "^6.10.0", "eslint": "^8.56.0", + "eslint-plugin-jest": "^27.6.3", + "eslint-plugin-jest-formatting": "^3.1.0", "eslint-plugin-storybook": "^0.6.15", "eslint-plugin-vue": "^9.21.1", "eslint-webpack-plugin": "^4.0.1", @@ -50,6 +53,7 @@ "stylus": "^0.62.0", "stylus-loader": "^8.1.0", "svgo-loader": "^4.0.0", + "vue-eslint-parser": "^9.4.2", "vue-loader": "^17.4.2", "vue-style-loader": "^4.1.3", "webpack": "^5.90.0", @@ -11855,6 +11859,43 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint-plugin-jest": { + "version": "27.6.3", + "resolved": "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-27.6.3.tgz", + "integrity": "sha512-+YsJFVH6R+tOiO3gCJon5oqn4KWc+mDq2leudk8mrp8RFubLOo9CVyi3cib4L7XMpxExmkmBZQTPDYVBzgpgOA==", + "dev": true, + "dependencies": { + "@typescript-eslint/utils": "^5.10.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@typescript-eslint/eslint-plugin": "^5.0.0 || ^6.0.0", + "eslint": "^7.0.0 || ^8.0.0", + "jest": "*" + }, + "peerDependenciesMeta": { + "@typescript-eslint/eslint-plugin": { + "optional": true + }, + "jest": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-jest-formatting": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-jest-formatting/-/eslint-plugin-jest-formatting-3.1.0.tgz", + "integrity": "sha512-XyysraZ1JSgGbLSDxjj5HzKKh0glgWf+7CkqxbTqb7zEhW7X2WHo5SBQ8cGhnszKN+2Lj3/oevBlHNbHezoc/A==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "peerDependencies": { + "eslint": ">=0.8.0" + } + }, "node_modules/eslint-plugin-storybook": { "version": "0.6.15", "resolved": "https://registry.npmjs.org/eslint-plugin-storybook/-/eslint-plugin-storybook-0.6.15.tgz", diff --git a/package.json b/package.json index 182ce3f9..4f2e0893 100644 --- a/package.json +++ b/package.json @@ -2,13 +2,17 @@ "name": "@cloudblueconnect/connect-ui-toolkit", "version": "30.1.0", "exports": { - ".": "./dist/index.js" + ".": "./dist/index.js", + "./tools": "./dist/tools" }, "scripts": { "build": "NODE_ENV=production webpack --config ./webpack.config.js", - "start": "NODE_ENV=development node ./components/tasks/start.js", - "lint": "eslint ./components/src --ext .vue,.js,.jsx,.cjs,.mjs --fix --ignore-path .gitignore", - "test": "NODE_ENV=test jest --config ./components/jest.config.js", + "build:core": "NODE_ENV=production webpack --config ./components/webpack.config.js", + "build:tools": "NODE_ENV=production webpack --config ./tools/webpack.config.js", + "start": "NODE_ENV=development webpack serve", + "start:https": "npm run start -- --server-type https", + "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --fix --ignore-path .gitignore", + "test": "jest --config ./jest.config.js", "storybook": "storybook dev -p 6006", "build-storybook": "storybook build" }, @@ -20,6 +24,7 @@ "devDependencies": { "@babel/core": "^7.23.9", "@babel/eslint-parser": "^7.23.10", + "@babel/preset-env": "^7.23.9", "@storybook/addon-actions": "^7.6.12", "@storybook/addon-designs": "^7.0.9", "@storybook/addon-essentials": "^7.6.12", @@ -40,6 +45,8 @@ "chromatic": "^9.1.0", "css-loader": "^6.10.0", "eslint": "^8.56.0", + "eslint-plugin-jest": "^27.6.3", + "eslint-plugin-jest-formatting": "^3.1.0", "eslint-plugin-storybook": "^0.6.15", "eslint-plugin-vue": "^9.21.1", "eslint-webpack-plugin": "^4.0.1", @@ -55,6 +62,7 @@ "stylus": "^0.62.0", "stylus-loader": "^8.1.0", "svgo-loader": "^4.0.0", + "vue-eslint-parser": "^9.4.2", "vue-loader": "^17.4.2", "vue-style-loader": "^4.1.3", "webpack": "^5.90.0", diff --git a/sonar-project.properties b/sonar-project.properties index bfe1ef64..3ec1f323 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -1,4 +1,4 @@ sonar.projectKey=connect-ui-toolkit sonar.organization=cloudbluesonarcube -sonar.javascript.lcov.reportPaths=components/test/coverage/lcov.info -sonar.exclusions=**/**/*.spec.js,**/**/*.stories.js +sonar.javascript.lcov.reportPaths=test/coverage/lcov.info +sonar.exclusions=**/**/*.spec.js,**/**/*.stories.js,**/**/*.config.js,*.config.js diff --git a/tools/api/fastApi/adapter.js b/tools/api/fastApi/adapter.js new file mode 100644 index 00000000..2cca2398 --- /dev/null +++ b/tools/api/fastApi/adapter.js @@ -0,0 +1,104 @@ +const getTotalItemsFromContentRangeHeader = value => parseInt(/\w+ \d+-\d+\/(\d+)/g.exec(value)[1]); + +const fetchItems = async (endpoint, queryParams) => { + const parameters = new URLSearchParams(queryParams); + const fullUrl = `${endpoint}?${parameters.toString()}`; + + const response = await fetch(fullUrl); + + if (!response.ok) { + throw new Error(`Failed to fetch "${fullUrl}". Received status "${response.status}: ${response.statusText}"`); + } + + const contentRange = response.headers.get('content-range'); + // Default to -1 if there is no content-range header + const total = contentRange ? getTotalItemsFromContentRangeHeader(contentRange) : -1; + + const items = await response.json(); + + return { total, items }; +}; + +/** + * + * @param {string} endpoint API endpoint to fetch + * @param {number} [rowsPerPage=10] Initial amount of items per page + * @returns {{next: (function(): Promise<{total: number, page: number, items: *[]}>), filter: (function(Object): Promise<{total: number, page: number, items: *[]}>), total: number, load: (function(): Promise<{total: number, page: number, items: *[]}>), previous: (function(): Promise<{total: number, page: number, items: *[]}>), setRowsPerPage: (function(number): Promise<{total: number, page: number, items: *[]}>), page: number, items: []}} + */ +export const fastApiTableAdapter = (endpoint, rowsPerPage = 10) => { + const state = { + items: [], + total: 0, + page: 1, + }; + + let limit = rowsPerPage; + let filters = {}; + + + /** + * @returns {Promise<{total: number, page: number, items: *[]}>} + */ + const load = async () => { + const response = await fetchItems(endpoint, { + limit, + offset: limit * (state.page - 1), + ...filters, + }); + + + state.total = response.total; + state.items = response.items; + + return state; + }; + + /** + * @returns {Promise<{total: number, page: number, items: *[]}>} + */ + const next = () => { + state.page++; + + return load(); + }; + + /** + * @returns {Promise<{total: number, page: number, items: *[]}>} + */ + const previous = () => { + state.page--; + + return load(); + }; + + /** + * @param {Object.}newFilters + * @returns {Promise<{total: number, page: number, items: *[]}>} + */ + const filter = (newFilters) => { + filters = newFilters; + state.page = 1; + + return load(); + }; + + /** + * @param {number} newRowsPerPage + * @returns {Promise<{total: number, page: number, items: *[]}>} + */ + const setRowsPerPage = (newRowsPerPage) => { + limit = newRowsPerPage + state.page = 1; + + return load(); + }; + + return { + load, + next, + previous, + filter, + setRowsPerPage, + ...state, + }; +}; diff --git a/tools/api/fastApi/adapter.spec.js b/tools/api/fastApi/adapter.spec.js new file mode 100644 index 00000000..e156a123 --- /dev/null +++ b/tools/api/fastApi/adapter.spec.js @@ -0,0 +1,207 @@ +import { fastApiTableAdapter } from './adapter'; + + +describe('#fastApiTableAdapter', () => { + let result; + let fetchResponse = {}; + + Object.defineProperty(global, 'fetch', { + value: jest.fn().mockImplementation(() => new Promise((resolve) => { + resolve({ + status: fetchResponse.status, + statusText: fetchResponse.statusText, + ok: fetchResponse.ok, + json: jest.fn().mockResolvedValue(fetchResponse.items), + headers: { + get: () => { + if (fetchResponse.contentRangeTotal) { + return `items 0-10/${fetchResponse.contentRangeTotal}`; + } + + return ''; + }, + }, + }); + })), + }); + + beforeEach(() => { + fetchResponse = { + status: 200, + statusText: 'Ok', + ok: true, + items: [], + contentRangeTotal: 0, + }; + }); + + describe('#constructor', () => { + it('returns the exposed properties', () => { + result = fastApiTableAdapter('/foo'); + + expect(result).toEqual({ + items: [], + total: 0, + page: 1, + load: expect.any(Function), + next: expect.any(Function), + previous: expect.any(Function), + filter: expect.any(Function), + setRowsPerPage: expect.any(Function), + }); + }); + }); + + describe('methods', () => { + let adapter; + + beforeEach(() => { + adapter = fastApiTableAdapter('/foo'); + }); + + describe('#load', () => { + it('calls the adapter\'s endpoint with the correct parameters', async () => { + await adapter.load(); + + expect(fetch).toHaveBeenCalledWith('/foo?limit=10&offset=0'); + }); + + it('returns the correct state object after the call', async () => { + fetchResponse.contentRangeTotal = 2; + fetchResponse.items = ['foo', 'bar']; + + const result = await adapter.load(); + + expect(result).toEqual({ + page: 1, + items: ['foo', 'bar'], + total: 2, + }); + }); + + it('returns -1 as total if there is no content range header', async () => { + fetchResponse.contentRangeTotal = null; + fetchResponse.items = ['foo', 'bar']; + + const result = await adapter.load(); + + expect(result).toEqual({ + page: 1, + items: ['foo', 'bar'], + total: -1, + }); + }); + + it('throws an error if the request is not successful', async () => { + fetchResponse.ok = false; + fetchResponse.status = 500; + fetchResponse.statusText = 'Internal Server Error'; + + let error; + + try { + await adapter.load(); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(Error); + expect(error.message).toEqual('Failed to fetch "/foo?limit=10&offset=0". Received status "500: Internal Server Error"'); + }); + }); + + describe('#next', () => { + beforeEach(async () => { + fetchResponse.contentRangeTotal = 1; + fetchResponse.items = ['foo']; + + result = await adapter.next(); + }); + + it('increases the current page and performs a request', () => { + expect(fetch).toHaveBeenCalledWith('/foo?limit=10&offset=10'); + }); + + it('returns the result of calling load', () => { + expect(result).toEqual({ + page: 2, + total: 1, + items: ['foo'], + }); + }); + }); + + describe('#previous', () => { + beforeEach(async () => { + fetchResponse.contentRangeTotal = 1; + fetchResponse.items = ['bar']; + + // Increase page to 3 + await adapter.next(); + await adapter.next(); + + result = await adapter.previous(); + }); + + it('decreases the current page and performs a request', () => { + expect(fetch).toHaveBeenCalledWith('/foo?limit=10&offset=20'); + }); + + it('returns the result of calling load', () => { + expect(result).toEqual({ + page: 2, + total: 1, + items: ['bar'], + }); + }); + }); + + describe('#filter', () => { + beforeEach(async () => { + fetchResponse.contentRangeTotal = 1; + fetchResponse.items = ['baz']; + + // Increase page to 2 + await adapter.next(); + + result = await adapter.filter({ id: '123' }); + }); + + it('resets the current page and performs a request with the new filters', () => { + expect(fetch).toHaveBeenCalledWith('/foo?limit=10&offset=0&id=123'); + }); + + it('returns the result of calling load', () => { + expect(result).toEqual({ + page: 1, + total: 1, + items: ['baz'], + }); + }); + }); + + describe('#setRowsPerPage', () => { + beforeEach(async () => { + fetchResponse.contentRangeTotal = 1; + fetchResponse.items = ['qux']; + + // Increase page to 2 + await adapter.next(); + + result = await adapter.setRowsPerPage(20); + }); + + it('resets the current page and performs a request with the new limit', () => { + expect(fetch).toHaveBeenCalledWith('/foo?limit=20&offset=0'); + }); + + it('returns the result of calling load', () => { + expect(result).toEqual({ + page: 1, + total: 1, + items: ['qux'], + }); + }); + }); + }); +}); diff --git a/tools/api/fastApi/index.js b/tools/api/fastApi/index.js new file mode 100644 index 00000000..4496b305 --- /dev/null +++ b/tools/api/fastApi/index.js @@ -0,0 +1,2 @@ +export { fastApiTableAdapter } from './adapter'; +export { fastApiTableAdapterComposable } from './vue-composable'; diff --git a/tools/api/fastApi/vue-composable.js b/tools/api/fastApi/vue-composable.js new file mode 100644 index 00000000..4345cb8c --- /dev/null +++ b/tools/api/fastApi/vue-composable.js @@ -0,0 +1,56 @@ +import { ref } from 'vue'; + +import { fastApiTableAdapter } from './adapter'; + + +/** + * Vue composable to wrap fastApiTableAdapter into reactive properties + * + * @param {string} endpoint API endpoint to fetch + * @param {number} [rowsPerPage=10] Initial amount of rows per page + * + * @returns {{next: ((function(): Promise)|*), filter: ((function(*): Promise)|*), total: Ref>, load: ((function(): Promise)|*), previous: ((function(): Promise)|*), setRowsPerPage: ((function(*): Promise)|*), page: Ref>, loading: Ref>, items: Ref>}} + */ +export const fastApiTableAdapterComposable = (endpoint, rowsPerPage = 10) => { + const adapter = fastApiTableAdapter(endpoint, rowsPerPage); + + const items = ref(adapter.items); + const total = ref(adapter.total); + const page = ref(adapter.page); + const loading = ref(false); + + const updateState = (newState) => { + items.value = newState.items; + total.value = newState.total; + page.value = newState.page; + }; + + const doAdapterAction = async (action, params) => { + try { + loading.value = true; + const newState = await adapter[action](params); + updateState(newState); + } finally { + loading.value = false; + } + } + + const load = () => doAdapterAction('load'); + const next = () => doAdapterAction('next'); + const previous = () => doAdapterAction('previous'); + const filter = (newFilters) => doAdapterAction('filter', newFilters); + const setRowsPerPage = (newRowsPerPage) => doAdapterAction('setRowsPerPage', newRowsPerPage); + + return { + load, + next, + previous, + filter, + setRowsPerPage, + page, + loading, + items, + total, + _adapter: adapter, + }; +}; diff --git a/tools/api/fastApi/vue-composable.spec.js b/tools/api/fastApi/vue-composable.spec.js new file mode 100644 index 00000000..3a5c54c5 --- /dev/null +++ b/tools/api/fastApi/vue-composable.spec.js @@ -0,0 +1,269 @@ +import { fastApiTableAdapterComposable } from './vue-composable'; +import { fastApiTableAdapter } from './adapter'; + + +jest.mock('./adapter', () => ({ + fastApiTableAdapter: jest.fn().mockImplementation(() => { + const result = { + total: 42, + items: ['foo', 'bar', 'baz'], + page: 1, + }; + + return { + items: [], + total: 0, + page: 1, + load: jest.fn().mockResolvedValue(result), + next: jest.fn().mockResolvedValue(result), + previous: jest.fn().mockResolvedValue(result), + filter: jest.fn().mockResolvedValue(result), + setRowsPerPage: jest.fn().mockResolvedValue(result), + }; + }), +})); + +describe('#fastApiTableAdapterComposable', () => { + let composable; + let adapter; + + describe('#constructor', () => { + beforeEach(() => { + composable = fastApiTableAdapterComposable('/foo'); + }); + + it('creates a new fastApiTableAdapter', () => { + expect(fastApiTableAdapter).toHaveBeenCalledWith('/foo', 10); + }); + + it('returns the exposed properties', () => { + expect(composable.items.value).toEqual([]); + expect(composable.page.value).toEqual(1); + expect(composable.loading.value).toEqual(false); + expect(composable.total.value).toEqual(0); + expect(composable.load).toBeInstanceOf(Function); + expect(composable.next).toBeInstanceOf(Function); + expect(composable.previous).toBeInstanceOf(Function); + expect(composable.filter).toBeInstanceOf(Function); + expect(composable.setRowsPerPage).toBeInstanceOf(Function); + expect(composable._adapter).toBeDefined(); + }); + }); + + describe('methods', () => { + beforeEach(() => { + composable = fastApiTableAdapterComposable('/foo', 10); + adapter = composable._adapter; + }); + + describe('#load', () => { + it('handles loading state', async () => { + const promise = composable.load(); + + expect(composable.loading.value).toEqual(true); + + // wait for promise resolution + await promise; + + expect(composable.loading.value).toEqual(false); + }); + + it('calls the adapter\'s load method', async () => { + await composable.load(); + + expect(adapter.load).toHaveBeenCalled(); + }); + + it('updates the composable state after the call', async () => { + await composable.load(); + + expect(composable.items.value).toEqual(['foo', 'bar', 'baz']); + expect(composable.total.value).toEqual(42); + expect(composable.page.value).toEqual(1); + }); + + it('throws an error if the request is not successful', async () => { + adapter.load.mockRejectedValueOnce(new Error('foo')); + + let error; + + try { + await composable.load(); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(Error); + expect(error.message).toEqual('foo'); + }); + }); + + describe('#next', () => { + it('handles loading state', async () => { + const promise = composable.next(); + + expect(composable.loading.value).toEqual(true); + + // wait for promise resolution + await promise; + + expect(composable.loading.value).toEqual(false); + }); + + it('calls the adapter\'s next method', async () => { + await composable.next(); + + expect(adapter.next).toHaveBeenCalled(); + }); + + it('updates the composable state after the call', async () => { + await composable.next(); + + expect(composable.items.value).toEqual(['foo', 'bar', 'baz']); + expect(composable.total.value).toEqual(42); + expect(composable.page.value).toEqual(1); + }); + + it('throws an error if the request is not successful', async () => { + adapter.next.mockRejectedValueOnce(new Error('foo')); + + let error; + + try { + await composable.next(); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(Error); + expect(error.message).toEqual('foo'); + }); + }); + + describe('#previous', () => { + it('handles loading state', async () => { + const promise = composable.previous(); + + expect(composable.loading.value).toEqual(true); + + // wait for promise resolution + await promise; + + expect(composable.loading.value).toEqual(false); + }); + + it('calls the adapter\'s previous method', async () => { + await composable.previous(); + + expect(adapter.previous).toHaveBeenCalled(); + }); + + it('updates the composable state after the call', async () => { + await composable.previous(); + + expect(composable.items.value).toEqual(['foo', 'bar', 'baz']); + expect(composable.total.value).toEqual(42); + expect(composable.page.value).toEqual(1); + }); + + it('throws an error if the request is not successful', async () => { + adapter.previous.mockRejectedValueOnce(new Error('foo')); + + let error; + + try { + await composable.previous(); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(Error); + expect(error.message).toEqual('foo'); + }); + }); + + describe('#filter', () => { + it('handles loading state', async () => { + const promise = composable.filter({ foo: 'bar' }); + + expect(composable.loading.value).toEqual(true); + + // wait for promise resolution + await promise; + + expect(composable.loading.value).toEqual(false); + }); + + it('calls the adapter\'s filter method', async () => { + await composable.filter({ foo: 'bar' }); + + expect(adapter.filter).toHaveBeenCalledWith({ foo: 'bar' }); + }); + + it('updates the composable state after the call', async () => { + await composable.filter({ foo: 'bar' }); + + expect(composable.items.value).toEqual(['foo', 'bar', 'baz']); + expect(composable.total.value).toEqual(42); + expect(composable.page.value).toEqual(1); + }); + + it('throws an error if the request is not successful', async () => { + adapter.filter.mockRejectedValueOnce(new Error('foo')); + + let error; + + try { + await composable.filter({ foo: 'bar' }); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(Error); + expect(error.message).toEqual('foo'); + }); + }); + + describe('#setRowsPerPage', () => { + it('handles loading state', async () => { + const promise = composable.setRowsPerPage(5); + + expect(composable.loading.value).toEqual(true); + + // wait for promise resolution + await promise; + + expect(composable.loading.value).toEqual(false); + }); + + it('calls the adapter\'s setRowsPerPage method', async () => { + await composable.setRowsPerPage(5); + + expect(adapter.setRowsPerPage).toHaveBeenCalled(); + }); + + it('updates the composable state after the call', async () => { + await composable.setRowsPerPage(5); + + expect(composable.items.value).toEqual(['foo', 'bar', 'baz']); + expect(composable.total.value).toEqual(42); + expect(composable.page.value).toEqual(1); + }); + + it('throws an error if the request is not successful', async () => { + adapter.setRowsPerPage.mockRejectedValueOnce(new Error('foo')); + + let error; + + try { + await composable.setRowsPerPage(5); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(Error); + expect(error.message).toEqual('foo'); + }); + }); + }); +}); diff --git a/tools/babel.config.js b/tools/babel.config.js new file mode 100644 index 00000000..cf5027e0 --- /dev/null +++ b/tools/babel.config.js @@ -0,0 +1,5 @@ +module.exports = { + "presets": [ + "@babel/preset-env", + ], +}; diff --git a/tools/jest.config.js b/tools/jest.config.js new file mode 100644 index 00000000..d04fdc2e --- /dev/null +++ b/tools/jest.config.js @@ -0,0 +1,24 @@ +/** @type {import('jest').Config} */ +module.exports = { + rootDir: __dirname, + displayName: 'tools', + + clearMocks: true, + + collectCoverageFrom: [ + '/**/*.js', + ], + + testMatch: [ + '/(**/*\\.spec.(js|jsx|ts|tsx)|**/__tests__/*.(js|jsx|ts|tsx))', + ], + + transform: { + '^.+\\.js$': 'babel-jest', + }, + + testEnvironment: 'jsdom', + testEnvironmentOptions: { + url: 'http://localhost/', + }, +}; diff --git a/tools/webpack.config.js b/tools/webpack.config.js new file mode 100644 index 00000000..a9ca680d --- /dev/null +++ b/tools/webpack.config.js @@ -0,0 +1,34 @@ +const path = require('node:path'); + + +module.exports = { + mode: process.env.NODE_ENV, + + experiments: { + outputModule: true, + }, + + entry: { + fastApi: { + import: path.resolve(__dirname, 'api/fastApi/index.js'), + filename: 'tools/[name].js', + }, + }, + + output: { + path: path.resolve(__dirname, '..', 'dist'), + library: { + type: 'module', + }, + }, + + module: { + rules: [ + { + test: /\.js$/, + loader: 'babel-loader', + exclude: /node_modules/, + }, + ], + }, +}; diff --git a/webpack.config.js b/webpack.config.js index 87436380..d8f28732 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -1,6 +1,30 @@ +const os = require('node:os'); + const componentsConfig = require('./components/webpack.config'); +const toolsConfig = require('./tools/webpack.config'); + +const devServerConfig = { + devServer: { + compress: true, + port: process.env.PORT || 3003, + allowedHosts: 'all', + headers: { + "Access-Control-Allow-Origin": "*", + }, + }, +}; module.exports = [ + devServerConfig, componentsConfig, + toolsConfig, ]; + + +// Calculate how many parallel builds can be done. Minimum is 1, otherwise it's the amount of cores +// available, maxing at 4. +const cpuCount = os.cpus().length || 1; +const parallelBuildsNum = Math.min(cpuCount, 4); + +module.exports.parallelism = parallelBuildsNum;