first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled

This commit is contained in:
2026-03-17 16:22:57 +03:30
commit 3d5eaf9445
15349 changed files with 2847338 additions and 0 deletions
@@ -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?
@@ -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).
@@ -0,0 +1,4 @@
{
"$schema": "../../../../node_modules/@biomejs/biome/configuration_schema.json",
"extends": ["../../../../biome.jsonc"]
}
@@ -0,0 +1,7 @@
import { defineConfig } from 'eslint/config';
import { frontendConfig } from '@n8n/eslint-config/frontend';
export default defineConfig(frontendConfig, {
files: ['**/*.test.ts'],
rules: { '@typescript-eslint/no-unsafe-assignment': 'warn' },
});
@@ -0,0 +1,49 @@
{
"name": "@n8n/composables",
"type": "module",
"version": "1.14.0",
"files": [
"dist"
],
"exports": {
"./*": {
"types": "./dist/*.d.mts",
"import": "./dist/*.mjs",
"require": "./dist/*.cjs"
}
},
"scripts": {
"dev": "tsdown --watch",
"build": "tsdown",
"preview": "vite preview",
"typecheck": "vue-tsc --noEmit",
"test": "vitest run",
"test:dev": "vitest --silent=false",
"lint": "eslint src --quiet",
"lint:fix": "eslint src --fix",
"format": "biome format --write . && prettier --write . --ignore-path ../../../../.prettierignore",
"format:check": "biome ci . && prettier --check . --ignore-path ../../../../.prettierignore"
},
"devDependencies": {
"@n8n/eslint-config": "workspace:*",
"@n8n/typescript-config": "workspace:*",
"@n8n/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",
"@vueuse/core": "catalog:frontend",
"vue": "catalog:frontend",
"tsdown": "catalog:",
"typescript": "catalog:",
"vite": "catalog:",
"vitest": "catalog:",
"vue-tsc": "catalog:frontend"
},
"peerDependencies": {
"@vueuse/core": "catalog:frontend",
"vue": "catalog:frontend"
},
"license": "See LICENSE.md file in the root of the repository"
}
@@ -0,0 +1,4 @@
import '@testing-library/jest-dom';
import { configure } from '@testing-library/vue';
configure({ testIdAttribute: 'data-test-id' });
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
@@ -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);
});
});
});
@@ -0,0 +1,46 @@
import { computed, 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');
const controlKeyText = computed(() => (isMacOs.value ? '⌘' : 'Ctrl'));
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,
controlKeyText,
isCtrlKeyPressed,
};
}
@@ -0,0 +1,114 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { useDocumentTitle } from './useDocumentTitle';
describe('useDocumentTitle', () => {
beforeEach(() => {
document.title = '';
});
it('should set the document title', () => {
const { set } = useDocumentTitle();
set('Test Title');
expect(document.title).toBe('Test Title - n8n');
});
it('should reset the document title', () => {
const { set, reset } = useDocumentTitle();
set('Test Title');
reset();
expect(document.title).toBe('Workflow Automation - n8n');
});
it('should use the correct suffix for the release channel', () => {
const { set } = useDocumentTitle({ releaseChannel: 'beta' });
set('Test Title');
expect(document.title).toBe('Test Title - n8n[BETA]');
});
it('should use default suffix for stable release channel', () => {
const { set } = useDocumentTitle({ releaseChannel: 'stable' });
set('Test Title');
expect(document.title).toBe('Test Title - n8n');
});
it('should use default suffix when release channel is undefined', () => {
const { set } = useDocumentTitle({ releaseChannel: undefined });
set('Test Title');
expect(document.title).toBe('Test Title - n8n');
});
describe('setDocumentTitle', () => {
it('should set document title with IDLE status', () => {
const { setDocumentTitle } = useDocumentTitle();
setDocumentTitle('My Workflow', 'IDLE');
expect(document.title).toBe('▶️ My Workflow - n8n');
});
it('should set document title with EXECUTING status', () => {
const { setDocumentTitle } = useDocumentTitle();
setDocumentTitle('My Workflow', 'EXECUTING');
expect(document.title).toBe('🔄 My Workflow - n8n');
});
it('should set document title with ERROR status', () => {
const { setDocumentTitle } = useDocumentTitle();
setDocumentTitle('My Workflow', 'ERROR');
expect(document.title).toBe('⚠️ My Workflow - n8n');
});
it('should set document title with DEBUG status', () => {
const { setDocumentTitle } = useDocumentTitle();
setDocumentTitle('My Workflow', 'DEBUG');
expect(document.title).toBe('⚠️ My Workflow - n8n');
});
it('should set document title with AI_BUILDING status', () => {
const { setDocumentTitle } = useDocumentTitle();
setDocumentTitle('My Workflow', 'AI_BUILDING');
expect(document.title).toBe('[Building] My Workflow - n8n');
});
it('should set document title with AI_DONE status', () => {
const { setDocumentTitle } = useDocumentTitle();
setDocumentTitle('My Workflow', 'AI_DONE');
expect(document.title).toBe('[Done] My Workflow - n8n');
});
});
describe('getDocumentState', () => {
it('should return undefined initially', () => {
const { getDocumentState } = useDocumentTitle();
expect(getDocumentState()).toBeUndefined();
});
it('should return the current state after setDocumentTitle is called', () => {
const { setDocumentTitle, getDocumentState } = useDocumentTitle();
setDocumentTitle('My Workflow', 'AI_BUILDING');
expect(getDocumentState()).toBe('AI_BUILDING');
});
it('should track state changes', () => {
const { setDocumentTitle, getDocumentState } = useDocumentTitle();
setDocumentTitle('My Workflow', 'IDLE');
expect(getDocumentState()).toBe('IDLE');
setDocumentTitle('My Workflow', 'AI_BUILDING');
expect(getDocumentState()).toBe('AI_BUILDING');
setDocumentTitle('My Workflow', 'AI_DONE');
expect(getDocumentState()).toBe('AI_DONE');
});
it('should return undefined after reset is called', () => {
const { setDocumentTitle, getDocumentState, reset } = useDocumentTitle();
setDocumentTitle('My Workflow', 'AI_DONE');
expect(getDocumentState()).toBe('AI_DONE');
reset();
expect(getDocumentState()).toBeUndefined();
});
});
});
@@ -0,0 +1,65 @@
import { ref, type Ref } from 'vue';
const DEFAULT_TITLE = 'n8n';
const DEFAULT_TAGLINE = 'Workflow Automation';
export type WorkflowTitleStatus =
| 'EXECUTING'
| 'IDLE'
| 'ERROR'
| 'DEBUG'
| 'AI_BUILDING'
| 'AI_DONE';
export interface UseDocumentTitleOptions {
/**
* The release channel (e.g., 'stable', 'beta', 'dev').
* If not provided or 'stable', the title will be 'n8n'.
* Otherwise, it will be 'n8n[CHANNEL]'.
*/
releaseChannel?: string;
/**
* Optional window reference for setting the document title.
* Useful for pop-out windows.
*/
windowRef?: Ref<Window | undefined>;
}
export function useDocumentTitle(options: UseDocumentTitleOptions = {}) {
const { releaseChannel, windowRef } = options;
const suffix =
!releaseChannel || releaseChannel === 'stable'
? DEFAULT_TITLE
: `${DEFAULT_TITLE}[${releaseChannel.toUpperCase()}]`;
const currentState = ref<WorkflowTitleStatus | undefined>(undefined);
const set = (title: string) => {
const sections = [title || DEFAULT_TAGLINE, suffix];
(windowRef?.value?.document ?? document).title = sections.join(' - ');
};
const reset = () => {
currentState.value = undefined;
set('');
};
const setDocumentTitle = (workflowName: string, status: WorkflowTitleStatus) => {
currentState.value = status;
let prefix = '⚠️';
if (status === 'EXECUTING') {
prefix = '🔄';
} else if (status === 'IDLE') {
prefix = '▶️';
} else if (status === 'AI_BUILDING') {
prefix = '[Building]';
} else if (status === 'AI_DONE') {
prefix = '[Done]';
}
set(`${prefix} ${workflowName}`);
};
const getDocumentState = () => currentState.value;
return { set, reset, setDocumentTitle, getDocumentState };
}
@@ -0,0 +1,71 @@
import { onKeyDown, onKeyUp } from '@vueuse/core';
import { ref } from 'vue';
import { useShortKeyPress } from './useShortKeyPress';
vi.mock('@vueuse/core', () => ({
onKeyDown: vi.fn(),
onKeyUp: vi.fn(),
}));
describe('useShortKeyPress', () => {
it('should call the function on short key press', async () => {
vi.useFakeTimers();
const fn = vi.fn();
const key = 'a';
const threshold = 300;
const disabled = ref(false);
useShortKeyPress(key, fn, { threshold, disabled });
const keyDownHandler = vi.mocked(onKeyDown).mock.calls[0][1];
const keyUpHandler = vi.mocked(onKeyUp).mock.calls[0][1];
keyDownHandler(new KeyboardEvent('keydown', { key }));
await vi.advanceTimersByTimeAsync(100);
keyUpHandler(new KeyboardEvent('keydown', { key }));
expect(fn).toHaveBeenCalled();
});
it('should not call the function if key press duration exceeds threshold', async () => {
vi.useFakeTimers();
const fn = vi.fn();
const key = 'a';
const threshold = 300;
const disabled = ref(false);
useShortKeyPress(key, fn, { threshold, disabled });
const keyDownHandler = vi.mocked(onKeyDown).mock.calls[0][1];
const keyUpHandler = vi.mocked(onKeyUp).mock.calls[0][1];
keyDownHandler(new KeyboardEvent('keydown', { key }));
await vi.advanceTimersByTimeAsync(400);
keyUpHandler(new KeyboardEvent('keydown', { key }));
expect(fn).not.toHaveBeenCalled();
});
it('should not call the function if disabled is true', async () => {
vi.useFakeTimers();
const fn = vi.fn();
const key = 'a';
const threshold = 300;
const disabled = ref(true);
useShortKeyPress(key, fn, { threshold, disabled });
const keyDownHandler = vi.mocked(onKeyDown).mock.calls[0][1];
const keyUpHandler = vi.mocked(onKeyUp).mock.calls[0][1];
keyDownHandler(new KeyboardEvent('keydown', { key }));
await vi.advanceTimersByTimeAsync(100);
keyUpHandler(new KeyboardEvent('keydown', { key }));
expect(fn).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,41 @@
import { onKeyDown, onKeyUp } from '@vueuse/core';
import type { KeyFilter } from '@vueuse/core';
import { ref, unref } from 'vue';
import type { MaybeRefOrGetter } from 'vue';
export function useShortKeyPress(
key: KeyFilter,
fn: () => void,
{
dedupe = true,
threshold = 300,
disabled = false,
}: {
dedupe?: boolean;
threshold?: number;
disabled?: MaybeRefOrGetter<boolean>;
},
) {
const keyDownTime = ref<number | null>(null);
onKeyDown(
key,
() => {
if (unref(disabled)) return;
keyDownTime.value = Date.now();
},
{
dedupe,
},
);
onKeyUp(key, () => {
if (unref(disabled) || !keyDownTime.value) return;
const isShortPress = Date.now() - keyDownTime.value < threshold;
if (isShortPress) {
fn();
}
});
}
@@ -0,0 +1,53 @@
import { nextTick, ref } from 'vue';
import { useThrottleWithReactiveDelay } from './useThrottleWithReactiveDelay';
describe(useThrottleWithReactiveDelay, () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should return throttled ref with initial value', () => {
const state = ref('initial');
const delay = ref(100);
const throttled = useThrottleWithReactiveDelay(state, delay);
expect(throttled.value).toBe('initial');
});
it('should throttle state updates', async () => {
const state = ref('initial');
const delay = ref(100);
const throttled = useThrottleWithReactiveDelay(state, delay);
state.value = 'updated';
await nextTick();
expect(throttled.value).toBe('initial');
vi.advanceTimersByTime(100);
await nextTick();
expect(throttled.value).toBe('updated');
});
it('should respect reactive delay changes', async () => {
const state = ref('initial');
const delay = ref(100);
const throttled = useThrottleWithReactiveDelay(state, delay);
delay.value = 200;
state.value = 'updated';
await nextTick();
vi.advanceTimersByTime(100);
await nextTick();
expect(throttled.value).toBe('initial');
vi.advanceTimersByTime(100);
await nextTick();
expect(throttled.value).toBe('updated');
});
});
@@ -0,0 +1,24 @@
import { useThrottleFn } from '@vueuse/core';
import { shallowRef, watch, type Ref, type ShallowRef } from 'vue';
/**
* Similar to `useThrottle` from @vueuse/core, but with changeable delay
*/
export function useThrottleWithReactiveDelay<T>(state: Ref<T>, delay: Ref<number>): ShallowRef<T> {
const throttled = shallowRef(state.value);
watch(
state,
useThrottleFn(
(latest: T) => {
throttled.value = latest;
},
delay,
true,
true,
),
{ immediate: true },
);
return throttled;
}
@@ -0,0 +1,11 @@
{
"extends": "@n8n/typescript-config/tsconfig.frontend.json",
"compilerOptions": {
"baseUrl": ".",
"rootDir": ".",
"outDir": "dist",
"types": ["vite/client", "vitest/globals"],
"isolatedModules": true
},
"include": ["src/**/*.ts", "src/**/*.vue", "vite.config.ts", "tsdown.config.ts"]
}
@@ -0,0 +1,11 @@
import { defineConfig } from 'tsdown';
// eslint-disable-next-line import-x/no-default-export
export default defineConfig({
entry: ['src/**/*.ts', '!src/**/*.test.ts', '!src/**/*.d.ts', '!src/__tests__/**/*'],
format: ['cjs', 'esm'],
clean: true,
dts: true,
sourcemap: true,
hash: false,
});
@@ -0,0 +1,4 @@
import { defineConfig, mergeConfig } from 'vite';
import { vitestConfig } from '@n8n/vitest-config/frontend';
export default mergeConfig(defineConfig({}), vitestConfig);