feat: Add reusable frontend composables package (#13077)

Co-authored-by: कारतोफ्फेलस्क्रिप्ट™ <netroy@users.noreply.github.com>
This commit is contained in:
Alex Grozav
2025-02-06 12:44:30 +02:00
committed by GitHub
parent df89c6fff4
commit ef87da4c19
49 changed files with 684 additions and 309 deletions

View File

@@ -0,0 +1,3 @@
const { createFrontendEslintConfig } = require('@n8n/frontend-eslint-config');
module.exports = createFrontendEslintConfig(__dirname);

View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@@ -0,0 +1,24 @@
# @n8n/composables
A collection of Vue composables that provide common functionality across n8n's Front-End packages.
## Table of Contents
- [Features](#features)
- [Contributing](#contributing)
- [License](#license)
## Features
- **Reusable Logic**: Encapsulate complex stateful logic into composable functions.
- **Consistency**: Ensure consistent patterns and practices across our Vue components.
- **Extensible**: Easily add new composables as our project grows.
- **Optimized**: Fully compatible with the Composition API.
## Contributing
For more details, please read our [CONTRIBUTING.md](CONTRIBUTING.md).
## License
For more details, please read our [LICENSE.md](LICENSE.md).

View File

@@ -0,0 +1,4 @@
{
"$schema": "../../../../node_modules/@biomejs/biome/configuration_schema.json",
"extends": ["../../../../biome.jsonc"]
}

View File

@@ -0,0 +1,46 @@
{
"name": "@n8n/composables",
"type": "module",
"files": [
"dist"
],
"exports": {
"./*": {
"types": "./dist/*.d.ts",
"import": "./dist/*.js",
"require": "./dist/*.cjs"
}
},
"scripts": {
"dev": "vite",
"build": "pnpm run typecheck && tsup",
"preview": "vite preview",
"typecheck": "vue-tsc --noEmit",
"test": "vitest run",
"test:dev": "vitest --silent=false",
"lint": "eslint src --ext .js,.ts,.vue --quiet",
"lintfix": "eslint src --ext .js,.ts,.vue --fix",
"format": "biome format --write . && prettier --write . --ignore-path ../../../../.prettierignore",
"format:check": "biome ci . && prettier --check . --ignore-path ../../../../.prettierignore"
},
"dependencies": {
"vue": "catalog:frontend"
},
"devDependencies": {
"@n8n/frontend-eslint-config": "workspace:*",
"@n8n/frontend-typescript-config": "workspace:*",
"@n8n/frontend-vitest-config": "workspace:*",
"@testing-library/jest-dom": "catalog:frontend",
"@testing-library/user-event": "catalog:frontend",
"@testing-library/vue": "catalog:frontend",
"@vitejs/plugin-vue": "catalog:frontend",
"@vue/tsconfig": "catalog:frontend",
"tsup": "catalog:frontend",
"typescript": "catalog:frontend",
"vite": "catalog:frontend",
"vite-plugin-dts": "catalog:frontend",
"vitest": "catalog:frontend",
"vue-tsc": "catalog:frontend"
},
"license": "See LICENSE.md file in the root of the repository"
}

View File

@@ -0,0 +1,4 @@
import '@testing-library/jest-dom';
import { configure } from '@testing-library/vue';
configure({ testIdAttribute: 'data-test-id' });

View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -0,0 +1,125 @@
import { useDeviceSupport } from './useDeviceSupport';
const detectPointerType = (query: string) => {
const isCoarse = query === '(any-pointer: coarse)';
const isFine = query === '(any-pointer: fine)';
return { fine: isFine, coarse: isCoarse };
};
describe('useDeviceSupport()', () => {
beforeEach(() => {
global.window = Object.create(window);
global.navigator = { userAgent: 'test-agent', maxTouchPoints: 0 } as Navigator;
});
describe('isTouchDevice', () => {
it('should be false if window matches `any-pointer: fine` and `!any-pointer: coarse`', () => {
Object.defineProperty(window, 'matchMedia', {
value: vi.fn().mockImplementation((query: string) => {
const { fine, coarse } = detectPointerType(query);
return { matches: fine && !coarse };
}),
});
const { isTouchDevice } = useDeviceSupport();
expect(isTouchDevice).toEqual(false);
});
it('should be false if window matches `any-pointer: fine` and `any-pointer: coarse`', () => {
Object.defineProperty(window, 'matchMedia', {
value: vi.fn().mockImplementation((query: string) => {
const { fine, coarse } = detectPointerType(query);
return { matches: fine && coarse };
}),
});
const { isTouchDevice } = useDeviceSupport();
expect(isTouchDevice).toEqual(false);
});
it('should be true if window matches `any-pointer: coarse` and `!any-pointer: fine`', () => {
Object.defineProperty(window, 'matchMedia', {
value: vi.fn().mockImplementation((query: string) => {
const { fine, coarse } = detectPointerType(query);
return { matches: coarse && !fine };
}),
});
const { isTouchDevice } = useDeviceSupport();
expect(isTouchDevice).toEqual(true);
});
});
describe('isMacOs', () => {
it('should be true for macOS user agent', () => {
Object.defineProperty(navigator, 'userAgent', { value: 'macintosh' });
const { isMacOs } = useDeviceSupport();
expect(isMacOs).toEqual(true);
});
it('should be false for non-macOS user agent', () => {
Object.defineProperty(navigator, 'userAgent', { value: 'windows' });
const { isMacOs } = useDeviceSupport();
expect(isMacOs).toEqual(false);
});
});
describe('controlKeyCode', () => {
it('should return Meta on macOS', () => {
Object.defineProperty(navigator, 'userAgent', { value: 'macintosh' });
const { controlKeyCode } = useDeviceSupport();
expect(controlKeyCode).toEqual('Meta');
});
it('should return Control on non-macOS', () => {
Object.defineProperty(navigator, 'userAgent', { value: 'windows' });
const { controlKeyCode } = useDeviceSupport();
expect(controlKeyCode).toEqual('Control');
});
});
describe('isMobileDevice', () => {
it('should be true for iOS user agent', () => {
Object.defineProperty(navigator, 'userAgent', { value: 'iphone' });
const { isMobileDevice } = useDeviceSupport();
expect(isMobileDevice).toEqual(true);
});
it('should be true for Android user agent', () => {
Object.defineProperty(navigator, 'userAgent', { value: 'android' });
const { isMobileDevice } = useDeviceSupport();
expect(isMobileDevice).toEqual(true);
});
it('should be false for non-mobile user agent', () => {
Object.defineProperty(navigator, 'userAgent', { value: 'windows' });
const { isMobileDevice } = useDeviceSupport();
expect(isMobileDevice).toEqual(false);
});
it('should be true for iPad user agent', () => {
Object.defineProperty(navigator, 'userAgent', { value: 'ipad' });
const { isMobileDevice } = useDeviceSupport();
expect(isMobileDevice).toEqual(true);
});
it('should be true for iPod user agent', () => {
Object.defineProperty(navigator, 'userAgent', { value: 'ipod' });
const { isMobileDevice } = useDeviceSupport();
expect(isMobileDevice).toEqual(true);
});
});
describe('isCtrlKeyPressed()', () => {
it('should return true for metaKey press on macOS', () => {
Object.defineProperty(navigator, 'userAgent', { value: 'macintosh' });
const { isCtrlKeyPressed } = useDeviceSupport();
const event = new KeyboardEvent('keydown', { metaKey: true });
expect(isCtrlKeyPressed(event)).toEqual(true);
});
it('should return true for ctrlKey press on non-macOS', () => {
Object.defineProperty(navigator, 'userAgent', { value: 'windows' });
const { isCtrlKeyPressed } = useDeviceSupport();
const event = new KeyboardEvent('keydown', { ctrlKey: true });
expect(isCtrlKeyPressed(event)).toEqual(true);
});
});
});

View File

@@ -0,0 +1,44 @@
import { ref } from 'vue';
export function useDeviceSupport() {
/**
* Check if the device is a touch device but exclude devices that have a fine pointer (mouse or track-pad)
* - `fine` will check for an accurate pointing device. Examples include mice, touch-pads, and drawing styluses
* - `coarse` will check for a pointing device of limited accuracy. Examples include touchscreens and motion-detection sensors
* - `any-pointer` will check for the presence of any pointing device, if there are multiple of them
*/
const isTouchDevice = ref(
window.matchMedia('(any-pointer: coarse)').matches &&
!window.matchMedia('(any-pointer: fine)').matches,
);
const userAgent = ref(navigator.userAgent.toLowerCase());
const isIOs = ref(
userAgent.value.includes('iphone') ||
userAgent.value.includes('ipad') ||
userAgent.value.includes('ipod'),
);
const isAndroidOs = ref(userAgent.value.includes('android'));
const isMacOs = ref(userAgent.value.includes('macintosh') || isIOs.value);
const isMobileDevice = ref(isIOs.value || isAndroidOs.value);
const controlKeyCode = ref(isMacOs.value ? 'Meta' : 'Control');
function isCtrlKeyPressed(e: MouseEvent | KeyboardEvent): boolean {
if (isMacOs.value) {
return (e as KeyboardEvent).metaKey;
}
return (e as KeyboardEvent).ctrlKey;
}
return {
userAgent: userAgent.value,
isTouchDevice: isTouchDevice.value,
isAndroidOs: isAndroidOs.value,
isIOs: isIOs.value,
isMacOs: isMacOs.value,
isMobileDevice: isMobileDevice.value,
controlKeyCode: controlKeyCode.value,
isCtrlKeyPressed,
};
}

View File

@@ -0,0 +1,10 @@
{
"extends": "@n8n/frontend-typescript-config",
"compilerOptions": {
"baseUrl": ".",
"rootDir": ".",
"outDir": "dist",
"types": ["vite/client", "vitest/globals"]
},
"include": ["src/**/*.ts", "src/**/*.vue", "vite.config.ts", "tsup.config.ts"]
}

View File

@@ -0,0 +1,11 @@
import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['src/useDeviceSupport.ts'],
format: ['cjs', 'esm'],
clean: true,
dts: true,
cjsInterop: true,
splitting: true,
sourcemap: true,
});

View File

@@ -0,0 +1,4 @@
import { defineConfig, mergeConfig } from 'vite';
import { vitestConfig } from '@n8n/frontend-vitest-config';
export default mergeConfig(defineConfig({}), vitestConfig);

View File

@@ -0,0 +1,14 @@
const sharedOptions = require('@n8n_io/eslint-config/shared');
/**
* @param {string} path
* @param {import('@types/eslint').ESLint.ConfigData} config
* @returns {import('@types/eslint').ESLint.ConfigData}
*/
module.exports.createFrontendEslintConfig = function (path, config = {}) {
return {
extends: ['@n8n_io/eslint-config/frontend'],
...sharedOptions(path, 'frontend'),
...config,
};
};

View File

@@ -0,0 +1,3 @@
import type { ESLint } from '@types/eslint';
export function createFrontendEslintConfig(path: string): ESLint.ConfigData;

View File

@@ -0,0 +1,23 @@
{
"name": "@n8n/frontend-eslint-config",
"version": "1.0.0",
"type": "module",
"dependencies": {
"@n8n_io/eslint-config": "workspace:*",
"eslint-plugin-n8n-local-rules": "^1.0.0"
},
"files": [
"index.cjs"
],
"main": "./index.cjs",
"module": "./index.cjs",
"exports": {
".": {
"import": "./index.cjs",
"require": "./index.cjs",
"types": "./index.d.ts"
},
"./*": "./*"
},
"license": "See LICENSE.md file in the root of the repository"
}

View File

@@ -0,0 +1,18 @@
{
"name": "@n8n/frontend-typescript-config",
"version": "1.0.0",
"type": "module",
"files": [
"tsconfig.json"
],
"main": "./tsconfig.json",
"module": "./tsconfig.json",
"exports": {
".": {
"import": "./tsconfig.json",
"require": "./tsconfig.json"
},
"./*": "./*"
},
"license": "See LICENSE.md file in the root of the repository"
}

View File

@@ -0,0 +1,13 @@
{
"extends": "../../../../tsconfig.json",
"compilerOptions": {
"target": "esnext",
"module": "esnext",
"importHelpers": true,
"allowJs": true,
"incremental": false,
"allowSyntheticDefaultImports": true,
"lib": ["esnext", "dom", "dom.iterable", "scripthost"]
},
"include": ["src/**/*.ts", "src/**/*.vue"]
}

View File

@@ -0,0 +1,3 @@
import type { UserConfig } from 'vitest/node';
export const vitestConfig: UserConfig;

View File

@@ -0,0 +1,30 @@
import { defineConfig as defineVitestConfig } from 'vitest/config';
/**
* Define a Vitest configuration
*
* @returns {import('vitest/node').UserConfig}
*/
export const vitestConfig = defineVitestConfig({
test: {
silent: true,
globals: true,
environment: 'jsdom',
setupFiles: ['./src/__tests__/setup.ts'],
...(process.env.COVERAGE_ENABLED === 'true'
? {
coverage: {
enabled: true,
provider: 'v8',
reporter: process.env.CI === 'true' ? 'cobertura' : 'text-summary',
all: true,
},
}
: {}),
css: {
modules: {
classNameStrategy: 'non-scoped',
},
},
},
});

View File

@@ -0,0 +1,27 @@
{
"name": "@n8n/frontend-vitest-config",
"version": "1.0.0",
"type": "module",
"peerDependencies": {
"vite": "catalog:frontend",
"vitest": "catalog:frontend"
},
"devDependencies": {
"vite": "catalog:frontend",
"vitest": "catalog:frontend"
},
"files": [
"index.mjs"
],
"main": "./index.mjs",
"module": "./index.mjs",
"exports": {
".": {
"import": "./index.mjs",
"require": "./index.mjs",
"types": "./index.d.ts"
},
"./*": "./*"
},
"license": "See LICENSE.md file in the root of the repository"
}