refactor(editor): Move editor-ui and design-system to frontend dir (no-changelog) (#13564)

This commit is contained in:
Alex Grozav
2025-02-28 14:28:30 +02:00
committed by GitHub
parent 684353436d
commit f5743176e5
1635 changed files with 805 additions and 1079 deletions

View File

@@ -0,0 +1,203 @@
<script setup lang="ts">
import '@/polyfills';
import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue';
import { useRoute } from 'vue-router';
import LoadingView from '@/views/LoadingView.vue';
import BannerStack from '@/components/banners/BannerStack.vue';
import AskAssistantChat from '@/components/AskAssistant/AskAssistantChat.vue';
import Modals from '@/components/Modals.vue';
import Telemetry from '@/components/Telemetry.vue';
import AskAssistantFloatingButton from '@/components/AskAssistant/AskAssistantFloatingButton.vue';
import { loadLanguage } from '@/plugins/i18n';
import { APP_MODALS_ELEMENT_ID, HIRING_BANNER, VIEWS } from '@/constants';
import { useRootStore } from '@/stores/root.store';
import { useAssistantStore } from '@/stores/assistant.store';
import { useUIStore } from '@/stores/ui.store';
import { useUsersStore } from '@/stores/users.store';
import { useSettingsStore } from '@/stores/settings.store';
import { useHistoryHelper } from '@/composables/useHistoryHelper';
import { useStyles } from './composables/useStyles';
const route = useRoute();
const rootStore = useRootStore();
const assistantStore = useAssistantStore();
const uiStore = useUIStore();
const usersStore = useUsersStore();
const settingsStore = useSettingsStore();
const { setAppZIndexes } = useStyles();
// Initialize undo/redo
useHistoryHelper(route);
const loading = ref(true);
const defaultLocale = computed(() => rootStore.defaultLocale);
const isDemoMode = computed(() => route.name === VIEWS.DEMO);
const showAssistantButton = computed(() => assistantStore.canShowAssistantButtonsOnCanvas);
const hasContentFooter = ref(false);
const appGrid = ref<Element | null>(null);
const assistantSidebarWidth = computed(() => assistantStore.chatWidth);
onMounted(async () => {
setAppZIndexes();
logHiringBanner();
loading.value = false;
window.addEventListener('resize', updateGridWidth);
await updateGridWidth();
});
onBeforeUnmount(() => {
window.removeEventListener('resize', updateGridWidth);
});
const logHiringBanner = () => {
if (settingsStore.isHiringBannerEnabled && !isDemoMode.value) {
console.log(HIRING_BANNER);
}
};
const updateGridWidth = async () => {
await nextTick();
if (appGrid.value) {
uiStore.appGridWidth = appGrid.value.clientWidth;
}
};
// As assistant sidebar width changes, recalculate the total width regularly
watch(assistantSidebarWidth, async () => {
await updateGridWidth();
});
watch(route, (r) => {
hasContentFooter.value = r.matched.some(
(matchedRoute) => matchedRoute.components?.footer !== undefined,
);
});
watch(defaultLocale, (newLocale) => {
void loadLanguage(newLocale);
});
</script>
<template>
<LoadingView v-if="loading" />
<div
v-else
id="n8n-app"
:class="{
[$style.container]: true,
[$style.sidebarCollapsed]: uiStore.sidebarMenuCollapsed,
}"
>
<div id="app-grid" ref="appGrid" :class="$style['app-grid']">
<div id="banners" :class="$style.banners">
<BannerStack v-if="!isDemoMode" />
</div>
<div id="header" :class="$style.header">
<RouterView name="header" />
</div>
<div v-if="usersStore.currentUser" id="sidebar" :class="$style.sidebar">
<RouterView name="sidebar" />
</div>
<div id="content" :class="$style.content">
<div :class="$style.contentWrapper">
<RouterView v-slot="{ Component }">
<KeepAlive v-if="$route.meta.keepWorkflowAlive" include="NodeView" :max="1">
<component :is="Component" />
</KeepAlive>
<component :is="Component" v-else />
</RouterView>
</div>
<div v-if="hasContentFooter" :class="$style.contentFooter">
<RouterView name="footer" />
</div>
</div>
<div :id="APP_MODALS_ELEMENT_ID" :class="$style.modals">
<Modals />
</div>
<Telemetry />
<AskAssistantFloatingButton v-if="showAssistantButton" />
</div>
<AskAssistantChat />
</div>
</template>
<style lang="scss" module>
// On the root level, whole app is a flex container
// with app grid and assistant sidebar as children
.container {
height: 100vh;
overflow: hidden;
display: grid;
grid-template-columns: 1fr auto;
}
// App grid is the main app layout including modals and other absolute positioned elements
.app-grid {
position: relative;
display: grid;
height: 100vh;
grid-template-areas:
'banners banners'
'sidebar header'
'sidebar content';
grid-template-columns: auto 1fr;
grid-template-rows: auto auto 1fr;
}
.banners {
grid-area: banners;
z-index: var(--z-index-top-banners);
}
.content {
display: flex;
flex-direction: column;
align-items: center;
overflow: auto;
grid-area: content;
}
.contentFooter {
height: auto;
z-index: 10;
width: 100%;
display: none;
// Only show footer if there's content
&:has(*) {
display: block;
}
}
.contentWrapper {
display: flex;
grid-area: content;
position: relative;
overflow: auto;
height: 100%;
width: 100%;
justify-content: center;
main {
width: 100%;
height: 100%;
}
}
.header {
grid-area: header;
z-index: var(--z-index-app-header);
min-width: 0;
min-height: 0;
}
.sidebar {
grid-area: sidebar;
z-index: var(--z-index-app-sidebar);
}
.modals {
width: 100%;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,198 @@
import { CanvasKey, CanvasNodeHandleKey, CanvasNodeKey } from '@/constants';
import { computed, ref } from 'vue';
import type {
CanvasInjectionData,
CanvasNode,
CanvasNodeData,
CanvasNodeEventBusEvents,
CanvasNodeHandleInjectionData,
CanvasNodeInjectionData,
ConnectStartEvent,
ExecutionOutputMapData,
} from '@/types';
import { CanvasConnectionMode, CanvasNodeRenderType } from '@/types';
import { NodeConnectionType } from 'n8n-workflow';
import type { EventBus } from '@n8n/utils/event-bus';
import { createEventBus } from '@n8n/utils/event-bus';
import type { ViewportTransform } from '@vue-flow/core';
export function createCanvasNodeData({
id = 'node',
name = 'Test Node',
subtitle = 'Test Node Subtitle',
type = 'test',
typeVersion = 1,
disabled = false,
inputs = [],
outputs = [],
connections = { [CanvasConnectionMode.Input]: {}, [CanvasConnectionMode.Output]: {} },
execution = { running: false },
issues = { items: [], visible: false },
pinnedData = { count: 0, visible: false },
runData = { outputMap: {}, iterations: 0, visible: false },
render = {
type: CanvasNodeRenderType.Default,
options: { configurable: false, configuration: false, trigger: false },
},
}: Partial<CanvasNodeData> = {}): CanvasNodeData {
return {
id,
name,
subtitle,
type,
typeVersion,
execution,
issues,
pinnedData,
runData,
disabled,
inputs,
outputs,
connections,
render,
};
}
export function createCanvasNodeElement({
id = '1',
type = 'default',
label = 'Node',
position = { x: 100, y: 100 },
data,
}: Partial<Omit<CanvasNode, 'data'> & { data: Partial<CanvasNodeData> }> = {}): CanvasNode {
return {
id,
type,
label,
position,
data: createCanvasNodeData({ id, type, ...data }),
};
}
export function createCanvasNodeProps({
id = 'node',
label = 'Test Node',
selected = false,
readOnly = false,
data = {},
}: {
id?: string;
label?: string;
selected?: boolean;
readOnly?: boolean;
data?: Partial<CanvasNodeData>;
} = {}) {
return {
id,
label,
selected,
readOnly,
data: createCanvasNodeData(data),
};
}
export function createCanvasProvide({
initialized = true,
isExecuting = false,
connectingHandle = undefined,
viewport = { x: 0, y: 0, zoom: 1 },
}: {
initialized?: boolean;
isExecuting?: boolean;
connectingHandle?: ConnectStartEvent;
viewport?: ViewportTransform;
} = {}) {
return {
[String(CanvasKey)]: {
initialized: ref(initialized),
isExecuting: ref(isExecuting),
connectingHandle: ref(connectingHandle),
viewport: ref(viewport),
} satisfies CanvasInjectionData,
};
}
export function createCanvasNodeProvide({
id = 'node',
label = 'Test Node',
selected = false,
readOnly = false,
data = {},
eventBus = createEventBus<CanvasNodeEventBusEvents>(),
}: {
id?: string;
label?: string;
selected?: boolean;
readOnly?: boolean;
data?: Partial<CanvasNodeData>;
eventBus?: EventBus<CanvasNodeEventBusEvents>;
} = {}) {
const props = createCanvasNodeProps({ id, label, selected, readOnly, data });
return {
[String(CanvasNodeKey)]: {
id: ref(props.id),
label: ref(props.label),
selected: ref(props.selected),
readOnly: ref(props.readOnly),
data: ref(props.data),
eventBus: ref(eventBus),
} satisfies CanvasNodeInjectionData,
};
}
export function createCanvasHandleProvide({
label = 'Handle',
mode = CanvasConnectionMode.Input,
type = NodeConnectionType.Main,
index = 0,
runData,
isConnected = false,
isConnecting = false,
isReadOnly = false,
isRequired = false,
}: {
label?: string;
mode?: CanvasConnectionMode;
type?: NodeConnectionType;
index?: number;
runData?: ExecutionOutputMapData;
isConnected?: boolean;
isConnecting?: boolean;
isReadOnly?: boolean;
isRequired?: boolean;
} = {}) {
const maxConnections = [NodeConnectionType.Main, NodeConnectionType.AiTool].includes(type)
? Infinity
: 1;
return {
[String(CanvasNodeHandleKey)]: {
label: ref(label),
mode: ref(mode),
type: ref(type),
index: ref(index),
isConnected: computed(() => isConnected),
isConnecting: ref(isConnecting),
isReadOnly: ref(isReadOnly),
isRequired: ref(isRequired),
maxConnections: ref(maxConnections),
runData: ref(runData),
} satisfies CanvasNodeHandleInjectionData,
};
}
export function createCanvasConnection(
nodeA: CanvasNode,
nodeB: CanvasNode,
{ sourceIndex = 0, targetIndex = 0 } = {},
) {
const nodeAOutput = nodeA.data?.outputs[sourceIndex];
const nodeBInput = nodeA.data?.inputs[targetIndex];
return {
id: `${nodeA.id}-${nodeB.id}`,
source: nodeA.id,
target: nodeB.id,
...(nodeAOutput ? { sourceHandle: `outputs/${nodeAOutput.type}/${nodeAOutput.index}` } : {}),
...(nodeBInput ? { targetHandle: `inputs/${nodeBInput.type}/${nodeBInput.index}` } : {}),
};
}

View File

@@ -0,0 +1 @@
export * from './canvas';

View File

@@ -0,0 +1,41 @@
import { faker } from '@faker-js/faker';
import type {
Project,
ProjectListItem,
ProjectSharingData,
ProjectType,
} from '@/types/projects.types';
import { ProjectTypes } from '@/types/projects.types';
export const createProjectSharingData = (projectType?: ProjectType): ProjectSharingData => ({
id: faker.string.uuid(),
name: faker.lorem.words({ min: 1, max: 3 }),
icon: { type: 'icon', value: 'folder' },
type: projectType ?? ProjectTypes.Personal,
createdAt: faker.date.past().toISOString(),
updatedAt: faker.date.recent().toISOString(),
});
export const createProjectListItem = (projectType?: ProjectType): ProjectListItem => {
const project = createProjectSharingData(projectType);
return {
...project,
role: 'project:editor',
createdAt: faker.date.past().toISOString(),
updatedAt: faker.date.recent().toISOString(),
};
};
export function createTestProject(data: Partial<Project>): Project {
return {
id: faker.string.uuid(),
name: faker.lorem.words({ min: 1, max: 3 }),
icon: { type: 'icon', value: 'folder' },
createdAt: faker.date.past().toISOString(),
updatedAt: faker.date.recent().toISOString(),
type: ProjectTypes.Team,
relations: [],
scopes: [],
...data,
};
}

View File

@@ -0,0 +1,16 @@
import { faker } from '@faker-js/faker';
import type { IUser } from '@/Interface';
import { SignInType } from '@/constants';
export const createUser = (overrides?: Partial<IUser>): IUser => ({
id: faker.string.uuid(),
email: faker.internet.email(),
firstName: faker.person.firstName(),
lastName: faker.person.lastName(),
isDefaultUser: false,
isPending: false,
isPendingUser: false,
mfaEnabled: false,
signInType: SignInType.EMAIL,
...overrides,
});

View File

@@ -0,0 +1,145 @@
import type { FrontendSettings } from '@n8n/api-types';
export const defaultSettings: FrontendSettings = {
inE2ETests: false,
databaseType: 'sqlite',
isDocker: false,
pruning: {
isEnabled: false,
maxAge: 0,
maxCount: 0,
},
allowedModules: {},
communityNodesEnabled: false,
defaultLocale: '',
endpointForm: '',
endpointFormTest: '',
endpointFormWaiting: '',
endpointWebhook: '',
endpointWebhookTest: '',
endpointWebhookWaiting: '',
enterprise: {
sharing: false,
ldap: false,
saml: false,
logStreaming: false,
debugInEditor: false,
advancedExecutionFilters: false,
variables: false,
sourceControl: false,
auditLogs: false,
showNonProdBanner: false,
workflowHistory: false,
binaryDataS3: false,
externalSecrets: false,
workerView: false,
advancedPermissions: false,
projects: {
team: {
limit: 1,
},
},
},
expressions: {
evaluator: 'tournament',
},
executionMode: 'regular',
executionTimeout: 0,
hideUsagePage: false,
hiringBannerEnabled: false,
instanceId: '',
license: { environment: 'development', consumerId: 'unknown' },
logLevel: 'info',
maxExecutionTimeout: 0,
oauthCallbackUrls: { oauth1: '', oauth2: '' },
personalizationSurveyEnabled: false,
releaseChannel: 'stable',
posthog: {
apiHost: '',
apiKey: '',
autocapture: false,
debug: false,
disableSessionRecording: false,
enabled: false,
},
publicApi: {
apiKeysPerUserLimit: 0,
enabled: false,
latestVersion: 0,
path: '',
swaggerUi: { enabled: false },
},
pushBackend: 'websocket',
saveDataErrorExecution: 'all',
saveDataSuccessExecution: 'all',
saveManualExecutions: false,
saveExecutionProgress: false,
sso: {
ldap: { loginEnabled: false, loginLabel: '' },
saml: { loginEnabled: false, loginLabel: '' },
},
telemetry: {
enabled: false,
},
templates: { enabled: false, host: '' },
timezone: '',
urlBaseEditor: '',
urlBaseWebhook: '',
authCookie: {
secure: false,
},
userManagement: {
showSetupOnFirstLoad: false,
smtpSetup: true,
authenticationMethod: 'email',
quota: 10,
},
versionCli: '',
nodeJsVersion: '',
concurrency: -1,
versionNotifications: {
enabled: true,
endpoint: '',
infoUrl: '',
},
workflowCallerPolicyDefaultOption: 'any',
workflowTagsDisabled: false,
variables: {
limit: -1,
},
deployment: {
type: 'default',
},
banners: {
dismissed: [],
},
binaryDataMode: 'default',
previewMode: false,
mfa: {
enabled: false,
},
askAi: {
enabled: false,
},
workflowHistory: {
pruneTime: 0,
licensePruneTime: 0,
},
security: {
blockFileAccessToN8nFiles: false,
},
aiAssistant: {
enabled: false,
},
aiCredits: {
enabled: false,
credits: 0,
},
easyAIWorkflowOnboarded: false,
partialExecution: {
version: 1,
},
folders: {
enabled: false,
},
};

View File

@@ -0,0 +1,197 @@
import type {
INodeType,
INodeTypeData,
INodeTypes,
IConnections,
IDataObject,
INode,
IPinData,
IWorkflowSettings,
LoadedClass,
INodeTypeDescription,
INodeIssues,
} from 'n8n-workflow';
import { NodeConnectionType, NodeHelpers, Workflow } from 'n8n-workflow';
import { v4 as uuid } from 'uuid';
import { mock } from 'vitest-mock-extended';
import {
AGENT_NODE_TYPE,
CHAT_TRIGGER_NODE_TYPE,
CODE_NODE_TYPE,
EXECUTABLE_TRIGGER_NODE_TYPES,
MANUAL_TRIGGER_NODE_TYPE,
NO_OP_NODE_TYPE,
SET_NODE_TYPE,
STICKY_NODE_TYPE,
} from '@/constants';
import type { INodeUi, IWorkflowDb } from '@/Interface';
import { CanvasNodeRenderType } from '@/types';
export const mockNode = ({
id = uuid(),
name,
type,
position = [0, 0],
disabled = false,
issues = undefined,
typeVersion = 1,
parameters = {},
}: {
id?: INodeUi['id'];
name: INodeUi['name'];
type: INodeUi['type'];
position?: INodeUi['position'];
disabled?: INodeUi['disabled'];
issues?: INodeIssues;
typeVersion?: INodeUi['typeVersion'];
parameters?: INodeUi['parameters'];
}) => mock<INodeUi>({ id, name, type, position, disabled, issues, typeVersion, parameters });
export const mockNodeTypeDescription = ({
name = SET_NODE_TYPE,
version = 1,
credentials = [],
inputs = [NodeConnectionType.Main],
outputs = [NodeConnectionType.Main],
codex = undefined,
properties = [],
}: {
name?: INodeTypeDescription['name'];
version?: INodeTypeDescription['version'];
credentials?: INodeTypeDescription['credentials'];
inputs?: INodeTypeDescription['inputs'];
outputs?: INodeTypeDescription['outputs'];
codex?: INodeTypeDescription['codex'];
properties?: INodeTypeDescription['properties'];
} = {}) =>
mock<INodeTypeDescription>({
name,
displayName: name,
description: '',
version,
defaults: {
name,
},
defaultVersion: Array.isArray(version) ? version[version.length - 1] : version,
properties: properties as [],
maxNodes: Infinity,
group: EXECUTABLE_TRIGGER_NODE_TYPES.includes(name) ? ['trigger'] : [],
inputs,
outputs,
codex,
credentials,
documentationUrl: 'https://docs',
webhooks: undefined,
});
export const mockLoadedNodeType = (name: string) =>
mock<LoadedClass<INodeType>>({
type: mock<INodeType>({
// @ts-expect-error
description: mockNodeTypeDescription({ name }),
}),
});
export const mockNodes = [
mockNode({ name: 'Manual Trigger', type: MANUAL_TRIGGER_NODE_TYPE }),
mockNode({ name: 'Set', type: SET_NODE_TYPE }),
mockNode({ name: 'Code', type: CODE_NODE_TYPE }),
mockNode({ name: 'Rename', type: SET_NODE_TYPE }),
mockNode({ name: 'Chat Trigger', type: CHAT_TRIGGER_NODE_TYPE }),
mockNode({ name: 'Agent', type: AGENT_NODE_TYPE }),
mockNode({ name: 'Sticky', type: STICKY_NODE_TYPE }),
mockNode({ name: CanvasNodeRenderType.AddNodes, type: CanvasNodeRenderType.AddNodes }),
mockNode({ name: 'End', type: NO_OP_NODE_TYPE }),
];
export const defaultNodeTypes = mockNodes.reduce<INodeTypeData>((acc, { type }) => {
acc[type] = mockLoadedNodeType(type);
return acc;
}, {});
export const defaultNodeDescriptions = Object.values(defaultNodeTypes).map(
({ type }) => type.description,
) as INodeTypeDescription[];
const nodeTypes = mock<INodeTypes>({
getByName(nodeType) {
return defaultNodeTypes[nodeType].type;
},
getByNameAndVersion(nodeType: string, version?: number): INodeType {
return NodeHelpers.getVersionedNodeType(defaultNodeTypes[nodeType].type, version);
},
});
export function createTestWorkflowObject({
id = uuid(),
name = 'Test Workflow',
nodes = [],
connections = {},
active = false,
staticData = {},
settings = {},
pinData = {},
}: {
id?: string;
name?: string;
nodes?: INode[];
connections?: IConnections;
active?: boolean;
staticData?: IDataObject;
settings?: IWorkflowSettings;
pinData?: IPinData;
} = {}) {
return new Workflow({
id,
name,
nodes,
connections,
active,
staticData,
settings,
pinData,
nodeTypes,
});
}
export function createTestWorkflow({
id = uuid(),
name = 'Test Workflow',
nodes = [],
connections = {},
active = false,
settings = {
timezone: 'DEFAULT',
executionOrder: 'v1',
},
pinData = {},
...rest
}: Partial<IWorkflowDb> = {}): IWorkflowDb {
return {
createdAt: '',
updatedAt: '',
id,
name,
nodes,
connections,
active,
settings,
versionId: '1',
meta: {},
pinData,
...rest,
};
}
export function createTestNode(node: Partial<INode> = {}): INode {
return {
id: uuid(),
name: 'Node',
type: 'n8n-nodes-base.set',
typeVersion: 1,
position: [0, 0] as [number, number],
parameters: {},
...node,
};
}

View File

@@ -0,0 +1,90 @@
import type { Plugin } from 'vue';
import { render } from '@testing-library/vue';
import { i18nInstance } from '@/plugins/i18n';
import { GlobalComponentsPlugin } from '@/plugins/components';
import { GlobalDirectivesPlugin } from '@/plugins/directives';
import { FontAwesomePlugin } from '@/plugins/icons';
import type { Pinia } from 'pinia';
import { PiniaVuePlugin } from 'pinia';
import type { Telemetry } from '@/plugins/telemetry';
import vueJsonPretty from 'vue-json-pretty';
import { merge } from 'lodash-es';
import type { TestingPinia } from '@pinia/testing';
export type RenderComponent = Parameters<typeof render>[0];
export type RenderOptions = Parameters<typeof render>[1] & {
pinia?: TestingPinia | Pinia;
};
const TelemetryPlugin: Plugin<{}> = {
install(app) {
app.config.globalProperties.$telemetry = {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
track(..._: unknown[]) {},
} as Telemetry;
},
};
const defaultOptions = {
global: {
stubs: {
'router-link': true,
'vue-json-pretty': vueJsonPretty,
},
plugins: [
i18nInstance,
PiniaVuePlugin,
FontAwesomePlugin,
GlobalComponentsPlugin,
GlobalDirectivesPlugin,
TelemetryPlugin,
],
},
};
export function renderComponent(component: RenderComponent, options: RenderOptions = {}) {
const { pinia, ...renderOptions } = options;
return render(component, {
...defaultOptions,
...renderOptions,
global: {
...defaultOptions.global,
...renderOptions.global,
stubs: { ...defaultOptions.global.stubs, ...(renderOptions.global?.stubs ?? {}) },
plugins: [
...defaultOptions.global.plugins,
...(renderOptions.global?.plugins ?? []),
...(pinia ? [pinia] : []),
],
},
});
}
export function createComponentRenderer(
component: RenderComponent,
defaultOptions: RenderOptions = {},
) {
return (options: RenderOptions = {}, rendererOptions: { merge?: boolean } = {}) =>
renderComponent(
component,
rendererOptions.merge
? merge(defaultOptions, options)
: {
...defaultOptions,
...options,
props: {
...defaultOptions.props,
...options.props,
},
global: {
...defaultOptions.global,
...options.global,
provide: {
...defaultOptions.global?.provide,
...options.global?.provide,
},
},
},
);
}

View File

@@ -0,0 +1,11 @@
import type { Server } from 'miragejs';
import { Response } from 'miragejs';
import type { AppSchema } from '../types';
export function routesForCredentials(server: Server) {
server.get('/rest/credentials', (schema: AppSchema) => {
const { models: data } = schema.all('credential');
return new Response(200, {}, { data });
});
}

View File

@@ -0,0 +1,11 @@
import type { Server } from 'miragejs';
import { Response } from 'miragejs';
import type { AppSchema } from '../types';
export function routesForCredentialTypes(server: Server) {
server.get('/types/credentials.json', (schema: AppSchema) => {
const { models: data } = schema.all('credentialType');
return new Response(200, {}, data);
});
}

View File

@@ -0,0 +1,24 @@
import type { Server } from 'miragejs';
import { routesForUsers } from './user';
import { routesForCredentials } from './credential';
import { routesForCredentialTypes } from './credentialType';
import { routesForVariables } from './variable';
import { routesForSettings } from './settings';
import { routesForSSO } from './sso';
import { routesForSourceControl } from './sourceControl';
import { routesForWorkflows } from './workflow';
import { routesForTags } from './tag';
const endpoints: Array<(server: Server) => void> = [
routesForCredentials,
routesForCredentialTypes,
routesForUsers,
routesForVariables,
routesForSettings,
routesForSSO,
routesForSourceControl,
routesForWorkflows,
routesForTags,
];
export { endpoints };

View File

@@ -0,0 +1,15 @@
import type { Server } from 'miragejs';
import { Response } from 'miragejs';
import { defaultSettings } from '../../defaults';
export function routesForSettings(server: Server) {
server.get('/rest/settings', () => {
return new Response(
200,
{},
{
data: defaultSettings,
},
);
});
}

View File

@@ -0,0 +1,86 @@
import type { Server, Request } from 'miragejs';
import { Response } from 'miragejs';
import { jsonParse } from 'n8n-workflow';
import type { AppSchema } from '@/__tests__/server/types';
import type { SourceControlPreferences } from '@/types/sourceControl.types';
export function routesForSourceControl(server: Server) {
const sourceControlApiRoot = '/rest/source-control';
const defaultSourceControlPreferences: SourceControlPreferences = {
branchName: '',
branches: [],
repositoryUrl: '',
branchReadOnly: false,
branchColor: '#1d6acb',
connected: false,
publicKey: 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHEX+25m',
keyGeneratorType: 'ed25519',
};
server.get(`${sourceControlApiRoot}/preferences`, () => {
return new Response(
200,
{},
{
data: defaultSourceControlPreferences,
},
);
});
server.post(`${sourceControlApiRoot}/preferences`, (_schema: AppSchema, request: Request) => {
const requestBody: Partial<SourceControlPreferences> = jsonParse(request.requestBody);
return new Response(
200,
{},
{
data: {
...defaultSourceControlPreferences,
...requestBody,
},
},
);
});
server.patch(`${sourceControlApiRoot}/preferences`, (_schema: AppSchema, request: Request) => {
const requestBody: Partial<SourceControlPreferences> = jsonParse(request.requestBody);
return new Response(
200,
{},
{
data: {
...defaultSourceControlPreferences,
...requestBody,
},
},
);
});
server.get(`${sourceControlApiRoot}/get-branches`, () => {
return new Response(
200,
{},
{
data: {
branches: ['main', 'dev'],
currentBranch: 'main',
},
},
);
});
server.post(`${sourceControlApiRoot}/disconnect`, () => {
return new Response(
200,
{},
{
data: {
...defaultSourceControlPreferences,
branchName: '',
connected: false,
},
},
);
});
}

View File

@@ -0,0 +1,35 @@
import type { SamlPreferences } from '@n8n/api-types';
import type { Server, Request } from 'miragejs';
import { Response } from 'miragejs';
import type { SamlPreferencesExtractedData } from '@/Interface';
import { faker } from '@faker-js/faker';
import type { AppSchema } from '@/__tests__/server/types';
import { jsonParse } from 'n8n-workflow';
let samlConfig = {
metadata: '<?xml version="1.0"?>',
metadataUrl: '',
entityID: faker.internet.url(),
returnUrl: faker.internet.url(),
} as SamlPreferences & SamlPreferencesExtractedData;
export function routesForSSO(server: Server) {
server.get('/rest/sso/saml/config', () => {
return new Response(200, {}, { data: samlConfig });
});
server.post('/rest/sso/saml/config', (_schema: AppSchema, request: Request) => {
const requestBody = jsonParse<object>(request.requestBody);
samlConfig = {
...samlConfig,
...requestBody,
};
return new Response(200, {}, { data: samlConfig });
});
server.get('/rest/sso/saml/config/test', () => {
return new Response(200, {}, { data: '<?xml version="1.0"?>' });
});
}

View File

@@ -0,0 +1,11 @@
import type { Server } from 'miragejs';
import { Response } from 'miragejs';
import type { AppSchema } from '../types';
export function routesForTags(server: Server) {
server.get('/rest/tags', (schema: AppSchema) => {
const { models: data } = schema.all('tag');
return new Response(200, {}, { data });
});
}

View File

@@ -0,0 +1,19 @@
import type { Server } from 'miragejs';
import { Response } from 'miragejs';
import type { AppSchema } from '../types';
export function routesForUsers(server: Server) {
server.get('/rest/users', (schema: AppSchema) => {
const { models: data } = schema.all('user');
return new Response(200, {}, { data });
});
server.get('/rest/login', (schema: AppSchema) => {
const model = schema.findBy('user', {
isDefaultUser: true,
});
return new Response(200, {}, { data: model?.attrs });
});
}

View File

@@ -0,0 +1,42 @@
import type { Request, Server } from 'miragejs';
import { Response } from 'miragejs';
import type { AppSchema } from '../types';
import { jsonParse } from 'n8n-workflow';
import type { EnvironmentVariable } from '@/Interface';
export function routesForVariables(server: Server) {
server.get('/rest/variables', (schema: AppSchema) => {
const { models: data } = schema.all('variable');
return new Response(200, {}, { data });
});
server.post('/rest/variables', (schema: AppSchema, request: Request) => {
const data = schema.create('variable', jsonParse(request.requestBody));
return new Response(200, {}, { data });
});
server.patch('/rest/variables/:id', (schema: AppSchema, request: Request) => {
const data: EnvironmentVariable = jsonParse(request.requestBody);
const id = request.params.id;
const model = schema.find('variable', id);
if (model) {
model.update(data);
}
return new Response(200, {}, { data: model?.attrs });
});
server.delete('/rest/variables/:id', (schema: AppSchema, request: Request) => {
const id = request.params.id;
const model = schema.find('variable', id);
if (model) {
model.destroy();
}
return new Response(200, {}, {});
});
}

View File

@@ -0,0 +1,16 @@
import type { Server } from 'miragejs';
import { Response } from 'miragejs';
import type { AppSchema } from '../types';
export function routesForWorkflows(server: Server) {
server.get('/rest/workflows', (schema: AppSchema) => {
const { models: data } = schema.all('workflow');
return new Response(200, {}, { data });
});
server.get('/rest/active-workflows', (schema: AppSchema) => {
const { models: data } = schema.all('workflow');
return new Response(200, {}, { data });
});
}

View File

@@ -0,0 +1,24 @@
import { Factory } from 'miragejs';
import { faker } from '@faker-js/faker';
import type { ICredentialsResponse } from '@/Interface';
export const credentialFactory = Factory.extend<ICredentialsResponse>({
id(i: number) {
return `${i}`;
},
createdAt() {
return faker.date.recent().toISOString();
},
name() {
return faker.company.name();
},
type() {
return 'notionApi';
},
updatedAt() {
return '';
},
isManaged() {
return false;
},
});

View File

@@ -0,0 +1,25 @@
import { Factory } from 'miragejs';
import type { ICredentialType } from 'n8n-workflow';
const credentialTypes = [
'airtableApi',
'dropboxApi',
'figmaApi',
'googleApi',
'gitlabApi',
'jenkinsApi',
'metabaseApi',
'notionApi',
];
export const credentialTypeFactory = Factory.extend<ICredentialType>({
name(i) {
return credentialTypes[i];
},
displayName(i) {
return credentialTypes[i];
},
properties() {
return [];
},
});

View File

@@ -0,0 +1,20 @@
import { userFactory } from './user';
import { credentialFactory } from './credential';
import { credentialTypeFactory } from './credentialType';
import { variableFactory } from './variable';
import { workflowFactory } from './workflow';
import { tagFactory } from './tag';
export * from './user';
export * from './credential';
export * from './credentialType';
export * from './variable';
export const factories = {
credential: credentialFactory,
credentialType: credentialTypeFactory,
user: userFactory,
variable: variableFactory,
workflow: workflowFactory,
tag: tagFactory,
};

View File

@@ -0,0 +1,12 @@
import { Factory } from 'miragejs';
import type { ITag } from '@/Interface';
import { faker } from '@faker-js/faker';
export const tagFactory = Factory.extend<ITag>({
id(i: number) {
return i.toString();
},
name() {
return faker.lorem.word();
},
});

View File

@@ -0,0 +1,31 @@
import { Factory } from 'miragejs';
import { faker } from '@faker-js/faker';
import { SignInType } from '@/constants';
import type { IUser } from '@/Interface';
export const userFactory = Factory.extend<IUser>({
id(i: number) {
return `${i}`;
},
firstName() {
return faker.name.firstName();
},
lastName() {
return faker.name.lastName();
},
isDefaultUser() {
return false;
},
isPending() {
return false;
},
isPendingUser() {
return false;
},
signInType(): SignInType {
return SignInType.EMAIL;
},
mfaEnabled() {
return false;
},
});

View File

@@ -0,0 +1,15 @@
import { Factory } from 'miragejs';
import { faker } from '@faker-js/faker';
import type { EnvironmentVariable } from '@/Interface';
export const variableFactory = Factory.extend<EnvironmentVariable>({
id(i: number) {
return `${i}`;
},
key() {
return `${faker.lorem.word()}`.toUpperCase();
},
value() {
return faker.internet.password(10);
},
});

View File

@@ -0,0 +1,33 @@
import { Factory } from 'miragejs';
import type { IWorkflowDb } from '@/Interface';
import { faker } from '@faker-js/faker';
export const workflowFactory = Factory.extend<IWorkflowDb>({
id(i: number) {
return i.toString();
},
versionId(i: number) {
return i.toString();
},
active() {
return faker.datatype.boolean();
},
nodes() {
return [];
},
connections() {
return {};
},
name() {
return faker.lorem.word();
},
createdAt() {
return faker.date.recent().toISOString();
},
updatedAt() {
return faker.date.recent().toISOString();
},
tags() {
return faker.lorem.words(2.5).split(' ');
},
});

View File

@@ -0,0 +1,7 @@
import { tags } from './tags';
import { workflows } from './workflows';
export const fixtures = {
tags,
workflows,
};

View File

@@ -0,0 +1,15 @@
import type { ITag } from '@/Interface';
export const tags: ITag[] = [
{
id: '1',
name: 'tag1',
},
{
id: '2',
name: 'tag2',
},
{
id: '3',
name: 'tag3',
},
];

View File

@@ -0,0 +1,47 @@
import type { IWorkflowDb } from '@/Interface';
import { faker } from '@faker-js/faker';
export const workflows = [
{
id: '1',
name: 'workflow1',
tags: [],
},
{
id: '2',
name: 'workflow2',
tags: [
{ id: '1', name: 'tag1' },
{ id: '2', name: 'tag2' },
],
},
{
id: '3',
name: 'workflow3',
tags: [
{ id: '1', name: 'tag1' },
{ id: '3', name: 'tag3' },
],
},
{
id: '4',
name: 'workflow4',
tags: [
{ id: '2', name: 'tag2' },
{ id: '3', name: 'tag3' },
],
},
{
id: '5',
name: 'workflow5',
tags: [
{ id: '1', name: 'tag1' },
{ id: '2', name: 'tag2' },
{ id: '3', name: 'tag3' },
],
},
].map((wf, idx) => ({
...wf,
createdAt: faker.date.recent().toISOString(),
updatedAt: new Date(`2024-1-${idx + 1}`).toISOString(),
})) as IWorkflowDb[];

View File

@@ -0,0 +1,47 @@
import { createServer } from 'miragejs';
import { endpoints } from './endpoints';
import { models } from './models';
import { factories } from './factories';
import { fixtures } from './fixtures';
export function setupServer() {
const server = createServer({
models,
factories,
fixtures,
seeds(server) {
server.loadFixtures('tags', 'workflows');
server.createList('credentialType', 8);
server.create('user', {
firstName: 'Nathan',
lastName: 'Doe',
isDefaultUser: true,
});
},
});
// Set server url prefix
server.urlPrefix = process.env.API_URL || '';
// Enable logging
server.logging = false;
// Handle defined endpoints
for (const endpointsFn of endpoints) {
endpointsFn(server);
}
// Handle undefined endpoints
server.post('/rest/:any', async () => ({}));
// Reset for everything else
server.namespace = '';
server.passthrough();
if (server.logging) {
console.log('Mirage database');
console.log(server.db.dump());
}
return server;
}

View File

@@ -0,0 +1,5 @@
import type { ICredentialsResponse } from '@/Interface';
import { Model } from 'miragejs';
import type { ModelDefinition } from 'miragejs/-types';
export const CredentialModel: ModelDefinition<ICredentialsResponse> = Model.extend({});

View File

@@ -0,0 +1,5 @@
import { Model } from 'miragejs';
import type { ModelDefinition } from 'miragejs/-types';
import type { ICredentialType } from 'n8n-workflow';
export const CredentialTypeModel: ModelDefinition<ICredentialType> = Model.extend({});

View File

@@ -0,0 +1,15 @@
import { UserModel } from './user';
import { CredentialModel } from './credential';
import { CredentialTypeModel } from './credentialType';
import { VariableModel } from './variable';
import { WorkflowModel } from './workflow';
import { TagModel } from './tag';
export const models = {
credential: CredentialModel,
credentialType: CredentialTypeModel,
user: UserModel,
variable: VariableModel,
workflow: WorkflowModel,
tag: TagModel,
};

View File

@@ -0,0 +1,5 @@
import type { ITag } from '@/Interface';
import { Model } from 'miragejs';
import type { ModelDefinition } from 'miragejs/-types';
export const TagModel: ModelDefinition<ITag> = Model.extend({});

View File

@@ -0,0 +1,5 @@
import type { IUser } from '@/Interface';
import { Model } from 'miragejs';
import type { ModelDefinition } from 'miragejs/-types';
export const UserModel: ModelDefinition<IUser> = Model.extend({});

View File

@@ -0,0 +1,5 @@
import type { EnvironmentVariable } from '@/Interface';
import { Model } from 'miragejs';
import type { ModelDefinition } from 'miragejs/-types';
export const VariableModel: ModelDefinition<EnvironmentVariable> = Model.extend({});

View File

@@ -0,0 +1,5 @@
import type { IWorkflowDb } from '@/Interface';
import { Model } from 'miragejs';
import type { ModelDefinition } from 'miragejs/-types';
export const WorkflowModel: ModelDefinition<IWorkflowDb> = Model.extend({});

View File

@@ -0,0 +1,9 @@
import type { Registry } from 'miragejs';
import type Schema from 'miragejs/orm/schema';
import type { models } from './models';
import type { factories } from './factories';
type AppRegistry = Registry<typeof models, typeof factories>;
export type AppSchema = Schema<AppRegistry>;

View File

@@ -0,0 +1,86 @@
import '@testing-library/jest-dom';
import { configure } from '@testing-library/vue';
import 'core-js/proposals/set-methods-v2';
configure({ testIdAttribute: 'data-test-id' });
window.ResizeObserver =
window.ResizeObserver ||
vi.fn().mockImplementation(() => ({
disconnect: vi.fn(),
observe: vi.fn(),
unobserve: vi.fn(),
}));
Element.prototype.scrollIntoView = vi.fn();
Range.prototype.getBoundingClientRect = vi.fn();
Range.prototype.getClientRects = vi.fn(() => ({
item: vi.fn(),
length: 0,
[Symbol.iterator]: vi.fn(),
}));
export class IntersectionObserver {
root = null;
rootMargin = '';
thresholds = [];
disconnect() {
return null;
}
observe() {
return null;
}
takeRecords() {
return [];
}
unobserve() {
return null;
}
}
window.IntersectionObserver = IntersectionObserver;
global.IntersectionObserver = IntersectionObserver;
// Mocks for useDeviceSupport
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation((query) => ({
matches: true,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
class Worker {
onmessage: (message: string) => void;
url: string;
constructor(url: string) {
this.url = url;
this.onmessage = () => {};
}
postMessage(message: string) {
this.onmessage(message);
}
addEventListener() {}
}
Object.defineProperty(window, 'Worker', {
writable: true,
value: Worker,
});

View File

@@ -0,0 +1,140 @@
import { within, waitFor } from '@testing-library/vue';
import userEvent from '@testing-library/user-event';
import type { ISettingsState } from '@/Interface';
import { UserManagementAuthenticationMethod } from '@/Interface';
import { defaultSettings } from './defaults';
import { APP_MODALS_ELEMENT_ID } from '@/constants';
import type { Mock } from 'vitest';
import type { Store, StoreDefinition } from 'pinia';
import type { ComputedRef } from 'vue';
/**
* Retries the given assertion until it passes or the timeout is reached
*
* @example
* await retry(
* () => expect(screen.getByText('Hello')).toBeInTheDocument()
* );
*/
export const retry = async (assertion: () => void, { interval = 20, timeout = 1000 } = {}) => {
return await new Promise((resolve, reject) => {
const startTime = Date.now();
const tryAgain = () => {
setTimeout(() => {
try {
resolve(assertion());
} catch (error) {
if (Date.now() - startTime > timeout) {
reject(error);
} else {
tryAgain();
}
}
}, interval);
};
tryAgain();
});
};
export const waitAllPromises = async () => await new Promise((resolve) => setTimeout(resolve));
export const SETTINGS_STORE_DEFAULT_STATE: ISettingsState = {
initialized: true,
settings: defaultSettings,
userManagement: {
showSetupOnFirstLoad: false,
smtpSetup: false,
authenticationMethod: UserManagementAuthenticationMethod.Email,
quota: defaultSettings.userManagement.quota,
},
templatesEndpointHealthy: false,
api: {
enabled: false,
latestVersion: 0,
path: '/',
swaggerUi: {
enabled: false,
},
},
ldap: {
loginLabel: '',
loginEnabled: false,
},
saml: {
loginLabel: '',
loginEnabled: false,
},
mfa: {
enabled: false,
},
saveDataErrorExecution: 'all',
saveDataSuccessExecution: 'all',
saveDataProgressExecution: false,
saveManualExecutions: false,
};
export const getDropdownItems = async (dropdownTriggerParent: HTMLElement) => {
await userEvent.click(within(dropdownTriggerParent).getByRole('combobox'));
const selectTrigger = dropdownTriggerParent.querySelector(
'.select-trigger[aria-describedby]',
) as HTMLElement;
await waitFor(() => expect(selectTrigger).toBeInTheDocument());
const selectDropdownId = selectTrigger.getAttribute('aria-describedby');
const selectDropdown = document.getElementById(selectDropdownId as string) as HTMLElement;
await waitFor(() => expect(selectDropdown).toBeInTheDocument());
return selectDropdown.querySelectorAll('.el-select-dropdown__item');
};
export const getSelectedDropdownValue = async (items: NodeListOf<Element>) => {
const selectedItem = Array.from(items).find((item) => item.classList.contains('selected'));
expect(selectedItem).toBeInTheDocument();
return selectedItem?.querySelector('p')?.textContent?.trim();
};
/**
* Create a container for teleported modals
*
* More info: https://test-utils.vuejs.org/guide/advanced/teleport#Mounting-the-Component
* @returns {HTMLElement} appModals
*/
export const createAppModals = () => {
const appModals = document.createElement('div');
appModals.id = APP_MODALS_ELEMENT_ID;
document.body.appendChild(appModals);
return appModals;
};
export const cleanupAppModals = () => {
document.body.innerHTML = '';
};
/**
* Typescript helper for mocking pinia store actions return value
*
* @see https://pinia.vuejs.org/cookbook/testing.html#Mocking-the-returned-value-of-an-action
*/
export const mockedStore = <TStoreDef extends () => unknown>(
useStore: TStoreDef,
): TStoreDef extends StoreDefinition<infer Id, infer State, infer Getters, infer Actions>
? Store<
Id,
State,
Record<string, never>,
{
[K in keyof Actions]: Actions[K] extends (...args: infer Args) => infer ReturnT
? Mock<(...args: Args) => ReturnT>
: Actions[K];
}
> & {
[K in keyof Getters]: Getters[K] extends ComputedRef<infer T> ? T : never;
}
: ReturnType<TStoreDef> => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return useStore() as any;
};
export type MockedStore<T extends () => unknown> = ReturnType<typeof mockedStore<T>>;

View File

@@ -0,0 +1,65 @@
import { useAIAssistantHelpers } from '@/composables/useAIAssistantHelpers';
import { AI_ASSISTANT_MAX_CONTENT_LENGTH } from '@/constants';
import type { ICredentialsResponse, IRestApiContext } from '@/Interface';
import type { AskAiRequest, ChatRequest, ReplaceCodeRequest } from '@/types/assistant.types';
import { makeRestApiRequest, streamRequest } from '@/utils/apiUtils';
import { getObjectSizeInKB } from '@/utils/objectUtils';
import type { IDataObject } from 'n8n-workflow';
export function chatWithAssistant(
ctx: IRestApiContext,
payload: ChatRequest.RequestPayload,
onMessageUpdated: (data: ChatRequest.ResponsePayload) => void,
onDone: () => void,
onError: (e: Error) => void,
): void {
try {
const payloadSize = getObjectSizeInKB(payload.payload);
if (payloadSize > AI_ASSISTANT_MAX_CONTENT_LENGTH) {
useAIAssistantHelpers().trimPayloadSize(payload);
}
} catch (e) {
onError(e);
return;
}
void streamRequest<ChatRequest.ResponsePayload>(
ctx,
'/ai/chat',
payload,
onMessageUpdated,
onDone,
onError,
);
}
export async function replaceCode(
context: IRestApiContext,
data: ReplaceCodeRequest.RequestPayload,
): Promise<ReplaceCodeRequest.ResponsePayload> {
return await makeRestApiRequest<ReplaceCodeRequest.ResponsePayload>(
context,
'POST',
'/ai/chat/apply-suggestion',
data,
);
}
export async function generateCodeForPrompt(
ctx: IRestApiContext,
{ question, context, forNode }: AskAiRequest.RequestPayload,
): Promise<{ code: string }> {
return await makeRestApiRequest(ctx, 'POST', '/ai/ask-ai', {
question,
context,
forNode,
} as IDataObject);
}
export async function claimFreeAiCredits(
ctx: IRestApiContext,
{ projectId }: { projectId?: string },
): Promise<ICredentialsResponse> {
return await makeRestApiRequest(ctx, 'POST', '/ai/free-credits', {
projectId,
} as IDataObject);
}

View File

@@ -0,0 +1,34 @@
import type { IRestApiContext } from '@/Interface';
import { makeRestApiRequest } from '@/utils/apiUtils';
import type {
CreateApiKeyRequestDto,
UpdateApiKeyRequestDto,
ApiKey,
ApiKeyWithRawValue,
} from '@n8n/api-types';
export async function getApiKeys(context: IRestApiContext): Promise<ApiKey[]> {
return await makeRestApiRequest(context, 'GET', '/api-keys');
}
export async function createApiKey(
context: IRestApiContext,
payload: CreateApiKeyRequestDto,
): Promise<ApiKeyWithRawValue> {
return await makeRestApiRequest(context, 'POST', '/api-keys', payload);
}
export async function deleteApiKey(
context: IRestApiContext,
id: string,
): Promise<{ success: boolean }> {
return await makeRestApiRequest(context, 'DELETE', `/api-keys/${id}`);
}
export async function updateApiKey(
context: IRestApiContext,
id: string,
payload: UpdateApiKeyRequestDto,
): Promise<{ success: boolean }> {
return await makeRestApiRequest(context, 'PATCH', `/api-keys/${id}`, payload);
}

View File

@@ -0,0 +1,22 @@
import type { Cloud, IRestApiContext, InstanceUsage } from '@/Interface';
import { get, post } from '@/utils/apiUtils';
export async function getCurrentPlan(context: IRestApiContext): Promise<Cloud.PlanData> {
return await get(context.baseUrl, '/admin/cloud-plan');
}
export async function getCurrentUsage(context: IRestApiContext): Promise<InstanceUsage> {
return await get(context.baseUrl, '/cloud/limits');
}
export async function getCloudUserInfo(context: IRestApiContext): Promise<Cloud.UserAccount> {
return await get(context.baseUrl, '/cloud/proxy/user/me');
}
export async function sendConfirmationEmail(context: IRestApiContext): Promise<Cloud.UserAccount> {
return await post(context.baseUrl, '/cloud/proxy/user/resend-confirmation-email');
}
export async function getAdminPanelLoginCode(context: IRestApiContext): Promise<{ code: string }> {
return await get(context.baseUrl, '/cloud/proxy/login/code');
}

View File

@@ -0,0 +1,28 @@
import type { IRestApiContext } from '@/Interface';
import type { PublicInstalledPackage } from 'n8n-workflow';
import { get, post, makeRestApiRequest } from '@/utils/apiUtils';
export async function getInstalledCommunityNodes(
context: IRestApiContext,
): Promise<PublicInstalledPackage[]> {
const response = await get(context.baseUrl, '/community-packages');
return response.data || [];
}
export async function installNewPackage(
context: IRestApiContext,
name: string,
): Promise<PublicInstalledPackage> {
return await post(context.baseUrl, '/community-packages', { name });
}
export async function uninstallPackage(context: IRestApiContext, name: string): Promise<void> {
return await makeRestApiRequest(context, 'DELETE', '/community-packages', { name });
}
export async function updatePackage(
context: IRestApiContext,
name: string,
): Promise<PublicInstalledPackage> {
return await makeRestApiRequest(context, 'PATCH', '/community-packages', { name });
}

View File

@@ -0,0 +1,26 @@
import type { ICredentialsResponse, IRestApiContext, IShareCredentialsPayload } from '@/Interface';
import { makeRestApiRequest } from '@/utils/apiUtils';
import type { IDataObject } from 'n8n-workflow';
export async function setCredentialSharedWith(
context: IRestApiContext,
id: string,
data: IShareCredentialsPayload,
): Promise<ICredentialsResponse> {
return await makeRestApiRequest(
context,
'PUT',
`/credentials/${id}/share`,
data as unknown as IDataObject,
);
}
export async function moveCredentialToProject(
context: IRestApiContext,
id: string,
destinationProjectId: string,
): Promise<void> {
return await makeRestApiRequest(context, 'PUT', `/credentials/${id}/transfer`, {
destinationProjectId,
});
}

View File

@@ -0,0 +1,119 @@
import type {
ICredentialsDecryptedResponse,
ICredentialsResponse,
IRestApiContext,
} from '@/Interface';
import { makeRestApiRequest } from '@/utils/apiUtils';
import type {
ICredentialsDecrypted,
ICredentialType,
IDataObject,
INodeCredentialTestRequest,
INodeCredentialTestResult,
} from 'n8n-workflow';
import axios from 'axios';
import type { CreateCredentialDto } from '@n8n/api-types';
export async function getCredentialTypes(baseUrl: string): Promise<ICredentialType[]> {
const { data } = await axios.get(baseUrl + 'types/credentials.json', { withCredentials: true });
return data;
}
export async function getCredentialsNewName(
context: IRestApiContext,
name?: string,
): Promise<{ name: string }> {
return await makeRestApiRequest(context, 'GET', '/credentials/new', name ? { name } : {});
}
export async function getAllCredentials(
context: IRestApiContext,
filter?: object,
includeScopes?: boolean,
): Promise<ICredentialsResponse[]> {
return await makeRestApiRequest(context, 'GET', '/credentials', {
...(includeScopes ? { includeScopes } : {}),
includeData: true,
...(filter ? { filter } : {}),
});
}
export async function getAllCredentialsForWorkflow(
context: IRestApiContext,
options: { workflowId: string } | { projectId: string },
): Promise<ICredentialsResponse[]> {
return await makeRestApiRequest(context, 'GET', '/credentials/for-workflow', {
...options,
});
}
export async function createNewCredential(
context: IRestApiContext,
payload: CreateCredentialDto,
): Promise<ICredentialsResponse> {
return await makeRestApiRequest(context, 'POST', '/credentials', payload);
}
export async function deleteCredential(context: IRestApiContext, id: string): Promise<boolean> {
return await makeRestApiRequest(context, 'DELETE', `/credentials/${id}`);
}
export async function updateCredential(
context: IRestApiContext,
id: string,
data: ICredentialsDecrypted,
): Promise<ICredentialsResponse> {
return await makeRestApiRequest(
context,
'PATCH',
`/credentials/${id}`,
data as unknown as IDataObject,
);
}
export async function getCredentialData(
context: IRestApiContext,
id: string,
): Promise<ICredentialsDecryptedResponse | ICredentialsResponse | undefined> {
return await makeRestApiRequest(context, 'GET', `/credentials/${id}`, {
includeData: true,
});
}
// Get OAuth1 Authorization URL using the stored credentials
export async function oAuth1CredentialAuthorize(
context: IRestApiContext,
data: ICredentialsResponse,
): Promise<string> {
return await makeRestApiRequest(
context,
'GET',
'/oauth1-credential/auth',
data as unknown as IDataObject,
);
}
// Get OAuth2 Authorization URL using the stored credentials
export async function oAuth2CredentialAuthorize(
context: IRestApiContext,
data: ICredentialsResponse,
): Promise<string> {
return await makeRestApiRequest(
context,
'GET',
'/oauth2-credential/auth',
data as unknown as IDataObject,
);
}
export async function testCredential(
context: IRestApiContext,
data: INodeCredentialTestRequest,
): Promise<INodeCredentialTestResult> {
return await makeRestApiRequest(
context,
'POST',
'/credentials/test',
data as unknown as IDataObject,
);
}

View File

@@ -0,0 +1,8 @@
import type { IRestApiContext } from '@/Interface';
import { get } from '@/utils/apiUtils';
export async function getBecomeCreatorCta(context: IRestApiContext): Promise<boolean> {
const response = await get(context.baseUrl, '/cta/become-creator');
return response;
}

View File

@@ -0,0 +1,40 @@
import type { EnvironmentVariable, IRestApiContext } from '@/Interface';
import { makeRestApiRequest } from '@/utils/apiUtils';
import type { IDataObject } from 'n8n-workflow';
export async function getVariables(context: IRestApiContext): Promise<EnvironmentVariable[]> {
return await makeRestApiRequest(context, 'GET', '/variables');
}
export async function getVariable(
context: IRestApiContext,
{ id }: { id: EnvironmentVariable['id'] },
): Promise<EnvironmentVariable> {
return await makeRestApiRequest(context, 'GET', `/variables/${id}`);
}
export async function createVariable(
context: IRestApiContext,
data: Omit<EnvironmentVariable, 'id'>,
): Promise<EnvironmentVariable> {
return await makeRestApiRequest(context, 'POST', '/variables', data as unknown as IDataObject);
}
export async function updateVariable(
context: IRestApiContext,
{ id, ...data }: EnvironmentVariable,
): Promise<EnvironmentVariable> {
return await makeRestApiRequest(
context,
'PATCH',
`/variables/${id}`,
data as unknown as IDataObject,
);
}
export async function deleteVariable(
context: IRestApiContext,
{ id }: { id: EnvironmentVariable['id'] },
) {
return await makeRestApiRequest(context, 'DELETE', `/variables/${id}`);
}

View File

@@ -0,0 +1,49 @@
import type { IRestApiContext } from '@/Interface';
import { makeRestApiRequest } from '@/utils/apiUtils';
import type { IDataObject, MessageEventBusDestinationOptions } from 'n8n-workflow';
export type ApiMessageEventBusDestinationOptions = MessageEventBusDestinationOptions & {
id: string;
};
export function hasDestinationId(
destination: MessageEventBusDestinationOptions,
): destination is ApiMessageEventBusDestinationOptions {
return destination.id !== undefined;
}
export async function saveDestinationToDb(
context: IRestApiContext,
destination: ApiMessageEventBusDestinationOptions,
subscribedEvents: string[] = [],
) {
const data: IDataObject = {
...destination,
subscribedEvents,
};
return await makeRestApiRequest(context, 'POST', '/eventbus/destination', data);
}
export async function deleteDestinationFromDb(context: IRestApiContext, destinationId: string) {
return await makeRestApiRequest(context, 'DELETE', `/eventbus/destination?id=${destinationId}`);
}
export async function sendTestMessageToDestination(
context: IRestApiContext,
destination: ApiMessageEventBusDestinationOptions,
): Promise<boolean> {
const data: IDataObject = {
...destination,
};
return await makeRestApiRequest(context, 'GET', '/eventbus/testmessage', data);
}
export async function getEventNamesFromBackend(context: IRestApiContext): Promise<string[]> {
return await makeRestApiRequest(context, 'GET', '/eventbus/eventnames');
}
export async function getDestinationsFromBackend(
context: IRestApiContext,
): Promise<MessageEventBusDestinationOptions[]> {
return await makeRestApiRequest(context, 'GET', '/eventbus/destination');
}

View File

@@ -0,0 +1,6 @@
import type { IRestApiContext } from '@/Interface';
import { makeRestApiRequest } from '@/utils/apiUtils';
export async function sessionStarted(context: IRestApiContext): Promise<void> {
return await makeRestApiRequest(context, 'GET', '/events/session-started');
}

View File

@@ -0,0 +1,54 @@
import type { IRestApiContext, ExternalSecretsProvider } from '@/Interface';
import { makeRestApiRequest } from '@/utils/apiUtils';
export const getExternalSecrets = async (
context: IRestApiContext,
): Promise<Record<string, string[]>> => {
return await makeRestApiRequest(context, 'GET', '/external-secrets/secrets');
};
export const getExternalSecretsProviders = async (
context: IRestApiContext,
): Promise<ExternalSecretsProvider[]> => {
return await makeRestApiRequest(context, 'GET', '/external-secrets/providers');
};
export const getExternalSecretsProvider = async (
context: IRestApiContext,
id: string,
): Promise<ExternalSecretsProvider> => {
return await makeRestApiRequest(context, 'GET', `/external-secrets/providers/${id}`);
};
export const testExternalSecretsProviderConnection = async (
context: IRestApiContext,
id: string,
data: ExternalSecretsProvider['data'],
): Promise<{ testState: ExternalSecretsProvider['state'] }> => {
return await makeRestApiRequest(context, 'POST', `/external-secrets/providers/${id}/test`, data);
};
export const updateProvider = async (
context: IRestApiContext,
id: string,
data: ExternalSecretsProvider['data'],
): Promise<boolean> => {
return await makeRestApiRequest(context, 'POST', `/external-secrets/providers/${id}`, data);
};
export const reloadProvider = async (
context: IRestApiContext,
id: string,
): Promise<{ updated: boolean }> => {
return await makeRestApiRequest(context, 'POST', `/external-secrets/providers/${id}/update`);
};
export const connectProvider = async (
context: IRestApiContext,
id: string,
connected: boolean,
): Promise<boolean> => {
return await makeRestApiRequest(context, 'POST', `/external-secrets/providers/${id}/connect`, {
connected,
});
};

View File

@@ -0,0 +1,33 @@
import type {
CurrentUserResponse,
IInviteResponse,
IRestApiContext,
InvitableRoleName,
} from '@/Interface';
import type { IDataObject } from 'n8n-workflow';
import { makeRestApiRequest } from '@/utils/apiUtils';
type AcceptInvitationParams = {
inviterId: string;
inviteeId: string;
firstName: string;
lastName: string;
password: string;
};
export async function inviteUsers(
context: IRestApiContext,
params: Array<{ email: string; role: InvitableRoleName }>,
) {
return await makeRestApiRequest<IInviteResponse[]>(context, 'POST', '/invitations', params);
}
export async function acceptInvitation(context: IRestApiContext, params: AcceptInvitationParams) {
const { inviteeId, ...props } = params;
return await makeRestApiRequest<CurrentUserResponse>(
context,
'POST',
`/invitations/${params.inviteeId}/accept`,
props as unknown as IDataObject,
);
}

View File

@@ -0,0 +1,34 @@
import type { ILdapConfig, ILdapSyncData, IRestApiContext } from '@/Interface';
import { makeRestApiRequest } from '@/utils/apiUtils';
import type { IDataObject } from 'n8n-workflow';
export async function getLdapConfig(context: IRestApiContext): Promise<ILdapConfig> {
return await makeRestApiRequest(context, 'GET', '/ldap/config');
}
export async function testLdapConnection(context: IRestApiContext): Promise<{}> {
return await makeRestApiRequest(context, 'POST', '/ldap/test-connection');
}
export async function updateLdapConfig(
context: IRestApiContext,
adConfig: ILdapConfig,
): Promise<ILdapConfig> {
return await makeRestApiRequest(
context,
'PUT',
'/ldap/config',
adConfig as unknown as IDataObject,
);
}
export async function runLdapSync(context: IRestApiContext, data: IDataObject): Promise<{}> {
return await makeRestApiRequest(context, 'POST', '/ldap/sync', data as unknown as IDataObject);
}
export async function getLdapSynchronizations(
context: IRestApiContext,
pagination: { page: number },
): Promise<ILdapSyncData[]> {
return await makeRestApiRequest(context, 'GET', '/ldap/sync', pagination);
}

View File

@@ -0,0 +1,35 @@
import type { IRestApiContext } from '@/Interface';
import { makeRestApiRequest } from '@/utils/apiUtils';
export async function canEnableMFA(context: IRestApiContext) {
return await makeRestApiRequest(context, 'POST', '/mfa/can-enable');
}
export async function getMfaQR(
context: IRestApiContext,
): Promise<{ qrCode: string; secret: string; recoveryCodes: string[] }> {
return await makeRestApiRequest(context, 'GET', '/mfa/qr');
}
export async function enableMfa(
context: IRestApiContext,
data: { mfaCode: string },
): Promise<void> {
return await makeRestApiRequest(context, 'POST', '/mfa/enable', data);
}
export async function verifyMfaCode(
context: IRestApiContext,
data: { mfaCode: string },
): Promise<void> {
return await makeRestApiRequest(context, 'POST', '/mfa/verify', data);
}
export type DisableMfaParams = {
mfaCode?: string;
mfaRecoveryCode?: string;
};
export async function disableMfa(context: IRestApiContext, data: DisableMfaParams): Promise<void> {
return await makeRestApiRequest(context, 'POST', '/mfa/disable', data);
}

View File

@@ -0,0 +1,90 @@
import type {
ActionResultRequestDto,
OptionsRequestDto,
ResourceLocatorRequestDto,
ResourceMapperFieldsRequestDto,
} from '@n8n/api-types';
import { makeRestApiRequest } from '@/utils/apiUtils';
import type { INodeTranslationHeaders, IRestApiContext } from '@/Interface';
import type {
INodeListSearchResult,
INodePropertyOptions,
INodeTypeDescription,
INodeTypeNameVersion,
NodeParameterValueType,
ResourceMapperFields,
} from 'n8n-workflow';
import axios from 'axios';
export async function getNodeTypes(baseUrl: string) {
const { data } = await axios.get(baseUrl + 'types/nodes.json', { withCredentials: true });
return data;
}
export async function getNodeTranslationHeaders(
context: IRestApiContext,
): Promise<INodeTranslationHeaders | undefined> {
return await makeRestApiRequest(context, 'GET', '/node-translation-headers');
}
export async function getNodesInformation(
context: IRestApiContext,
nodeInfos: INodeTypeNameVersion[],
): Promise<INodeTypeDescription[]> {
return await makeRestApiRequest(context, 'POST', '/node-types', { nodeInfos });
}
export async function getNodeParameterOptions(
context: IRestApiContext,
sendData: OptionsRequestDto,
): Promise<INodePropertyOptions[]> {
return await makeRestApiRequest(context, 'POST', '/dynamic-node-parameters/options', sendData);
}
export async function getResourceLocatorResults(
context: IRestApiContext,
sendData: ResourceLocatorRequestDto,
): Promise<INodeListSearchResult> {
return await makeRestApiRequest(
context,
'POST',
'/dynamic-node-parameters/resource-locator-results',
sendData,
);
}
export async function getResourceMapperFields(
context: IRestApiContext,
sendData: ResourceMapperFieldsRequestDto,
): Promise<ResourceMapperFields> {
return await makeRestApiRequest(
context,
'POST',
'/dynamic-node-parameters/resource-mapper-fields',
sendData,
);
}
export async function getLocalResourceMapperFields(
context: IRestApiContext,
sendData: ResourceMapperFieldsRequestDto,
): Promise<ResourceMapperFields> {
return await makeRestApiRequest(
context,
'POST',
'/dynamic-node-parameters/local-resource-mapper-fields',
sendData,
);
}
export async function getNodeParameterActionResult(
context: IRestApiContext,
sendData: ActionResultRequestDto,
): Promise<NodeParameterValueType> {
return await makeRestApiRequest(
context,
'POST',
'/dynamic-node-parameters/action-result',
sendData,
);
}

View File

@@ -0,0 +1,7 @@
import type { IRestApiContext } from '@/Interface';
import { makeRestApiRequest } from '@/utils/apiUtils';
import type { NpsSurveyState } from 'n8n-workflow';
export async function updateNpsSurveyState(context: IRestApiContext, state: NpsSurveyState) {
await makeRestApiRequest(context, 'PATCH', '/user-settings/nps-survey', state);
}

View File

@@ -0,0 +1,8 @@
import type { IRestApiContext } from '@/Interface';
import { makeRestApiRequest } from '@/utils/apiUtils';
const GET_STATUS_ENDPOINT = '/orchestration/worker/status';
export const sendGetWorkerStatus = async (context: IRestApiContext): Promise<void> => {
await makeRestApiRequest(context, 'POST', GET_STATUS_ENDPOINT);
};

View File

@@ -0,0 +1,52 @@
import type { IRestApiContext } from '@/Interface';
import { makeRestApiRequest } from '@/utils/apiUtils';
import type { Project, ProjectListItem, ProjectsCount } from '@/types/projects.types';
import type { CreateProjectDto, UpdateProjectDto } from '@n8n/api-types';
export const getAllProjects = async (context: IRestApiContext): Promise<ProjectListItem[]> => {
return await makeRestApiRequest(context, 'GET', '/projects');
};
export const getMyProjects = async (context: IRestApiContext): Promise<ProjectListItem[]> => {
return await makeRestApiRequest(context, 'GET', '/projects/my-projects');
};
export const getPersonalProject = async (context: IRestApiContext): Promise<Project> => {
return await makeRestApiRequest(context, 'GET', '/projects/personal');
};
export const getProject = async (context: IRestApiContext, id: string): Promise<Project> => {
return await makeRestApiRequest(context, 'GET', `/projects/${id}`);
};
export const createProject = async (
context: IRestApiContext,
payload: CreateProjectDto,
): Promise<Project> => {
return await makeRestApiRequest(context, 'POST', '/projects', payload);
};
export const updateProject = async (
context: IRestApiContext,
id: Project['id'],
payload: UpdateProjectDto,
): Promise<void> => {
await makeRestApiRequest(context, 'PATCH', `/projects/${id}`, payload);
};
export const deleteProject = async (
context: IRestApiContext,
projectId: string,
transferId?: string,
): Promise<void> => {
await makeRestApiRequest(
context,
'DELETE',
`/projects/${projectId}`,
transferId ? { transferId } : {},
);
};
export const getProjectsCount = async (context: IRestApiContext): Promise<ProjectsCount> => {
return await makeRestApiRequest(context, 'GET', '/projects/count');
};

View File

@@ -0,0 +1,7 @@
import type { IRestApiContext } from '@/Interface';
import type { RoleMap } from '@/types/roles.types';
import { makeRestApiRequest } from '@/utils/apiUtils';
export const getRoles = async (context: IRestApiContext): Promise<RoleMap> => {
return await makeRestApiRequest(context, 'GET', '/roles');
};

View File

@@ -0,0 +1,30 @@
import { request } from '@/utils/apiUtils';
import type { JSONSchema7 } from 'json-schema';
export type GetSchemaPreviewOptions = {
nodeType: string;
version: number;
resource?: string;
operation?: string;
};
const padVersion = (version: number) => {
return version.toString().split('.').concat(['0', '0']).slice(0, 3).join('.');
};
export const getSchemaPreview = async (
baseUrl: string,
options: GetSchemaPreviewOptions,
): Promise<JSONSchema7> => {
const { nodeType, version, resource, operation } = options;
const versionString = padVersion(version);
const path = ['schemas', nodeType.replace('@n8n/', ''), versionString, resource, operation]
.filter(Boolean)
.join('/');
return await request({
method: 'GET',
baseURL: baseUrl,
endpoint: `${path}.json`,
withCredentials: false,
});
};

View File

@@ -0,0 +1,39 @@
import type { IRestApiContext, IN8nPrompts, IN8nPromptResponse } from '../Interface';
import { makeRestApiRequest, get, post } from '@/utils/apiUtils';
import { N8N_IO_BASE_URL, NPM_COMMUNITY_NODE_SEARCH_API_URL } from '@/constants';
import type { FrontendSettings } from '@n8n/api-types';
export async function getSettings(context: IRestApiContext): Promise<FrontendSettings> {
return await makeRestApiRequest(context, 'GET', '/settings');
}
export async function getPromptsData(instanceId: string, userId: string): Promise<IN8nPrompts> {
return await get(
N8N_IO_BASE_URL,
'/prompts',
{},
{ 'n8n-instance-id': instanceId, 'n8n-user-id': userId },
);
}
export async function submitContactInfo(
instanceId: string,
userId: string,
email: string,
): Promise<IN8nPromptResponse> {
return await post(
N8N_IO_BASE_URL,
'/prompt',
{ email },
{ 'n8n-instance-id': instanceId, 'n8n-user-id': userId },
);
}
export async function getAvailableCommunityPackageCount(): Promise<number> {
const response = await get(
NPM_COMMUNITY_NODE_SEARCH_API_URL,
'search?q=keywords:n8n-community-node-package',
);
return response.total || 0;
}

View File

@@ -0,0 +1,86 @@
import type {
PullWorkFolderRequestDto,
PushWorkFolderRequestDto,
SourceControlledFile,
} from '@n8n/api-types';
import type { IRestApiContext } from '@/Interface';
import type {
SourceControlPreferences,
SourceControlStatus,
SshKeyTypes,
} from '@/types/sourceControl.types';
import { makeRestApiRequest } from '@/utils/apiUtils';
import type { TupleToUnion } from '@/utils/typeHelpers';
const sourceControlApiRoot = '/source-control';
const createPreferencesRequestFn =
(method: 'POST' | 'PATCH') =>
async (
context: IRestApiContext,
preferences: Partial<SourceControlPreferences>,
): Promise<SourceControlPreferences> =>
await makeRestApiRequest(context, method, `${sourceControlApiRoot}/preferences`, preferences);
export const pushWorkfolder = async (
context: IRestApiContext,
data: PushWorkFolderRequestDto,
): Promise<void> => {
return await makeRestApiRequest(context, 'POST', `${sourceControlApiRoot}/push-workfolder`, data);
};
export const pullWorkfolder = async (
context: IRestApiContext,
data: PullWorkFolderRequestDto,
): Promise<SourceControlledFile[]> => {
return await makeRestApiRequest(context, 'POST', `${sourceControlApiRoot}/pull-workfolder`, data);
};
export const getBranches = async (
context: IRestApiContext,
): Promise<{ branches: string[]; currentBranch: string }> => {
return await makeRestApiRequest(context, 'GET', `${sourceControlApiRoot}/get-branches`);
};
export const savePreferences = createPreferencesRequestFn('POST');
export const updatePreferences = createPreferencesRequestFn('PATCH');
export const getPreferences = async (
context: IRestApiContext,
): Promise<SourceControlPreferences> => {
return await makeRestApiRequest(context, 'GET', `${sourceControlApiRoot}/preferences`);
};
export const getStatus = async (context: IRestApiContext): Promise<SourceControlStatus> => {
return await makeRestApiRequest(context, 'GET', `${sourceControlApiRoot}/status`);
};
export const getAggregatedStatus = async (
context: IRestApiContext,
options: {
direction: 'push' | 'pull';
preferLocalVersion: boolean;
verbose: boolean;
} = { direction: 'push', preferLocalVersion: true, verbose: false },
): Promise<SourceControlledFile[]> => {
return await makeRestApiRequest(context, 'GET', `${sourceControlApiRoot}/get-status`, options);
};
export const disconnect = async (
context: IRestApiContext,
keepKeyPair: boolean,
): Promise<string> => {
return await makeRestApiRequest(context, 'POST', `${sourceControlApiRoot}/disconnect`, {
keepKeyPair,
});
};
export const generateKeyPair = async (
context: IRestApiContext,
keyGeneratorType?: TupleToUnion<SshKeyTypes>,
): Promise<string> => {
return await makeRestApiRequest(context, 'POST', `${sourceControlApiRoot}/generate-key-pair`, {
keyGeneratorType,
});
};

View File

@@ -0,0 +1,35 @@
import type { SamlPreferences, SamlToggleDto } from '@n8n/api-types';
import { makeRestApiRequest } from '@/utils/apiUtils';
import type { IRestApiContext, SamlPreferencesExtractedData } from '@/Interface';
export const initSSO = async (context: IRestApiContext): Promise<string> => {
return await makeRestApiRequest(context, 'GET', '/sso/saml/initsso');
};
export const getSamlMetadata = async (context: IRestApiContext): Promise<SamlPreferences> => {
return await makeRestApiRequest(context, 'GET', '/sso/saml/metadata');
};
export const getSamlConfig = async (
context: IRestApiContext,
): Promise<SamlPreferences & SamlPreferencesExtractedData> => {
return await makeRestApiRequest(context, 'GET', '/sso/saml/config');
};
export const saveSamlConfig = async (
context: IRestApiContext,
data: Partial<SamlPreferences>,
): Promise<SamlPreferences | undefined> => {
return await makeRestApiRequest(context, 'POST', '/sso/saml/config', data);
};
export const toggleSamlConfig = async (
context: IRestApiContext,
data: SamlToggleDto,
): Promise<void> => {
return await makeRestApiRequest(context, 'POST', '/sso/saml/config/toggle', data);
};
export const testSamlConfig = async (context: IRestApiContext): Promise<string> => {
return await makeRestApiRequest(context, 'GET', '/sso/saml/config/test');
};

View File

@@ -0,0 +1,29 @@
import type { IRestApiContext, ITag } from '@/Interface';
import { makeRestApiRequest } from '@/utils/apiUtils';
import type { CreateOrUpdateTagRequestDto, RetrieveTagQueryDto } from '@n8n/api-types';
type TagsApiEndpoint = '/tags' | '/annotation-tags';
export function createTagsApi(endpoint: TagsApiEndpoint) {
return {
getTags: async (context: IRestApiContext, data: RetrieveTagQueryDto): Promise<ITag[]> => {
return await makeRestApiRequest(context, 'GET', endpoint, data);
},
createTag: async (
context: IRestApiContext,
data: CreateOrUpdateTagRequestDto,
): Promise<ITag> => {
return await makeRestApiRequest(context, 'POST', endpoint, data);
},
updateTag: async (
context: IRestApiContext,
id: string,
data: CreateOrUpdateTagRequestDto,
): Promise<ITag> => {
return await makeRestApiRequest(context, 'PATCH', `${endpoint}/${id}`, data);
},
deleteTag: async (context: IRestApiContext, id: string): Promise<boolean> => {
return await makeRestApiRequest(context, 'DELETE', `${endpoint}/${id}`);
},
};
}

View File

@@ -0,0 +1,86 @@
import type { RawAxiosRequestHeaders } from 'axios';
import type {
ITemplatesCategory,
ITemplatesCollection,
ITemplatesQuery,
ITemplatesWorkflow,
ITemplatesCollectionResponse,
ITemplatesWorkflowResponse,
IWorkflowTemplate,
TemplateSearchFacet,
} from '@/Interface';
import { get } from '@/utils/apiUtils';
function stringifyArray(arr: string[]) {
return arr.join(',');
}
export async function testHealthEndpoint(apiEndpoint: string) {
return await get(apiEndpoint, '/health');
}
export async function getCategories(
apiEndpoint: string,
headers?: RawAxiosRequestHeaders,
): Promise<{ categories: ITemplatesCategory[] }> {
return await get(apiEndpoint, '/templates/categories', undefined, headers);
}
export async function getCollections(
apiEndpoint: string,
query: ITemplatesQuery,
headers?: RawAxiosRequestHeaders,
): Promise<{ collections: ITemplatesCollection[] }> {
return await get(
apiEndpoint,
'/templates/collections',
{ category: query.categories, search: query.search },
headers,
);
}
export async function getWorkflows(
apiEndpoint: string,
query: { page: number; limit: number; categories: string[]; search: string },
headers?: RawAxiosRequestHeaders,
): Promise<{
totalWorkflows: number;
workflows: ITemplatesWorkflow[];
filters: TemplateSearchFacet[];
}> {
return await get(
apiEndpoint,
'/templates/search',
{
page: query.page,
rows: query.limit,
category: stringifyArray(query.categories),
search: query.search,
},
headers,
);
}
export async function getCollectionById(
apiEndpoint: string,
collectionId: string,
headers?: RawAxiosRequestHeaders,
): Promise<{ collection: ITemplatesCollectionResponse }> {
return await get(apiEndpoint, `/templates/collections/${collectionId}`, undefined, headers);
}
export async function getTemplateById(
apiEndpoint: string,
templateId: string,
headers?: RawAxiosRequestHeaders,
): Promise<{ workflow: ITemplatesWorkflowResponse }> {
return await get(apiEndpoint, `/templates/workflows/${templateId}`, undefined, headers);
}
export async function getWorkflowTemplate(
apiEndpoint: string,
templateId: string,
headers?: RawAxiosRequestHeaders,
): Promise<IWorkflowTemplate> {
return await get(apiEndpoint, `/workflows/templates/${templateId}`, undefined, headers);
}

View File

@@ -0,0 +1,41 @@
import { getSchemaPreview } from '../schemaPreview';
import * as apiUtils from '@/utils/apiUtils';
describe('schemaPreview', () => {
afterEach(() => {
vi.resetAllMocks();
});
vi.mock('@/utils/apiUtils', () => ({
request: vi.fn().mockResolvedValue({ test: 'test' }),
}));
it('should return schema preview', async () => {
const response = await getSchemaPreview('http://test.com', {
nodeType: 'n8n-nodes-base.asana',
version: 1,
resource: 'resource',
operation: 'operation',
});
expect(response).toEqual({ test: 'test' });
});
it('should parse out nodeType', async () => {
const spy = vi.spyOn(apiUtils, 'request');
await getSchemaPreview('http://test.com', {
nodeType: '@n8n/n8n-nodes-base.asana',
version: 1,
resource: 'resource',
operation: 'operation',
});
expect(spy).toHaveBeenCalledWith({
method: 'GET',
baseURL: 'http://test.com',
endpoint: 'schemas/n8n-nodes-base.asana/1.0.0/resource/operation.json',
withCredentials: false,
});
});
});

View File

@@ -0,0 +1,295 @@
import type { IRestApiContext } from '@/Interface';
import { makeRestApiRequest, request } from '@/utils/apiUtils';
export interface TestDefinitionRecord {
id: string;
name: string;
workflowId: string;
evaluationWorkflowId?: string | null;
annotationTagId?: string | null;
description?: string | null;
updatedAt?: string;
createdAt: string;
annotationTag?: string | null;
mockedNodes?: Array<{ name: string; id: string }>;
}
interface CreateTestDefinitionParams {
name: string;
workflowId: string;
evaluationWorkflowId?: string | null;
}
export interface UpdateTestDefinitionParams {
name?: string;
evaluationWorkflowId?: string | null;
annotationTagId?: string | null;
description?: string | null;
mockedNodes?: Array<{ name: string; id: string }>;
}
export interface UpdateTestResponse {
createdAt: string;
updatedAt: string;
id: string;
name: string;
workflowId: string;
description?: string | null;
annotationTag?: string | null;
evaluationWorkflowId?: string | null;
annotationTagId?: string | null;
}
export interface TestRunRecord {
id: string;
testDefinitionId: string;
status: 'new' | 'running' | 'completed' | 'error' | 'cancelled' | 'warning' | 'success';
metrics?: Record<string, number>;
createdAt: string;
updatedAt: string;
runAt: string;
completedAt: string;
errorCode?: string;
errorDetails?: Record<string, unknown>;
finalResult?: 'success' | 'error' | 'warning';
failedCases?: number;
passedCases?: number;
totalCases?: number;
}
interface GetTestRunParams {
testDefinitionId: string;
runId: string;
}
interface DeleteTestRunParams {
testDefinitionId: string;
runId: string;
}
export interface TestCaseExecutionRecord {
id: string;
testRunId: string;
executionId: string;
pastExecutionId: string;
evaluationExecutionId: string;
status: 'running' | 'completed' | 'error';
createdAt: string;
updatedAt: string;
runAt: string;
metrics?: Record<string, number>;
errorCode?: string;
errorDetails?: Record<string, unknown>;
}
const endpoint = '/evaluation/test-definitions';
const getMetricsEndpoint = (testDefinitionId: string, metricId?: string) =>
`${endpoint}/${testDefinitionId}/metrics${metricId ? `/${metricId}` : ''}`;
export async function getTestDefinitions(
context: IRestApiContext,
params?: { workflowId?: string },
) {
let url = endpoint;
if (params?.workflowId) {
url += `?filter=${JSON.stringify({ workflowId: params.workflowId })}`;
}
return await makeRestApiRequest<{ count: number; testDefinitions: TestDefinitionRecord[] }>(
context,
'GET',
url,
);
}
export async function getTestDefinition(context: IRestApiContext, id: string) {
return await makeRestApiRequest<TestDefinitionRecord>(context, 'GET', `${endpoint}/${id}`);
}
export async function createTestDefinition(
context: IRestApiContext,
params: CreateTestDefinitionParams,
) {
return await makeRestApiRequest<TestDefinitionRecord>(context, 'POST', endpoint, params);
}
export async function updateTestDefinition(
context: IRestApiContext,
id: string,
params: UpdateTestDefinitionParams,
) {
return await makeRestApiRequest<UpdateTestResponse>(
context,
'PATCH',
`${endpoint}/${id}`,
params,
);
}
export async function deleteTestDefinition(context: IRestApiContext, id: string) {
return await makeRestApiRequest<{ success: boolean }>(context, 'DELETE', `${endpoint}/${id}`);
}
export async function getExampleEvaluationInput(
context: IRestApiContext,
testDefinitionId: string,
annotationTagId: string,
) {
return await makeRestApiRequest<Record<string, unknown> | null>(
context,
'GET',
`${endpoint}/${testDefinitionId}/example-evaluation-input?annotationTagId=${annotationTagId}`,
);
}
// Metrics
export interface TestMetricRecord {
id: string;
name: string;
testDefinitionId: string;
createdAt?: string;
updatedAt?: string;
}
export interface CreateTestMetricParams {
testDefinitionId: string;
name: string;
}
export interface UpdateTestMetricParams {
name: string;
id: string;
testDefinitionId: string;
}
export interface DeleteTestMetricParams {
testDefinitionId: string;
id: string;
}
export const getTestMetrics = async (context: IRestApiContext, testDefinitionId: string) => {
return await makeRestApiRequest<TestMetricRecord[]>(
context,
'GET',
getMetricsEndpoint(testDefinitionId),
);
};
export const getTestMetric = async (
context: IRestApiContext,
testDefinitionId: string,
id: string,
) => {
return await makeRestApiRequest<TestMetricRecord>(
context,
'GET',
getMetricsEndpoint(testDefinitionId, id),
);
};
export const createTestMetric = async (
context: IRestApiContext,
params: CreateTestMetricParams,
) => {
return await makeRestApiRequest<TestMetricRecord>(
context,
'POST',
getMetricsEndpoint(params.testDefinitionId),
{ name: params.name },
);
};
export const updateTestMetric = async (
context: IRestApiContext,
params: UpdateTestMetricParams,
) => {
return await makeRestApiRequest<TestMetricRecord>(
context,
'PATCH',
getMetricsEndpoint(params.testDefinitionId, params.id),
{ name: params.name },
);
};
export const deleteTestMetric = async (
context: IRestApiContext,
params: DeleteTestMetricParams,
) => {
return await makeRestApiRequest(
context,
'DELETE',
getMetricsEndpoint(params.testDefinitionId, params.id),
);
};
const getRunsEndpoint = (testDefinitionId: string, runId?: string) =>
`${endpoint}/${testDefinitionId}/runs${runId ? `/${runId}` : ''}`;
// Get all test runs for a test definition
export const getTestRuns = async (context: IRestApiContext, testDefinitionId: string) => {
return await makeRestApiRequest<TestRunRecord[]>(
context,
'GET',
getRunsEndpoint(testDefinitionId),
);
};
// Get specific test run
export const getTestRun = async (context: IRestApiContext, params: GetTestRunParams) => {
return await makeRestApiRequest<TestRunRecord>(
context,
'GET',
getRunsEndpoint(params.testDefinitionId, params.runId),
);
};
// Start a new test run
export const startTestRun = async (context: IRestApiContext, testDefinitionId: string) => {
const response = await request({
method: 'POST',
baseURL: context.baseUrl,
endpoint: `${endpoint}/${testDefinitionId}/run`,
headers: { 'push-ref': context.pushRef },
});
// CLI is returning the response without wrapping it in `data` key
return response as { success: boolean };
};
export const cancelTestRun = async (
context: IRestApiContext,
testDefinitionId: string,
testRunId: string,
) => {
const response = await request({
method: 'POST',
baseURL: context.baseUrl,
endpoint: `${endpoint}/${testDefinitionId}/runs/${testRunId}/cancel`,
headers: { 'push-ref': context.pushRef },
});
// CLI is returning the response without wrapping it in `data` key
return response as { success: boolean };
};
// Delete a test run
export const deleteTestRun = async (context: IRestApiContext, params: DeleteTestRunParams) => {
return await makeRestApiRequest<{ success: boolean }>(
context,
'DELETE',
getRunsEndpoint(params.testDefinitionId, params.runId),
);
};
const getRunExecutionsEndpoint = (testDefinitionId: string, runId: string) =>
`${endpoint}/${testDefinitionId}/runs/${runId}/cases`;
// Get all test cases of a test run
export const getTestCaseExecutions = async (
context: IRestApiContext,
testDefinitionId: string,
runId: string,
) => {
return await makeRestApiRequest<TestCaseExecutionRecord[]>(
context,
'GET',
getRunExecutionsEndpoint(testDefinitionId, runId),
);
};

View File

@@ -0,0 +1,12 @@
import type { IRestApiContext } from '@/Interface';
import { makeRestApiRequest } from '@/utils/apiUtils';
import type { BannerName } from '@n8n/api-types';
export async function dismissBannerPermanently(
context: IRestApiContext,
data: { bannerName: BannerName; dismissedBanners: string[] },
): Promise<void> {
return await makeRestApiRequest(context, 'POST', '/owner/dismiss-banner', {
banner: data.bannerName,
});
}

View File

@@ -0,0 +1,36 @@
import type { CommunityRegisteredRequestDto } from '@n8n/api-types';
import { makeRestApiRequest } from '@/utils/apiUtils';
import type { IRestApiContext, UsageState } from '@/Interface';
export const getLicense = async (context: IRestApiContext): Promise<UsageState['data']> => {
return await makeRestApiRequest(context, 'GET', '/license');
};
export const activateLicenseKey = async (
context: IRestApiContext,
data: { activationKey: string },
): Promise<UsageState['data']> => {
return await makeRestApiRequest(context, 'POST', '/license/activate', data);
};
export const renewLicense = async (context: IRestApiContext): Promise<UsageState['data']> => {
return await makeRestApiRequest(context, 'POST', '/license/renew');
};
export const requestLicenseTrial = async (
context: IRestApiContext,
): Promise<UsageState['data']> => {
return await makeRestApiRequest(context, 'POST', '/license/enterprise/request_trial');
};
export const registerCommunityEdition = async (
context: IRestApiContext,
params: CommunityRegisteredRequestDto,
): Promise<{ title: string; text: string }> => {
return await makeRestApiRequest(
context,
'POST',
'/license/enterprise/community-registered',
params,
);
};

View File

@@ -0,0 +1,163 @@
import type {
PasswordUpdateRequestDto,
SettingsUpdateRequestDto,
UserUpdateRequestDto,
} from '@n8n/api-types';
import type {
CurrentUserResponse,
IPersonalizationLatestVersion,
IRestApiContext,
IUserResponse,
InvitableRoleName,
} from '@/Interface';
import type { IDataObject, IUserSettings } from 'n8n-workflow';
import { makeRestApiRequest } from '@/utils/apiUtils';
export async function loginCurrentUser(
context: IRestApiContext,
): Promise<CurrentUserResponse | null> {
return await makeRestApiRequest(context, 'GET', '/login');
}
export async function login(
context: IRestApiContext,
params: { email: string; password: string; mfaCode?: string; mfaRecoveryToken?: string },
): Promise<CurrentUserResponse> {
return await makeRestApiRequest(context, 'POST', '/login', params);
}
export async function logout(context: IRestApiContext): Promise<void> {
await makeRestApiRequest(context, 'POST', '/logout');
}
export async function setupOwner(
context: IRestApiContext,
params: { firstName: string; lastName: string; email: string; password: string },
): Promise<CurrentUserResponse> {
return await makeRestApiRequest(
context,
'POST',
'/owner/setup',
params as unknown as IDataObject,
);
}
export async function validateSignupToken(
context: IRestApiContext,
params: { inviterId: string; inviteeId: string },
): Promise<{ inviter: { firstName: string; lastName: string } }> {
return await makeRestApiRequest(context, 'GET', '/resolve-signup-token', params);
}
export async function signup(
context: IRestApiContext,
params: {
inviterId: string;
inviteeId: string;
firstName: string;
lastName: string;
password: string;
},
): Promise<CurrentUserResponse> {
const { inviteeId, ...props } = params;
return await makeRestApiRequest(
context,
'POST',
`/users/${params.inviteeId}`,
props as unknown as IDataObject,
);
}
export async function sendForgotPasswordEmail(
context: IRestApiContext,
params: { email: string },
): Promise<void> {
await makeRestApiRequest(context, 'POST', '/forgot-password', params);
}
export async function validatePasswordToken(
context: IRestApiContext,
params: { token: string },
): Promise<void> {
await makeRestApiRequest(context, 'GET', '/resolve-password-token', params);
}
export async function changePassword(
context: IRestApiContext,
params: { token: string; password: string; mfaCode?: string },
): Promise<void> {
await makeRestApiRequest(context, 'POST', '/change-password', params);
}
export async function updateCurrentUser(
context: IRestApiContext,
params: UserUpdateRequestDto,
): Promise<IUserResponse> {
return await makeRestApiRequest(context, 'PATCH', '/me', params);
}
export async function updateCurrentUserSettings(
context: IRestApiContext,
settings: SettingsUpdateRequestDto,
): Promise<IUserSettings> {
return await makeRestApiRequest(context, 'PATCH', '/me/settings', settings);
}
export async function updateOtherUserSettings(
context: IRestApiContext,
userId: string,
settings: SettingsUpdateRequestDto,
): Promise<IUserSettings> {
return await makeRestApiRequest(context, 'PATCH', `/users/${userId}/settings`, settings);
}
export async function updateCurrentUserPassword(
context: IRestApiContext,
params: PasswordUpdateRequestDto,
): Promise<void> {
return await makeRestApiRequest(context, 'PATCH', '/me/password', params);
}
export async function deleteUser(
context: IRestApiContext,
{ id, transferId }: { id: string; transferId?: string },
): Promise<void> {
await makeRestApiRequest(context, 'DELETE', `/users/${id}`, transferId ? { transferId } : {});
}
export async function getUsers(context: IRestApiContext): Promise<IUserResponse[]> {
return await makeRestApiRequest(context, 'GET', '/users');
}
export async function getInviteLink(
context: IRestApiContext,
{ id }: { id: string },
): Promise<{ link: string }> {
return await makeRestApiRequest(context, 'GET', `/users/${id}/invite-link`);
}
export async function getPasswordResetLink(
context: IRestApiContext,
{ id }: { id: string },
): Promise<{ link: string }> {
return await makeRestApiRequest(context, 'GET', `/users/${id}/password-reset-link`);
}
export async function submitPersonalizationSurvey(
context: IRestApiContext,
params: IPersonalizationLatestVersion,
): Promise<void> {
await makeRestApiRequest(context, 'POST', '/me/survey', params as unknown as IDataObject);
}
export interface UpdateGlobalRolePayload {
id: string;
newRoleName: InvitableRoleName;
}
export async function updateGlobalRole(
context: IRestApiContext,
{ id, newRoleName }: UpdateGlobalRolePayload,
): Promise<IUserResponse> {
return await makeRestApiRequest(context, 'PATCH', `/users/${id}/role`, { newRoleName });
}

View File

@@ -0,0 +1,12 @@
import type { IVersion } from '@/Interface';
import { INSTANCE_ID_HEADER } from '@/constants';
import { get } from '@/utils/apiUtils';
export async function getNextVersions(
endpoint: string,
version: string,
instanceId: string,
): Promise<IVersion[]> {
const headers = { [INSTANCE_ID_HEADER as string]: instanceId };
return await get(endpoint, version, {}, headers);
}

View File

@@ -0,0 +1,20 @@
import type { IUser } from '@/Interface';
import { post } from '@/utils/apiUtils';
const N8N_API_BASE_URL = 'https://api.n8n.io/api';
const CONTACT_EMAIL_SUBMISSION_ENDPOINT = '/accounts/onboarding';
export async function submitEmailOnSignup(
instanceId: string,
currentUser: IUser,
email: string | undefined,
agree: boolean,
): Promise<string> {
return await post(N8N_API_BASE_URL, CONTACT_EMAIL_SUBMISSION_ENDPOINT, {
instance_id: instanceId,
user_id: `${instanceId}#${currentUser.id}`,
email,
agree,
agree_updates: true,
});
}

View File

@@ -0,0 +1,32 @@
import type { IRestApiContext } from '@/Interface';
import { get } from '@/utils/apiUtils';
import type {
WorkflowHistory,
WorkflowVersion,
WorkflowHistoryRequestParams,
} from '@/types/workflowHistory';
export const getWorkflowHistory = async (
context: IRestApiContext,
workflowId: string,
queryParams: WorkflowHistoryRequestParams,
): Promise<WorkflowHistory[]> => {
const { data } = await get(
context.baseUrl,
`/workflow-history/workflow/${workflowId}`,
queryParams,
);
return data;
};
export const getWorkflowVersion = async (
context: IRestApiContext,
workflowId: string,
versionId: string,
): Promise<WorkflowVersion> => {
const { data } = await get(
context.baseUrl,
`/workflow-history/workflow/${workflowId}/version/${versionId}`,
);
return data;
};

View File

@@ -0,0 +1,25 @@
import type { TransferWorkflowBodyDto } from '@n8n/api-types';
import type { IRestApiContext, IShareWorkflowsPayload, IWorkflowsShareResponse } from '@/Interface';
import { makeRestApiRequest } from '@/utils/apiUtils';
import type { IDataObject } from 'n8n-workflow';
export async function setWorkflowSharedWith(
context: IRestApiContext,
id: string,
data: IShareWorkflowsPayload,
): Promise<IWorkflowsShareResponse> {
return await makeRestApiRequest(
context,
'PUT',
`/workflows/${id}/share`,
data as unknown as IDataObject,
);
}
export async function moveWorkflowToProject(
context: IRestApiContext,
id: string,
body: TransferWorkflowBodyDto,
): Promise<void> {
return await makeRestApiRequest(context, 'PUT', `/workflows/${id}/transfer`, body);
}

View File

@@ -0,0 +1,86 @@
import type {
IExecutionResponse,
IExecutionsCurrentSummaryExtended,
IRestApiContext,
IWorkflowDb,
NewWorkflowResponse,
WorkflowListResource,
} from '@/Interface';
import type {
ExecutionFilters,
ExecutionOptions,
ExecutionSummary,
IDataObject,
} from 'n8n-workflow';
import { getFullApiResponse, makeRestApiRequest } from '@/utils/apiUtils';
export async function getNewWorkflow(context: IRestApiContext, data?: IDataObject) {
const response = await makeRestApiRequest<NewWorkflowResponse>(
context,
'GET',
'/workflows/new',
data,
);
return {
name: response.name,
settings: response.defaultSettings,
};
}
export async function getWorkflow(context: IRestApiContext, id: string, filter?: object) {
const sendData = filter ? { filter } : undefined;
return await makeRestApiRequest<IWorkflowDb>(context, 'GET', `/workflows/${id}`, sendData);
}
export async function getWorkflows(context: IRestApiContext, filter?: object, options?: object) {
return await getFullApiResponse<IWorkflowDb[]>(context, 'GET', '/workflows', {
includeScopes: true,
...(filter ? { filter } : {}),
...(options ? options : {}),
});
}
export async function getWorkflowsAndFolders(
context: IRestApiContext,
filter?: object,
options?: object,
includeFolders?: boolean,
) {
return await getFullApiResponse<WorkflowListResource[]>(context, 'GET', '/workflows', {
includeScopes: true,
includeFolders,
...(filter ? { filter } : {}),
...(options ? options : {}),
});
}
export async function getActiveWorkflows(context: IRestApiContext) {
return await makeRestApiRequest<string[]>(context, 'GET', '/active-workflows');
}
export async function getActiveExecutions(context: IRestApiContext, filter: IDataObject) {
const output = await makeRestApiRequest<{
results: ExecutionSummary[];
count: number;
estimated: boolean;
}>(context, 'GET', '/executions', { filter });
return output.results;
}
export async function getExecutions(
context: IRestApiContext,
filter?: ExecutionFilters,
options?: ExecutionOptions,
): Promise<{ count: number; results: IExecutionsCurrentSummaryExtended[]; estimated: boolean }> {
return await makeRestApiRequest(context, 'GET', '/executions', { filter, ...options });
}
export async function getExecutionData(context: IRestApiContext, executionId: string) {
return await makeRestApiRequest<IExecutionResponse | null>(
context,
'GET',
`/executions/${executionId}`,
);
}

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="80px" height="80px" viewBox="0 0 80 80" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: Sketch 64 (93537) - https://sketch.com -->
<title>Icon-Architecture/64/Arch_AWS-Secrets-Manager_64</title>
<desc>Created with Sketch.</desc>
<defs>
<linearGradient x1="0%" y1="100%" x2="100%" y2="0%" id="linearGradient-1">
<stop stop-color="#BD0816" offset="0%"></stop>
<stop stop-color="#FF5252" offset="100%"></stop>
</linearGradient>
</defs>
<g id="Icon-Architecture/64/Arch_AWS-Secrets-Manager_64" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="Icon-Architecture-BG/64/Security-Identity-Compliance" fill="url(#linearGradient-1)">
<rect id="Rectangle" x="0" y="0" width="80" height="80"></rect>
</g>
<path d="M38.76,43.36 C38.76,44.044 39.317,44.6 40,44.6 C40.684,44.6 41.24,44.044 41.24,43.36 C41.24,42.676 40.684,42.12 40,42.12 C39.317,42.12 38.76,42.676 38.76,43.36 L38.76,43.36 Z M36.76,43.36 C36.76,41.573 38.213,40.12 40,40.12 C41.787,40.12 43.24,41.573 43.24,43.36 C43.24,44.796 42.296,46.002 41,46.426 L41,49 L39,49 L39,46.426 C37.704,46.002 36.76,44.796 36.76,43.36 L36.76,43.36 Z M49,38 L31,38 L31,51 L49,51 L49,48 L46,48 L46,46 L49,46 L49,43 L46,43 L46,41 L49,41 L49,38 Z M34,36 L45.999,36 L46,31 C46.001,28.384 43.143,26.002 40.004,26 L40.001,26 C38.472,26 36.928,26.574 35.763,27.575 C34.643,28.537 34,29.786 34,31.001 L34,36 Z M48,31.001 L47.999,36 L50,36 C50.553,36 51,36.448 51,37 L51,52 C51,52.552 50.553,53 50,53 L30,53 C29.447,53 29,52.552 29,52 L29,37 C29,36.448 29.447,36 30,36 L32,36 L32,31 C32.001,29.202 32.897,27.401 34.459,26.058 C35.982,24.75 38.001,24 40.001,24 L40.004,24 C44.265,24.002 48.001,27.273 48,31.001 L48,31.001 Z M19.207,55.049 L20.828,53.877 C18.093,50.097 16.581,45.662 16.396,41 L19,41 L19,39 L16.399,39 C16.598,34.366 18.108,29.957 20.828,26.198 L19.207,25.025 C16.239,29.128 14.599,33.942 14.399,39 L12,39 L12,41 L14.396,41 C14.582,46.086 16.224,50.926 19.207,55.049 L19.207,55.049 Z M53.838,59.208 C50.069,61.936 45.648,63.446 41,63.639 L41,61 L39,61 L39,63.639 C34.352,63.447 29.93,61.937 26.159,59.208 L24.988,60.828 C29.1,63.805 33.928,65.445 39,65.639 L39,68 L41,68 L41,65.639 C46.072,65.445 50.898,63.805 55.01,60.828 L53.838,59.208 Z M26.159,20.866 C29.93,18.138 34.352,16.628 39,16.436 L39,19 L41,19 L41,16.436 C45.648,16.628 50.069,18.138 53.838,20.866 L55.01,19.246 C50.898,16.27 46.072,14.63 41,14.436 L41,12 L39,12 L39,14.436 C33.928,14.629 29.1,16.269 24.988,19.246 L26.159,20.866 Z M65.599,39 C65.399,33.942 63.759,29.128 60.79,25.025 L59.169,26.198 C61.89,29.957 63.4,34.366 63.599,39 L61,39 L61,41 L63.602,41 C63.416,45.662 61.905,50.097 59.169,53.877 L60.79,55.049 C63.774,50.926 65.415,46.086 65.602,41 L68,41 L68,39 L65.599,39 Z M56.386,25.064 L64.226,17.224 L62.812,15.81 L54.972,23.65 L56.386,25.064 Z M23.612,55.01 L15.772,62.85 L17.186,64.264 L25.026,56.424 L23.612,55.01 Z M28.666,27.253 L13.825,12.413 L12.411,13.827 L27.252,28.667 L28.666,27.253 Z M54.193,52.78 L67.586,66.173 L66.172,67.587 L52.779,54.194 L54.193,52.78 Z" id="AWS-Secrets-Manager_Icon_64_Squid" fill="#FFFFFF"></path>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

@@ -0,0 +1,23 @@
<svg width="150" height="150" viewBox="0 0 96 96" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="e399c19f-b68f-429d-b176-18c2117ff73c" x1="-1032.172" x2="-1059.213" y1="145.312" y2="65.426" gradientTransform="matrix(1 0 0 -1 1075 158)" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#114a8b"/>
<stop offset="1" stop-color="#0669bc"/>
</linearGradient>
<linearGradient id="ac2a6fc2-ca48-4327-9a3c-d4dcc3256e15" x1="-1023.725" x2="-1029.98" y1="108.083" y2="105.968" gradientTransform="matrix(1 0 0 -1 1075 158)" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-opacity=".3"/>
<stop offset=".071" stop-opacity=".2"/>
<stop offset=".321" stop-opacity=".1"/>
<stop offset=".623" stop-opacity=".05"/>
<stop offset="1" stop-opacity="0"/>
</linearGradient>
<linearGradient id="a7fee970-a784-4bb1-af8d-63d18e5f7db9" x1="-1027.165" x2="-997.482" y1="147.642" y2="68.561" gradientTransform="matrix(1 0 0 -1 1075 158)" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#3ccbf4"/>
<stop offset="1" stop-color="#2892df"/>
</linearGradient>
</defs>
<path fill="url(#e399c19f-b68f-429d-b176-18c2117ff73c)" d="M33.338 6.544h26.038l-27.03 80.087a4.152 4.152 0 0 1-3.933 2.824H8.149a4.145 4.145 0 0 1-3.928-5.47L29.404 9.368a4.152 4.152 0 0 1 3.934-2.825z"/>
<path fill="#0078d4" d="M71.175 60.261h-41.29a1.911 1.911 0 0 0-1.305 3.309l26.532 24.764a4.171 4.171 0 0 0 2.846 1.121h23.38z"/>
<path fill="url(#ac2a6fc2-ca48-4327-9a3c-d4dcc3256e15)" d="M33.338 6.544a4.118 4.118 0 0 0-3.943 2.879L4.252 83.917a4.14 4.14 0 0 0 3.908 5.538h20.787a4.443 4.443 0 0 0 3.41-2.9l5.014-14.777 17.91 16.705a4.237 4.237 0 0 0 2.666.972H81.24L71.024 60.261l-29.781.007L59.47 6.544z"/>
<path fill="url(#a7fee970-a784-4bb1-af8d-63d18e5f7db9)" d="M66.595 9.364a4.145 4.145 0 0 0-3.928-2.82H33.648a4.146 4.146 0 0 1 3.928 2.82l25.184 74.62a4.146 4.146 0 0 1-3.928 5.472h29.02a4.146 4.146 0 0 0 3.927-5.472z"/>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 308 KiB

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<svg width="800px" height="800px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g transform="translate(2.000000, 6.000000)" fill="#4285F4" fill-rule="nonzero">
<path d="M20,9.41469125e-14 L20,11.8867925 L16.0377358,11.8867925 C15.9335312,11.8867925 15.8490566,11.8023179 15.8490566,11.6981132 L15.8490566,10.5660377 C15.8490566,10.4618331 15.9335312,10.3773585 16.0377358,10.3773585 L18.4900566,10.377 L18.4900566,1.509 L16.0377358,1.50943396 C15.9335312,1.50943396 15.8490566,1.42495939 15.8490566,1.32075472 L15.8490566,0.188679245 C15.8490566,0.0844745755 15.9335312,9.41105434e-14 16.0377358,9.41469125e-14 L20,9.41469125e-14 Z M4.309101,9.41469125e-14 C4.41330567,9.3877869e-14 4.49778024,0.0844745755 4.49778024,0.188679245 L4.49778024,1.32075472 C4.49778024,1.42495939 4.41330567,1.50943396 4.309101,1.50943396 L1.509,1.509 L1.509,10.377 L4.29245283,10.3773585 C4.3966575,10.3773585 4.48113208,10.4618331 4.48113208,10.5660377 L4.48113208,11.6981132 C4.48113208,11.8023179 4.3966575,11.8867925 4.29245283,11.8867925 L2.14050999e-13,11.8867925 L2.14050999e-13,9.41469125e-14 L4.309101,9.41469125e-14 Z M15.4271098,3.86792453 L15.4271098,5.3240566 L15.4879305,5.34852941 L16.8381494,4.87130966 L17.1179245,5.69114872 L15.7555414,6.15613208 L15.719049,6.25402331 L16.6556874,7.51437292 L15.9501676,8.02830189 L15.0378575,6.76795228 L14.9527085,6.76795228 L14.0403984,8.02830189 L13.3348786,7.51437292 L14.2593529,6.25402331 L14.2350246,6.15613208 L12.8726415,5.69114872 L13.1524166,4.87130966 L14.4904714,5.34852941 L14.5634562,5.3240566 L14.5634562,3.86792453 L15.4271098,3.86792453 Z M5.19597773,3.86792453 L5.19597773,5.30963945 L5.2567984,5.33386996 L6.60701735,4.86137515 L6.88679245,5.673097 L5.52440936,6.13347656 L5.48791696,6.23039857 L6.42455533,7.47826947 L5.71903552,7.98711003 L4.80672541,6.73923913 L4.72157647,6.73923913 L3.80926637,7.98711003 L3.10374655,7.47826947 L4.02822079,6.23039857 L4.00389252,6.13347656 L2.64150943,5.673097 L2.92128453,4.86137515 L4.25933935,5.33386996 L4.33232416,5.30963945 L4.33232416,3.86792453 L5.19597773,3.86792453 Z M10.2903173,3.86792453 L10.2903173,5.30963945 L10.351138,5.33386996 L11.701357,4.86137515 L11.9811321,5.673097 L10.618749,6.13347656 L10.5822566,6.23039857 L11.518895,7.47826947 L10.8133751,7.98711003 L9.90106504,6.73923913 L9.81591609,6.73923913 L8.90360599,7.98711003 L8.19808618,7.47826947 L9.12256042,6.23039857 L9.09823215,6.13347656 L7.73584906,5.673097 L8.01562416,4.86137515 L9.35367897,5.33386996 L9.42666378,5.30963945 L9.42666378,3.86792453 L10.2903173,3.86792453 Z" >
</path>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,108 @@
<script setup lang="ts">
import { createEventBus } from '@n8n/utils/event-bus';
import Modal from './Modal.vue';
import { ABOUT_MODAL_KEY } from '../constants';
import { useRootStore } from '@/stores/root.store';
import { useToast } from '@/composables/useToast';
import { useClipboard } from '@/composables/useClipboard';
import { useDebugInfo } from '@/composables/useDebugInfo';
import { useI18n } from '@/composables/useI18n';
const modalBus = createEventBus();
const toast = useToast();
const i18n = useI18n();
const debugInfo = useDebugInfo();
const clipboard = useClipboard();
const rootStore = useRootStore();
const closeDialog = () => {
modalBus.emit('close');
};
const copyDebugInfoToClipboard = async () => {
toast.showToast({
title: i18n.baseText('about.debug.toast.title'),
message: i18n.baseText('about.debug.toast.message'),
type: 'info',
duration: 5000,
});
await clipboard.copy(debugInfo.generateDebugInfo());
};
</script>
<template>
<Modal
max-width="540px"
:title="i18n.baseText('about.aboutN8n')"
:event-bus="modalBus"
:name="ABOUT_MODAL_KEY"
:center="true"
>
<template #content>
<div :class="$style.container">
<el-row>
<el-col :span="8" class="info-name">
<n8n-text>{{ i18n.baseText('about.n8nVersion') }}</n8n-text>
</el-col>
<el-col :span="16">
<n8n-text>{{ rootStore.versionCli }}</n8n-text>
</el-col>
</el-row>
<el-row>
<el-col :span="8" class="info-name">
<n8n-text>{{ i18n.baseText('about.sourceCode') }}</n8n-text>
</el-col>
<el-col :span="16">
<n8n-link to="https://github.com/n8n-io/n8n">https://github.com/n8n-io/n8n</n8n-link>
</el-col>
</el-row>
<el-row>
<el-col :span="8" class="info-name">
<n8n-text>{{ i18n.baseText('about.license') }}</n8n-text>
</el-col>
<el-col :span="16">
<n8n-link to="https://github.com/n8n-io/n8n/blob/master/LICENSE.md">
{{ i18n.baseText('about.n8nLicense') }}
</n8n-link>
</el-col>
</el-row>
<el-row>
<el-col :span="8" class="info-name">
<n8n-text>{{ i18n.baseText('about.instanceID') }}</n8n-text>
</el-col>
<el-col :span="16">
<n8n-text>{{ rootStore.instanceId }}</n8n-text>
</el-col>
</el-row>
<el-row>
<el-col :span="8" class="info-name">
<n8n-text>{{ i18n.baseText('about.debug.title') }}</n8n-text>
</el-col>
<el-col :span="16">
<div :class="$style.debugInfo" @click="copyDebugInfoToClipboard">
<n8n-link>{{ i18n.baseText('about.debug.message') }}</n8n-link>
</div>
</el-col>
</el-row>
</div>
</template>
<template #footer>
<div class="action-buttons">
<n8n-button
float="right"
:label="i18n.baseText('about.close')"
data-test-id="close-about-modal-button"
@click="closeDialog"
/>
</div>
</template>
</Modal>
</template>
<style module lang="scss">
.container > * {
margin-bottom: var(--spacing-s);
overflow-wrap: break-word;
}
</style>

View File

@@ -0,0 +1,141 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import Modal from '@/components/Modal.vue';
import {
WORKFLOW_ACTIVE_MODAL_KEY,
WORKFLOW_SETTINGS_MODAL_KEY,
LOCAL_STORAGE_ACTIVATION_FLAG,
VIEWS,
} from '../constants';
import { getActivatableTriggerNodes, getTriggerNodeServiceName } from '@/utils/nodeTypesUtils';
import { useUIStore } from '@/stores/ui.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useStorage } from '@/composables/useStorage';
import { useExecutionsStore } from '@/stores/executions.store';
import { useRouter } from 'vue-router';
import { useI18n } from '@/composables/useI18n';
const checked = ref(false);
const executionsStore = useExecutionsStore();
const workflowsStore = useWorkflowsStore();
const nodeTypesStore = useNodeTypesStore();
const uiStore = useUIStore();
const router = useRouter();
const i18n = useI18n();
const triggerContent = computed(() => {
const foundTriggers = getActivatableTriggerNodes(workflowsStore.workflowTriggerNodes);
if (!foundTriggers.length) {
return '';
}
if (foundTriggers.length > 1) {
return i18n.baseText('activationModal.yourTriggersWillNowFire');
}
const trigger = foundTriggers[0];
const triggerNodeType = nodeTypesStore.getNodeType(trigger.type, trigger.typeVersion);
if (triggerNodeType) {
if (triggerNodeType.activationMessage) {
return triggerNodeType.activationMessage;
}
const serviceName = getTriggerNodeServiceName(triggerNodeType);
if (trigger.webhookId) {
return i18n.baseText('activationModal.yourWorkflowWillNowListenForEvents', {
interpolate: {
serviceName,
},
});
} else if (triggerNodeType.polling) {
return i18n.baseText('activationModal.yourWorkflowWillNowRegularlyCheck', {
interpolate: {
serviceName,
},
});
}
}
return i18n.baseText('activationModal.yourTriggerWillNowFire');
});
const showExecutionsList = async () => {
const activeExecution = executionsStore.activeExecution;
const currentWorkflow = workflowsStore.workflowId;
if (activeExecution) {
router
.push({
name: VIEWS.EXECUTION_PREVIEW,
params: { name: currentWorkflow, executionId: activeExecution.id },
})
.catch(() => {});
} else {
router.push({ name: VIEWS.EXECUTION_HOME, params: { name: currentWorkflow } }).catch(() => {});
}
uiStore.closeModal(WORKFLOW_ACTIVE_MODAL_KEY);
};
const showSettings = async () => {
uiStore.openModal(WORKFLOW_SETTINGS_MODAL_KEY);
};
const handleCheckboxChange = (checkboxValue: boolean) => {
checked.value = checkboxValue;
useStorage(LOCAL_STORAGE_ACTIVATION_FLAG).value = checkboxValue.toString();
};
</script>
<template>
<Modal
:name="WORKFLOW_ACTIVE_MODAL_KEY"
:title="i18n.baseText('activationModal.workflowActivated')"
width="460px"
>
<template #content>
<div>
<n8n-text>{{ triggerContent }}</n8n-text>
</div>
<div :class="$style.spaced">
<n8n-text>
<n8n-text :bold="true">
{{ i18n.baseText('activationModal.theseExecutionsWillNotShowUp') }}
</n8n-text>
{{ i18n.baseText('activationModal.butYouCanSeeThem') }}
<a @click="showExecutionsList">
{{ i18n.baseText('activationModal.executionList') }}
</a>
{{ i18n.baseText('activationModal.ifYouChooseTo') }}
<a @click="showSettings">{{ i18n.baseText('activationModal.saveExecutions') }}</a>
</n8n-text>
</div>
</template>
<template #footer="{ close }">
<div :class="$style.footer">
<el-checkbox :model-value="checked" @update:model-value="handleCheckboxChange">{{
i18n.baseText('generic.dontShowAgain')
}}</el-checkbox>
<n8n-button :label="i18n.baseText('activationModal.gotIt')" @click="close" />
</div>
</template>
</Modal>
</template>
<style lang="scss" module>
.spaced {
margin-top: var(--spacing-2xs);
}
.footer {
text-align: right;
> * {
margin-left: var(--spacing-s);
}
}
</style>

View File

@@ -0,0 +1,37 @@
<script setup lang="ts">
withDefaults(
defineProps<{
size?: 'mini' | 'small' | 'medium' | 'large';
}>(),
{
size: 'medium',
},
);
const sizes = {
mini: 8,
small: 10,
medium: 12,
large: 16,
};
</script>
<template>
<svg
:width="sizes[size]"
:height="sizes[size]"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g id="NodeIcon">
<path
id="Union"
fill-rule="evenodd"
clip-rule="evenodd"
d="M15.7982 7.80784L13.92 7.20243C12.7186 6.80602 12.0148 5.83386 11.6844 4.61226L10.8579 0.586096C10.8363 0.506544 10.7837 0.400024 10.6219 0.400024C10.4857 0.400024 10.4075 0.506544 10.386 0.586096L9.55943 4.61361C9.22773 5.83521 8.52525 6.80737 7.32387 7.20378L5.44562 7.80919C5.18 7.89548 5.17595 8.27032 5.44023 8.36066L7.33196 9.01191C8.52929 9.40968 9.22773 10.3805 9.55943 11.5967L10.3873 15.5784C10.4089 15.6579 10.4534 15.8008 10.6233 15.8008C10.7991 15.8008 10.8362 15.6634 10.858 15.5831L10.8592 15.5784L11.6871 11.5967C12.0188 10.3791 12.7173 9.40833 13.9146 9.01191L15.8063 8.36066C16.0679 8.26897 16.0639 7.89413 15.7982 7.80784ZM5.04114 11.3108C3.92815 10.9434 3.81743 10.5296 3.63184 9.83597L3.62672 9.81687L3.15615 8.16649C3.12784 8.05997 2.85008 8.05997 2.82041 8.16649L2.50085 9.69147C2.31074 10.394 1.90623 10.9522 1.21588 11.18L0.11563 11.6574C-0.0367335 11.7072 -0.0394302 11.923 0.112933 11.9742L1.22127 12.3666C1.90893 12.5945 2.31074 13.1527 2.5022 13.8525L2.82176 15.3114C2.85142 15.4179 3.12784 15.4179 3.15615 15.3114L3.53099 13.8592C3.72111 13.1554 4.01235 12.5958 4.94675 12.3666L5.98768 11.9742C6.14004 11.9216 6.13869 11.7059 5.98498 11.656L5.04114 11.3108ZM5.33019 0.812949C5.36674 0.661849 5.58158 0.659434 5.61894 0.811355L5.61899 0.811582L6.02856 2.50239C6.08442 2.69624 6.23624 2.8465 6.43132 2.89951L7.47286 3.18013C7.61383 3.2197 7.61829 3.41714 7.48035 3.46394L7.48015 3.46401L6.38799 3.83076C6.21241 3.88968 6.07619 4.03027 6.02153 4.20719L5.61894 5.77311L5.61884 5.77349C5.58095 5.92613 5.36829 5.91987 5.33166 5.77336L4.94237 4.21215C4.88888 4.03513 4.75378 3.89336 4.57956 3.83328L3.48805 3.4555C3.34919 3.40591 3.36033 3.20859 3.50031 3.17175L3.50054 3.17169L4.53472 2.90337C4.73486 2.85153 4.89134 2.69755 4.94463 2.49805L5.33019 0.812949Z"
fill="currentColor"
/>
</g>
</svg>
</template>

View File

@@ -0,0 +1,16 @@
<script lang="ts" setup>
import { useI18n } from '@/composables/useI18n';
const i18n = useI18n();
defineProps<{
nodeName: string;
}>();
</script>
<template>
<div>
{{ i18n.baseText('aiAssistant.codeUpdated.message.body1') }}
<a data-action="openNodeDetail" :data-action-parameter-node="nodeName">{{ nodeName }}</a>
{{ i18n.baseText('aiAssistant.codeUpdated.message.body2') }}
</div>
</template>

View File

@@ -0,0 +1,74 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useUIStore } from '@/stores/ui.store';
import { useAnnotationTagsStore } from '@/stores/tags.store';
import { ANNOTATION_TAGS_MANAGER_MODAL_KEY } from '@/constants';
import type { EventBus } from '@n8n/utils/event-bus';
interface TagsDropdownWrapperProps {
placeholder?: string;
modelValue?: string[];
createEnabled?: boolean;
eventBus?: EventBus | null;
}
const props = withDefaults(defineProps<TagsDropdownWrapperProps>(), {
placeholder: '',
modelValue: () => [],
createEnabled: false,
eventBus: null,
});
const emit = defineEmits<{
'update:modelValue': [selected: string[]];
esc: [];
blur: [];
}>();
const tagsStore = useAnnotationTagsStore();
const uiStore = useUIStore();
const selectedTags = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value),
});
const allTags = computed(() => tagsStore.allTags);
const isLoading = computed(() => tagsStore.isLoading);
const tagsById = computed(() => tagsStore.tagsById);
async function createTag(name: string) {
return await tagsStore.create(name);
}
function handleManageTags() {
uiStore.openModal(ANNOTATION_TAGS_MANAGER_MODAL_KEY);
}
function handleEsc() {
emit('esc');
}
function handleBlur() {
emit('blur');
}
// Fetch all tags when the component is created
void tagsStore.fetchAll();
</script>
<template>
<TagsDropdown
v-model="selectedTags"
:placeholder="placeholder"
:create-enabled="createEnabled"
:event-bus="eventBus"
:all-tags="allTags"
:is-loading="isLoading"
:tags-by-id="tagsById"
:create-tag="createTag"
@manage-tags="handleManageTags"
@esc="handleEsc"
@blur="handleBlur"
/>
</template>

View File

@@ -0,0 +1,125 @@
<script lang="ts" setup>
import { ref } from 'vue';
import { useI18n } from '@/composables/useI18n';
import type { ApiKey } from '@n8n/api-types';
import { DateTime } from 'luxon';
const API_KEY_ITEM_ACTIONS = {
EDIT: 'edit',
DELETE: 'delete',
};
const ACTION_LIST = [
{
label: 'Edit',
value: API_KEY_ITEM_ACTIONS.EDIT,
},
{
label: 'Delete',
value: API_KEY_ITEM_ACTIONS.DELETE,
},
];
const i18n = useI18n();
const cardActions = ref<HTMLDivElement | null>(null);
const props = defineProps<{
apiKey: ApiKey;
}>();
const emit = defineEmits<{
edit: [id: string];
delete: [id: string];
}>();
async function onAction(action: string) {
if (action === API_KEY_ITEM_ACTIONS.EDIT) {
emit('edit', props.apiKey.id);
} else if (action === API_KEY_ITEM_ACTIONS.DELETE) {
emit('delete', props.apiKey.id);
}
}
const hasApiKeyExpired = (apiKey: ApiKey) => {
if (!apiKey.expiresAt) return false;
return apiKey.expiresAt <= Date.now() / 1000;
};
const getExpirationTime = (apiKey: ApiKey): string => {
if (!apiKey.expiresAt) return i18n.baseText('settings.api.neverExpires');
if (hasApiKeyExpired(apiKey)) return i18n.baseText('settings.api.expired');
const time = DateTime.fromSeconds(apiKey.expiresAt).toFormat('ccc, MMM d yyyy');
return i18n.baseText('settings.api.expirationTime', { interpolate: { time } });
};
</script>
<template>
<n8n-card :class="$style.cardLink" data-test-id="api-key-card" @click="onAction('edit')">
<template #header>
<div>
<n8n-heading tag="h2" bold :class="$style.cardHeading">
{{ apiKey.label }}
</n8n-heading>
<div :class="[$style.cardDescription]">
<n8n-text :color="!hasApiKeyExpired(apiKey) ? 'text-light' : 'warning'" size="small">
<span>{{ getExpirationTime(apiKey) }}</span>
</n8n-text>
</div>
</div>
<div v-if="apiKey.apiKey.includes('*')" :class="$style.cardApiKey">
<n8n-text color="text-light" size="small"> {{ apiKey.apiKey }}</n8n-text>
</div>
</template>
<template #append>
<div ref="cardActions" :class="$style.cardActions">
<n8n-action-toggle :actions="ACTION_LIST" theme="dark" @action="onAction" />
</div>
</template>
</n8n-card>
</template>
<style lang="scss" module>
.cardLink {
transition: box-shadow 0.3s ease;
cursor: pointer;
padding: 0 0 0 var(--spacing-s);
align-items: stretch;
&:hover {
box-shadow: 0 2px 8px rgba(#441c17, 0.1);
}
}
.cardHeading {
font-size: var(--font-size-s);
word-break: word-break;
padding: var(--spacing-s) 0 0 var(--spacing-s);
width: 200px;
}
.cardDescription {
min-height: 19px;
display: flex;
align-items: center;
padding: 0 0 var(--spacing-s) var(--spacing-s);
}
.cardActions {
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
padding: 0 var(--spacing-s) 0 0;
cursor: default;
}
.cardApiKey {
flex-grow: 1;
display: flex;
justify-content: center;
}
</style>

View File

@@ -0,0 +1,235 @@
import { createComponentRenderer } from '@/__tests__/render';
import { createTestingPinia } from '@pinia/testing';
import { API_KEY_CREATE_OR_EDIT_MODAL_KEY, STORES } from '@/constants';
import { cleanupAppModals, createAppModals, mockedStore, retry } from '@/__tests__/utils';
import ApiKeyEditModal from './ApiKeyCreateOrEditModal.vue';
import { fireEvent } from '@testing-library/vue';
import { useApiKeysStore } from '@/stores/apiKeys.store';
import { DateTime } from 'luxon';
import type { ApiKeyWithRawValue } from '@n8n/api-types';
const renderComponent = createComponentRenderer(ApiKeyEditModal, {
pinia: createTestingPinia({
initialState: {
[STORES.UI]: {
modalsById: {
[API_KEY_CREATE_OR_EDIT_MODAL_KEY]: { open: true },
},
},
},
}),
});
const testApiKey: ApiKeyWithRawValue = {
id: '123',
label: 'new api key',
apiKey: '123456***',
createdAt: new Date().toString(),
updatedAt: new Date().toString(),
rawApiKey: '123456',
expiresAt: 0,
};
const apiKeysStore = mockedStore(useApiKeysStore);
describe('ApiKeyCreateOrEditModal', () => {
beforeEach(() => {
createAppModals();
});
afterEach(() => {
cleanupAppModals();
vi.clearAllMocks();
});
test('should allow creating API key with default expiration (30 days)', async () => {
apiKeysStore.createApiKey.mockResolvedValue(testApiKey);
const { getByText, getByPlaceholderText } = renderComponent({
props: {
mode: 'new',
},
});
await retry(() => expect(getByText('Create API Key')).toBeInTheDocument());
expect(getByText('Label')).toBeInTheDocument();
const inputLabel = getByPlaceholderText('e.g Internal Project');
const saveButton = getByText('Save');
expect(inputLabel).toBeInTheDocument();
expect(saveButton).toBeInTheDocument();
await fireEvent.update(inputLabel, 'new label');
await fireEvent.click(saveButton);
expect(getByText('API Key Created')).toBeInTheDocument();
expect(getByText('Done')).toBeInTheDocument();
expect(
getByText('Make sure to copy your API key now as you will not be able to see this again.'),
).toBeInTheDocument();
expect(getByText('You can find more details in')).toBeInTheDocument();
expect(getByText('the API documentation')).toBeInTheDocument();
expect(getByText('Click to copy')).toBeInTheDocument();
expect(getByText('new api key')).toBeInTheDocument();
});
test('should allow creating API key with custom expiration', async () => {
apiKeysStore.createApiKey.mockResolvedValue({
id: '123',
label: 'new api key',
apiKey: '123456',
createdAt: new Date().toString(),
updatedAt: new Date().toString(),
rawApiKey: '***456',
expiresAt: 0,
});
const { getByText, getByPlaceholderText, getByTestId } = renderComponent({
props: {
mode: 'new',
},
});
await retry(() => expect(getByText('Create API Key')).toBeInTheDocument());
expect(getByText('Label')).toBeInTheDocument();
const inputLabel = getByPlaceholderText('e.g Internal Project');
const saveButton = getByText('Save');
const expirationSelect = getByTestId('expiration-select');
expect(inputLabel).toBeInTheDocument();
expect(saveButton).toBeInTheDocument();
expect(expirationSelect).toBeInTheDocument();
await fireEvent.update(inputLabel, 'new label');
await fireEvent.click(expirationSelect);
const customOption = getByText('Custom');
expect(customOption).toBeInTheDocument();
await fireEvent.click(customOption);
const customExpirationInput = getByPlaceholderText('yyyy-mm-dd');
expect(customExpirationInput).toBeInTheDocument();
await fireEvent.input(customExpirationInput, '2029-12-31');
await fireEvent.click(saveButton);
expect(getByText('***456')).toBeInTheDocument();
expect(getByText('API Key Created')).toBeInTheDocument();
expect(getByText('Done')).toBeInTheDocument();
expect(
getByText('Make sure to copy your API key now as you will not be able to see this again.'),
).toBeInTheDocument();
expect(getByText('You can find more details in')).toBeInTheDocument();
expect(getByText('the API documentation')).toBeInTheDocument();
expect(getByText('Click to copy')).toBeInTheDocument();
expect(getByText('new api key')).toBeInTheDocument();
});
test('should allow creating API key with no expiration', async () => {
apiKeysStore.createApiKey.mockResolvedValue(testApiKey);
const { getByText, getByPlaceholderText, getByTestId } = renderComponent({
props: {
mode: 'new',
},
});
await retry(() => expect(getByText('Create API Key')).toBeInTheDocument());
expect(getByText('Label')).toBeInTheDocument();
const inputLabel = getByPlaceholderText('e.g Internal Project');
const saveButton = getByText('Save');
const expirationSelect = getByTestId('expiration-select');
expect(inputLabel).toBeInTheDocument();
expect(saveButton).toBeInTheDocument();
expect(expirationSelect).toBeInTheDocument();
await fireEvent.update(inputLabel, 'new label');
await fireEvent.click(expirationSelect);
const noExpirationOption = getByText('No Expiration');
expect(noExpirationOption).toBeInTheDocument();
await fireEvent.click(noExpirationOption);
await fireEvent.click(saveButton);
expect(getByText('API Key Created')).toBeInTheDocument();
expect(getByText('Done')).toBeInTheDocument();
expect(
getByText('Make sure to copy your API key now as you will not be able to see this again.'),
).toBeInTheDocument();
expect(getByText('You can find more details in')).toBeInTheDocument();
expect(getByText('the API documentation')).toBeInTheDocument();
expect(getByText('Click to copy')).toBeInTheDocument();
expect(getByText('new api key')).toBeInTheDocument();
});
test('should allow editing API key label', async () => {
apiKeysStore.apiKeys = [testApiKey];
apiKeysStore.updateApiKey.mockResolvedValue();
const { getByText, getByTestId } = renderComponent({
props: {
mode: 'edit',
activeId: '123',
},
});
await retry(() => expect(getByText('Edit API Key')).toBeInTheDocument());
expect(getByText('Label')).toBeInTheDocument();
const formattedDate = DateTime.fromMillis(Date.parse(testApiKey.createdAt)).toFormat(
'ccc, MMM d yyyy',
);
expect(getByText(`API key was created on ${formattedDate}`)).toBeInTheDocument();
const labelInput = getByTestId('api-key-label');
expect((labelInput as unknown as HTMLInputElement).value).toBe('new api key');
await fireEvent.update(labelInput, 'updated api key');
const editButton = getByText('Edit');
expect(editButton).toBeInTheDocument();
await fireEvent.click(editButton);
expect(apiKeysStore.updateApiKey).toHaveBeenCalledWith('123', { label: 'updated api key' });
});
});

View File

@@ -0,0 +1,389 @@
<script lang="ts" setup>
import Modal from '@/components/Modal.vue';
import { API_KEY_CREATE_OR_EDIT_MODAL_KEY, DOCS_DOMAIN } from '@/constants';
import { computed, onMounted, ref } from 'vue';
import { useUIStore } from '@/stores/ui.store';
import { createEventBus } from '@n8n/utils/event-bus';
import { useI18n } from '@/composables/useI18n';
import { useSettingsStore } from '@/stores/settings.store';
import { useRootStore } from '@/stores/root.store';
import { useDocumentTitle } from '@/composables/useDocumentTitle';
import { useApiKeysStore } from '@/stores/apiKeys.store';
import { useToast } from '@/composables/useToast';
import type { BaseTextKey } from '@/plugins/i18n';
import { N8nText } from '@n8n/design-system';
import { DateTime } from 'luxon';
import type { ApiKey, ApiKeyWithRawValue, CreateApiKeyRequestDto } from '@n8n/api-types';
const EXPIRATION_OPTIONS = {
'7_DAYS': 7,
'30_DAYS': 30,
'60_DAYS': 60,
'90_DAYS': 90,
CUSTOM: 1,
NO_EXPIRATION: 0,
};
const i18n = useI18n();
const { showError, showMessage } = useToast();
const uiStore = useUIStore();
const rootStore = useRootStore();
const settingsStore = useSettingsStore();
const { isSwaggerUIEnabled, publicApiPath, publicApiLatestVersion } = settingsStore;
const { createApiKey, updateApiKey, apiKeysById } = useApiKeysStore();
const { baseUrl } = useRootStore();
const documentTitle = useDocumentTitle();
const label = ref('');
const expirationDaysFromNow = ref(EXPIRATION_OPTIONS['30_DAYS']);
const modalBus = createEventBus();
const newApiKey = ref<ApiKeyWithRawValue | null>(null);
const apiDocsURL = ref('');
const loading = ref(false);
const rawApiKey = ref('');
const customExpirationDate = ref('');
const showExpirationDateSelector = ref(false);
const apiKeyCreationDate = ref('');
const calculateExpirationDate = (daysFromNow: number) => {
const date = DateTime.now()
.setZone(rootStore.timezone)
.startOf('day')
.plus({ days: daysFromNow });
return date;
};
const getExpirationOptionLabel = (value: number) => {
if (EXPIRATION_OPTIONS.CUSTOM === value) {
return i18n.baseText('settings.api.view.modal.form.expiration.custom');
}
if (EXPIRATION_OPTIONS.NO_EXPIRATION === value) {
return i18n.baseText('settings.api.view.modal.form.expiration.none');
}
return i18n.baseText('settings.api.view.modal.form.expiration.days', {
interpolate: {
numberOfDays: value,
},
});
};
const expirationDate = ref(
calculateExpirationDate(expirationDaysFromNow.value).toFormat('ccc, MMM d yyyy'),
);
const inputRef = ref<HTMLTextAreaElement | null>(null);
const props = withDefaults(
defineProps<{
mode?: 'new' | 'edit';
activeId?: string;
}>(),
{
mode: 'new',
activeId: '',
},
);
const allFormFieldsAreSet = computed(() => {
const isExpirationDateSet =
expirationDaysFromNow.value === EXPIRATION_OPTIONS.NO_EXPIRATION ||
(expirationDaysFromNow.value === EXPIRATION_OPTIONS.CUSTOM && customExpirationDate.value) ||
expirationDate.value;
return label.value && (props.mode === 'edit' ? true : isExpirationDateSet);
});
const isCustomDateInThePast = (date: Date) => Date.now() > date.getTime();
onMounted(() => {
documentTitle.set(i18n.baseText('settings.api'));
setTimeout(() => {
inputRef.value?.focus();
});
if (props.mode === 'edit') {
const apiKey = apiKeysById[props.activeId];
label.value = apiKey.label ?? '';
apiKeyCreationDate.value = getApiKeyCreationTime(apiKey);
}
apiDocsURL.value = isSwaggerUIEnabled
? `${baseUrl}${publicApiPath}/v${publicApiLatestVersion}/docs`
: `https://${DOCS_DOMAIN}/api/api-reference/`;
});
function onInput(value: string): void {
label.value = value;
}
const getApiKeyCreationTime = (apiKey: ApiKey): string => {
const time = DateTime.fromMillis(Date.parse(apiKey.createdAt)).toFormat('ccc, MMM d yyyy');
return i18n.baseText('settings.api.creationTime', { interpolate: { time } });
};
async function onEdit() {
try {
loading.value = true;
await updateApiKey(props.activeId, { label: label.value });
showMessage({
type: 'success',
title: i18n.baseText('settings.api.update.toast'),
});
closeModal();
} catch (error) {
showError(error, i18n.baseText('settings.api.edit.error'));
} finally {
loading.value = false;
}
}
function closeModal() {
uiStore.closeModal(API_KEY_CREATE_OR_EDIT_MODAL_KEY);
}
const onSave = async () => {
if (!label.value) {
return;
}
let expirationUnixTimestamp = null;
if (expirationDaysFromNow.value === EXPIRATION_OPTIONS.CUSTOM) {
expirationUnixTimestamp = parseInt(customExpirationDate.value, 10);
} else if (expirationDaysFromNow.value !== EXPIRATION_OPTIONS.NO_EXPIRATION) {
expirationUnixTimestamp = calculateExpirationDate(expirationDaysFromNow.value).toUnixInteger();
}
const payload: CreateApiKeyRequestDto = {
label: label.value,
expiresAt: expirationUnixTimestamp,
};
try {
loading.value = true;
newApiKey.value = await createApiKey(payload);
rawApiKey.value = newApiKey.value.rawApiKey;
showMessage({
type: 'success',
title: i18n.baseText('settings.api.create.toast'),
});
} catch (error) {
showError(error, i18n.baseText('settings.api.create.error'));
} finally {
loading.value = false;
}
};
const modalTitle = computed(() => {
let path = 'edit';
if (props.mode === 'new') {
if (newApiKey.value) {
path = 'created';
} else {
path = 'create';
}
}
return i18n.baseText(`settings.api.view.modal.title.${path}` as BaseTextKey);
});
const onSelect = (value: number) => {
if (value === EXPIRATION_OPTIONS.CUSTOM) {
showExpirationDateSelector.value = true;
expirationDate.value = '';
return;
}
if (value !== EXPIRATION_OPTIONS.NO_EXPIRATION) {
expirationDate.value = calculateExpirationDate(value).toFormat('ccc, MMM d yyyy');
showExpirationDateSelector.value = false;
return;
}
expirationDate.value = '';
showExpirationDateSelector.value = false;
};
</script>
<template>
<Modal
:title="modalTitle"
:event-bus="modalBus"
:name="API_KEY_CREATE_OR_EDIT_MODAL_KEY"
width="600px"
:lock-scroll="false"
:close-on-esc="true"
:close-on-click-outside="true"
:show-close="true"
>
<template #content>
<div>
<p v-if="newApiKey" class="mb-s">
<n8n-info-tip :bold="false">
<i18n-t keypath="settings.api.view.info" tag="span">
<template #apiAction>
<a
href="https://docs.n8n.io/api"
target="_blank"
v-text="i18n.baseText('settings.api.view.info.api')"
/>
</template>
<template #webhookAction>
<a
href="https://docs.n8n.io/integrations/core-nodes/n8n-nodes-base.webhook/"
target="_blank"
v-text="i18n.baseText('settings.api.view.info.webhook')"
/>
</template>
</i18n-t>
</n8n-info-tip>
</p>
<n8n-card v-if="newApiKey" class="mb-4xs">
<CopyInput
:label="newApiKey.label"
:value="newApiKey.rawApiKey"
:redact-value="true"
:copy-button-text="i18n.baseText('generic.clickToCopy')"
:toast-title="i18n.baseText('settings.api.view.copy.toast')"
:hint="i18n.baseText('settings.api.view.copy')"
/>
</n8n-card>
<div v-if="newApiKey" :class="$style.hint">
<N8nText size="small">
{{
i18n.baseText(
`settings.api.view.${settingsStore.isSwaggerUIEnabled ? 'tryapi' : 'more-details'}`,
)
}}
</N8nText>
{{ ' ' }}
<n8n-link :to="apiDocsURL" :new-window="true" size="small">
{{
i18n.baseText(
`settings.api.view.${isSwaggerUIEnabled ? 'apiPlayground' : 'external-docs'}`,
)
}}
</n8n-link>
</div>
<div v-else :class="$style.form">
<N8nInputLabel
:label="i18n.baseText('settings.api.view.modal.form.label')"
color="text-dark"
>
<N8nInput
ref="inputRef"
required
:model-value="label"
size="large"
type="text"
:placeholder="i18n.baseText('settings.api.view.modal.form.label.placeholder')"
:maxlength="50"
data-test-id="api-key-label"
@update:model-value="onInput"
/>
</N8nInputLabel>
<div v-if="mode === 'new'" :class="$style.expirationSection">
<N8nInputLabel
:label="i18n.baseText('settings.api.view.modal.form.expiration')"
color="text-dark"
>
<N8nSelect
v-model="expirationDaysFromNow"
size="large"
filterable
data-test-id="expiration-select"
@update:model-value="onSelect"
>
<N8nOption
v-for="key in Object.keys(EXPIRATION_OPTIONS)"
:key="key"
:value="EXPIRATION_OPTIONS[key as keyof typeof EXPIRATION_OPTIONS]"
:label="
getExpirationOptionLabel(
EXPIRATION_OPTIONS[key as keyof typeof EXPIRATION_OPTIONS],
)
"
>
</N8nOption>
</N8nSelect>
</N8nInputLabel>
<N8nText v-if="expirationDate" class="mb-xs">{{
i18n.baseText('settings.api.view.modal.form.expirationText', {
interpolate: { expirationDate },
})
}}</N8nText>
<el-date-picker
v-if="showExpirationDateSelector"
v-model="customExpirationDate"
type="date"
:teleported="false"
placeholder="yyyy-mm-dd"
value-format="X"
:disabled-date="isCustomDateInThePast"
/>
</div>
</div>
</div>
</template>
<template #footer>
<div :class="$style.footer">
<N8nButton
v-if="mode === 'new' && !newApiKey"
:loading="loading"
:disabled="!allFormFieldsAreSet"
:label="i18n.baseText('settings.api.view.modal.save.button')"
@click="onSave"
/>
<N8nButton
v-else-if="mode === 'new'"
:label="i18n.baseText('settings.api.view.modal.done.button')"
@click="closeModal"
/>
<N8nButton
v-if="mode === 'edit'"
:disabled="!allFormFieldsAreSet"
:label="i18n.baseText('settings.api.view.modal.edit.button')"
@click="onEdit"
/>
<N8nText v-if="mode === 'edit'" size="small" color="text-light">{{
apiKeyCreationDate
}}</N8nText>
</div>
</template>
</Modal>
</template>
<style module lang="scss">
.notice {
margin: 0;
}
.hint {
color: var(--color-text-light);
margin-bottom: var(--spacing-s);
}
.form {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
}
.expirationSection {
display: flex;
flex-direction: row;
align-items: flex-end;
gap: var(--spacing-xs);
}
.footer {
display: flex;
flex-direction: row-reverse;
justify-content: space-between;
align-items: center;
}
</style>

View File

@@ -0,0 +1,108 @@
<script lang="ts" setup>
import { useAssistantStore } from '@/stores/assistant.store';
import { useDebounce } from '@/composables/useDebounce';
import { useUsersStore } from '@/stores/users.store';
import { computed } from 'vue';
import SlideTransition from '@/components/transitions/SlideTransition.vue';
import AskAssistantChat from '@n8n/design-system/components/AskAssistantChat/AskAssistantChat.vue';
import { useTelemetry } from '@/composables/useTelemetry';
const assistantStore = useAssistantStore();
const usersStore = useUsersStore();
const telemetry = useTelemetry();
const user = computed(() => ({
firstName: usersStore.currentUser?.firstName ?? '',
lastName: usersStore.currentUser?.lastName ?? '',
}));
const loadingMessage = computed(() => assistantStore.assistantThinkingMessage);
function onResize(data: { direction: string; x: number; width: number }) {
assistantStore.updateWindowWidth(data.width);
}
function onResizeDebounced(data: { direction: string; x: number; width: number }) {
void useDebounce().callDebounced(onResize, { debounceTime: 10, trailing: true }, data);
}
async function onUserMessage(content: string, quickReplyType?: string, isFeedback = false) {
// If there is no current session running, initialize the support chat session
if (!assistantStore.currentSessionId) {
await assistantStore.initSupportChat(content);
} else {
await assistantStore.sendMessage({ text: content, quickReplyType });
}
const task = assistantStore.chatSessionTask;
const solutionCount = assistantStore.chatMessages.filter(
(msg) => msg.role === 'assistant' && !['text', 'event'].includes(msg.type),
).length;
if (isFeedback) {
telemetry.track('User gave feedback', {
task,
chat_session_id: assistantStore.currentSessionId,
is_quick_reply: !!quickReplyType,
is_positive: quickReplyType === 'all-good',
solution_count: solutionCount,
response: content,
});
}
}
async function onCodeReplace(index: number) {
await assistantStore.applyCodeDiff(index);
telemetry.track('User clicked solution card action', {
action: 'replace_code',
});
}
async function undoCodeDiff(index: number) {
await assistantStore.undoCodeDiff(index);
telemetry.track('User clicked solution card action', {
action: 'undo_code_replace',
});
}
function onClose() {
assistantStore.closeChat();
telemetry.track('User closed assistant', { source: 'top-toggle' });
}
</script>
<template>
<SlideTransition>
<N8nResizeWrapper
v-show="assistantStore.isAssistantOpen"
:supported-directions="['left']"
:width="assistantStore.chatWidth"
data-test-id="ask-assistant-sidebar"
@resize="onResizeDebounced"
>
<div
:style="{ width: `${assistantStore.chatWidth}px` }"
:class="$style.wrapper"
data-test-id="ask-assistant-chat"
tabindex="0"
@keydown.stop
>
<AskAssistantChat
:user="user"
:messages="assistantStore.chatMessages"
:streaming="assistantStore.streaming"
:loading-message="loadingMessage"
:session-id="assistantStore.currentSessionId"
@close="onClose"
@message="onUserMessage"
@code-replace="onCodeReplace"
@code-undo="undoCodeDiff"
/>
</div>
</N8nResizeWrapper>
</SlideTransition>
</template>
<style module>
.wrapper {
height: 100%;
}
</style>

View File

@@ -0,0 +1,95 @@
<script setup lang="ts">
import { useI18n } from '@/composables/useI18n';
import { useStyles } from '@/composables/useStyles';
import { useAssistantStore } from '@/stores/assistant.store';
import { useCanvasStore } from '@/stores/canvas.store';
import AssistantAvatar from '@n8n/design-system/components/AskAssistantAvatar/AssistantAvatar.vue';
import AskAssistantButton from '@n8n/design-system/components/AskAssistantButton/AskAssistantButton.vue';
import { computed } from 'vue';
const assistantStore = useAssistantStore();
const i18n = useI18n();
const { APP_Z_INDEXES } = useStyles();
const canvasStore = useCanvasStore();
const lastUnread = computed(() => {
const msg = assistantStore.lastUnread;
if (msg?.type === 'block') {
return msg.title;
}
if (msg?.type === 'text') {
return msg.content;
}
if (msg?.type === 'code-diff') {
return msg.description;
}
return '';
});
const onClick = () => {
assistantStore.openChat();
assistantStore.trackUserOpenedAssistant({
source: 'canvas',
task: 'placeholder',
has_existing_session: !assistantStore.isSessionEnded,
});
};
</script>
<template>
<div
v-if="assistantStore.canShowAssistantButtonsOnCanvas && !assistantStore.isAssistantOpen"
:class="$style.container"
data-test-id="ask-assistant-floating-button"
:style="{ '--canvas-panel-height-offset': `${canvasStore.panelHeight}px` }"
>
<n8n-tooltip
:z-index="APP_Z_INDEXES.ASK_ASSISTANT_FLOATING_BUTTON_TOOLTIP"
placement="top"
:visible="!!lastUnread"
:popper-class="$style.tooltip"
>
<template #content>
<div :class="$style.text">{{ lastUnread }}</div>
<div :class="$style.assistant">
<AssistantAvatar size="mini" />
<span>{{ i18n.baseText('aiAssistant.name') }}</span>
</div>
</template>
<AskAssistantButton :unread-count="assistantStore.unreadCount" @click="onClick" />
</n8n-tooltip>
</div>
</template>
<style lang="scss" module>
.container {
position: absolute;
bottom: calc(var(--canvas-panel-height-offset, 0px) + var(--spacing-s));
right: var(--spacing-s);
z-index: var(--z-index-ask-assistant-floating-button);
}
.tooltip {
min-width: 150px;
max-width: 265px !important;
line-height: normal;
}
.assistant {
font-size: var(--font-size-3xs);
line-height: var(--spacing-s);
font-weight: var(--font-weight-bold);
margin-top: var(--spacing-xs);
> span {
margin-left: var(--spacing-4xs);
}
}
.text {
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2; /* number of lines to show */
line-clamp: 2;
-webkit-box-orient: vertical;
}
</style>

View File

@@ -0,0 +1,98 @@
<script lang="ts" setup>
import { NEW_ASSISTANT_SESSION_MODAL } from '@/constants';
import Modal from '../Modal.vue';
import AssistantIcon from '@n8n/design-system/components/AskAssistantIcon/AssistantIcon.vue';
import AssistantText from '@n8n/design-system/components/AskAssistantText/AssistantText.vue';
import { useI18n } from '@/composables/useI18n';
import { useUIStore } from '@/stores/ui.store';
import type { ChatRequest } from '@/types/assistant.types';
import { useAssistantStore } from '@/stores/assistant.store';
import type { ICredentialType } from 'n8n-workflow';
const i18n = useI18n();
const uiStore = useUIStore();
const assistantStore = useAssistantStore();
const props = defineProps<{
name: string;
data: {
context: { errorHelp: ChatRequest.ErrorContext } | { credHelp: { credType: ICredentialType } };
};
}>();
const close = () => {
uiStore.closeModal(NEW_ASSISTANT_SESSION_MODAL);
};
const startNewSession = async () => {
if ('errorHelp' in props.data.context) {
await assistantStore.initErrorHelper(props.data.context.errorHelp);
assistantStore.trackUserOpenedAssistant({
source: 'error',
task: 'error',
has_existing_session: true,
});
} else if ('credHelp' in props.data.context) {
await assistantStore.initCredHelp(props.data.context.credHelp.credType);
}
close();
};
</script>
<template>
<Modal
width="460px"
height="250px"
data-test-id="new-assistant-session-modal"
:name="NEW_ASSISTANT_SESSION_MODAL"
:center="true"
:append-to-body="true"
>
<template #header>
{{ i18n.baseText('aiAssistant.newSessionModal.title.part1') }}
<span :class="$style.assistantIcon"><AssistantIcon size="medium" /></span>
<AssistantText size="xlarge" :text="i18n.baseText('aiAssistant.assistant')" />
{{ i18n.baseText('aiAssistant.newSessionModal.title.part2') }}
</template>
<template #content>
<div :class="$style.container">
<p>
<n8n-text>{{ i18n.baseText('aiAssistant.newSessionModal.message') }}</n8n-text>
</p>
<p>
<n8n-text>{{ i18n.baseText('aiAssistant.newSessionModal.question') }}</n8n-text>
</p>
</div>
</template>
<template #footer>
<div :class="$style.footer">
<n8n-button :label="i18n.baseText('generic.cancel')" type="secondary" @click="close" />
<n8n-button
:label="i18n.baseText('aiAssistant.newSessionModal.confirm')"
@click="startNewSession"
/>
</div>
</template>
</Modal>
</template>
<style lang="scss" module>
.container {
p {
line-height: normal;
}
p + p {
margin-top: 10px;
}
}
.assistantIcon {
margin-right: var(--spacing-4xs);
}
.footer {
display: flex;
gap: 10px;
justify-content: flex-end;
}
</style>

View File

@@ -0,0 +1,65 @@
import { createComponentRenderer } from '@/__tests__/render';
import { createTestingPinia } from '@pinia/testing';
import userEvent from '@testing-library/user-event';
import Assignment from './Assignment.vue';
import { defaultSettings } from '@/__tests__/defaults';
import { STORES } from '@/constants';
import { merge } from 'lodash-es';
import { cleanupAppModals, createAppModals } from '@/__tests__/utils';
const DEFAULT_SETUP = {
pinia: createTestingPinia({
initialState: { [STORES.SETTINGS]: { settings: merge({}, defaultSettings) } },
}),
props: {
path: 'parameters.fields.0',
modelValue: {
name: '',
type: 'string',
value: '',
},
issues: [],
},
};
const renderComponent = createComponentRenderer(Assignment, DEFAULT_SETUP);
describe('Assignment.vue', () => {
beforeEach(() => {
createAppModals();
});
afterEach(() => {
cleanupAppModals();
vi.clearAllMocks();
});
it('can edit name, type and value', async () => {
const { getByTestId, baseElement, emitted } = renderComponent();
const nameField = getByTestId('assignment-name').querySelector('input') as HTMLInputElement;
const valueField = getByTestId('assignment-value').querySelector('input') as HTMLInputElement;
expect(getByTestId('assignment')).toBeInTheDocument();
expect(getByTestId('assignment-name')).toBeInTheDocument();
expect(getByTestId('assignment-value')).toBeInTheDocument();
expect(getByTestId('assignment-type-select')).toBeInTheDocument();
await userEvent.type(nameField, 'New name');
await userEvent.type(valueField, 'New value');
await userEvent.click(baseElement.querySelectorAll('.option')[3]);
expect(emitted('update:model-value')[0]).toEqual([
{ name: 'New name', type: 'array', value: 'New value' },
]);
});
it('can remove itself', async () => {
const { getByTestId, emitted } = renderComponent();
await userEvent.click(getByTestId('assignment-remove'));
expect(emitted('remove')).toEqual([[]]);
});
});

View File

@@ -0,0 +1,274 @@
<script setup lang="ts">
import type { IUpdateInformation } from '@/Interface';
import InputTriple from '@/components/InputTriple/InputTriple.vue';
import ParameterInputFull from '@/components/ParameterInputFull.vue';
import ParameterInputHint from '@/components/ParameterInputHint.vue';
import ParameterIssues from '@/components/ParameterIssues.vue';
import { useResolvedExpression } from '@/composables/useResolvedExpression';
import useEnvironmentsStore from '@/stores/environments.ee.store';
import { useNDVStore } from '@/stores/ndv.store';
import type { AssignmentValue, INodeProperties } from 'n8n-workflow';
import { computed, ref } from 'vue';
import TypeSelect from './TypeSelect.vue';
import { N8nIconButton } from '@n8n/design-system';
interface Props {
path: string;
modelValue: AssignmentValue;
issues: string[];
hideType?: boolean;
isReadOnly?: boolean;
index?: number;
}
const props = defineProps<Props>();
const assignment = ref<AssignmentValue>(props.modelValue);
const emit = defineEmits<{
'update:model-value': [value: AssignmentValue];
remove: [];
}>();
const ndvStore = useNDVStore();
const environmentsStore = useEnvironmentsStore();
const assignmentTypeToNodeProperty = (
type: string,
): Partial<INodeProperties> & Pick<INodeProperties, 'type'> => {
switch (type) {
case 'boolean':
return {
type: 'options',
default: false,
options: [
{ name: 'false', value: false },
{ name: 'true', value: true },
],
};
case 'array':
case 'object':
case 'any':
return { type: 'string' };
default:
return { type } as INodeProperties;
}
};
const nameParameter = computed<INodeProperties>(() => ({
name: 'name',
displayName: 'Name',
default: '',
requiresDataPath: 'single',
placeholder: 'name',
type: 'string',
}));
const valueParameter = computed<INodeProperties>(() => {
return {
name: 'value',
displayName: 'Value',
default: '',
placeholder: 'value',
...assignmentTypeToNodeProperty(assignment.value.type ?? 'string'),
};
});
const value = computed(() => assignment.value.value);
const resolvedAdditionalExpressionData = computed(() => {
return { $vars: environmentsStore.variablesAsObject };
});
const { resolvedExpressionString, isExpression } = useResolvedExpression({
expression: value,
additionalData: resolvedAdditionalExpressionData,
});
const hint = computed(() => resolvedExpressionString.value);
const highlightHint = computed(() => Boolean(hint.value && ndvStore.getHoveringItem));
const onAssignmentNameChange = (update: IUpdateInformation): void => {
assignment.value.name = update.value as string;
};
const onAssignmentTypeChange = (update: string): void => {
assignment.value.type = update;
if (update === 'boolean' && !isExpression.value) {
assignment.value.value = false;
}
};
const onAssignmentValueChange = (update: IUpdateInformation): void => {
assignment.value.value = update.value as string;
};
const onRemove = (): void => {
emit('remove');
};
const onBlur = (): void => {
emit('update:model-value', assignment.value);
};
</script>
<template>
<div
:class="{
[$style.wrapper]: true,
[$style.hasIssues]: issues.length > 0,
[$style.hasHint]: !!hint,
}"
data-test-id="assignment"
>
<N8nIconButton
v-if="!isReadOnly"
type="tertiary"
text
size="mini"
icon="grip-vertical"
:class="[$style.iconButton, $style.defaultTopPadding, 'drag-handle']"
></N8nIconButton>
<N8nIconButton
v-if="!isReadOnly"
type="tertiary"
text
size="mini"
icon="trash"
data-test-id="assignment-remove"
:class="[$style.iconButton, $style.extraTopPadding]"
@click="onRemove"
></N8nIconButton>
<div :class="$style.inputs">
<InputTriple middle-width="100px">
<template #left>
<ParameterInputFull
:key="nameParameter.type"
display-options
hide-label
hide-hint
:is-read-only="isReadOnly"
:parameter="nameParameter"
:value="assignment.name"
:path="`${path}.name`"
data-test-id="assignment-name"
@update="onAssignmentNameChange"
@blur="onBlur"
/>
</template>
<template v-if="!hideType" #middle>
<TypeSelect
:class="$style.select"
:model-value="assignment.type ?? 'string'"
:is-read-only="isReadOnly"
@update:model-value="onAssignmentTypeChange"
>
</TypeSelect>
</template>
<template #right="{ breakpoint }">
<div :class="$style.value">
<ParameterInputFull
:key="valueParameter.type"
display-options
hide-label
hide-issues
hide-hint
is-assignment
:is-read-only="isReadOnly"
:options-position="breakpoint === 'default' ? 'top' : 'bottom'"
:parameter="valueParameter"
:value="assignment.value"
:path="`${path}.value`"
data-test-id="assignment-value"
@update="onAssignmentValueChange"
@blur="onBlur"
/>
<ParameterInputHint
data-test-id="parameter-expression-preview-value"
:class="$style.hint"
:highlight="highlightHint"
:hint="hint"
single-line
/>
</div>
</template>
</InputTriple>
</div>
<div :class="$style.status">
<ParameterIssues v-if="issues.length > 0" :issues="issues" />
</div>
</div>
</template>
<style lang="scss" module>
.wrapper {
position: relative;
display: flex;
align-items: flex-end;
gap: var(--spacing-4xs);
&.hasIssues {
--input-border-color: var(--color-danger);
}
&.hasHint {
padding-bottom: var(--spacing-s);
}
&:hover {
.iconButton {
opacity: 1;
}
}
}
.inputs {
display: flex;
flex-direction: column;
flex-grow: 1;
min-width: 0;
> div {
flex-grow: 1;
}
}
.value {
position: relative;
.hint {
position: absolute;
bottom: calc(var(--spacing-s) * -1);
left: 0;
right: 0;
}
}
.iconButton {
position: absolute;
left: 0;
opacity: 0;
transition: opacity 100ms ease-in;
color: var(--icon-base-color);
}
.extraTopPadding {
top: calc(20px + var(--spacing-l));
}
.defaultTopPadding {
top: var(--spacing-l);
}
.status {
align-self: flex-start;
padding-top: 28px;
}
.statusIcon {
padding-left: var(--spacing-4xs);
}
</style>

View File

@@ -0,0 +1,154 @@
import { createComponentRenderer } from '@/__tests__/render';
import { useNDVStore } from '@/stores/ndv.store';
import { createTestingPinia } from '@pinia/testing';
import userEvent from '@testing-library/user-event';
import { fireEvent, within } from '@testing-library/vue';
import * as workflowHelpers from '@/composables/useWorkflowHelpers';
import AssignmentCollection from './AssignmentCollection.vue';
import { STORES } from '@/constants';
import { cleanupAppModals, createAppModals, SETTINGS_STORE_DEFAULT_STATE } from '@/__tests__/utils';
const DEFAULT_SETUP = {
pinia: createTestingPinia({
initialState: {
[STORES.SETTINGS]: SETTINGS_STORE_DEFAULT_STATE,
},
stubActions: false,
}),
props: {
path: 'parameters.fields',
node: {
parameters: {},
id: 'f63efb2d-3cc5-4500-89f9-b39aab19baf5',
name: 'Edit Fields',
type: 'n8n-nodes-base.set',
typeVersion: 3.3,
position: [1120, 380],
credentials: {},
disabled: false,
},
parameter: { name: 'fields', displayName: 'Fields To Set' },
value: {},
},
};
const renderComponent = createComponentRenderer(AssignmentCollection, DEFAULT_SETUP);
const getInput = (e: HTMLElement): HTMLInputElement => {
return e.querySelector('input') as HTMLInputElement;
};
const getAssignmentType = (assignment: HTMLElement): string => {
return getInput(within(assignment).getByTestId('assignment-type-select')).value;
};
async function dropAssignment({
key,
value,
dropArea,
}: {
key: string;
value: unknown;
dropArea: HTMLElement;
}): Promise<void> {
useNDVStore().draggableStartDragging({
type: 'mapping',
data: `{{ $json.${key} }}`,
dimensions: null,
});
vitest.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce(value as never);
await userEvent.hover(dropArea);
await fireEvent.mouseUp(dropArea);
}
describe('AssignmentCollection.vue', () => {
beforeEach(() => {
createAppModals();
});
afterEach(() => {
vi.clearAllMocks();
cleanupAppModals();
});
it('renders empty state properly', async () => {
const { getByTestId, queryByTestId } = renderComponent();
expect(getByTestId('assignment-collection-fields')).toBeInTheDocument();
expect(getByTestId('assignment-collection-fields')).toHaveClass('empty');
expect(getByTestId('assignment-collection-drop-area')).toHaveTextContent(
'Drag input fields here',
);
expect(queryByTestId('assignment')).not.toBeInTheDocument();
});
it('can add and remove assignments', async () => {
const { getByTestId, findAllByTestId } = renderComponent();
await userEvent.click(getByTestId('assignment-collection-drop-area'));
await userEvent.click(getByTestId('assignment-collection-drop-area'));
let assignments = await findAllByTestId('assignment');
expect(assignments.length).toEqual(2);
await userEvent.type(getInput(within(assignments[1]).getByTestId('assignment-name')), 'second');
await userEvent.type(
getInput(within(assignments[1]).getByTestId('assignment-value')),
'secondValue',
);
await userEvent.click(within(assignments[0]).getByTestId('assignment-remove'));
assignments = await findAllByTestId('assignment');
expect(assignments.length).toEqual(1);
expect(getInput(within(assignments[0]).getByTestId('assignment-value'))).toHaveValue(
'secondValue',
);
});
it('does not break with saved assignments that have no ID (legacy)', async () => {
const { findAllByTestId } = renderComponent({
props: {
value: {
assignments: [
{ name: 'key1', value: 'value1', type: 'string' },
{ name: 'key2', value: 'value2', type: 'string' },
{ name: 'key3', value: 'value3', type: 'string' },
],
},
},
});
let assignments = await findAllByTestId('assignment');
expect(assignments.length).toEqual(3);
// Remove 2nd assignment
await userEvent.click(within(assignments[1]).getByTestId('assignment-remove'));
assignments = await findAllByTestId('assignment');
expect(assignments.length).toEqual(2);
expect(getInput(within(assignments[0]).getByTestId('assignment-value'))).toHaveValue('value1');
expect(getInput(within(assignments[1]).getByTestId('assignment-value'))).toHaveValue('value3');
});
it('can add assignments by drag and drop (and infer type)', async () => {
const { getByTestId, findAllByTestId } = renderComponent();
const dropArea = getByTestId('drop-area');
await dropAssignment({ key: 'boolKey', value: true, dropArea });
await dropAssignment({ key: 'stringKey', value: 'stringValue', dropArea });
await dropAssignment({ key: 'numberKey', value: 25, dropArea });
await dropAssignment({ key: 'objectKey', value: {}, dropArea });
await dropAssignment({ key: 'arrayKey', value: [], dropArea });
const assignments = await findAllByTestId('assignment');
expect(assignments.length).toBe(5);
expect(getAssignmentType(assignments[0])).toEqual('Boolean');
expect(getAssignmentType(assignments[1])).toEqual('String');
expect(getAssignmentType(assignments[2])).toEqual('Number');
expect(getAssignmentType(assignments[3])).toEqual('Object');
expect(getAssignmentType(assignments[4])).toEqual('Array');
});
});

View File

@@ -0,0 +1,299 @@
<script setup lang="ts">
import { useDebounce } from '@/composables/useDebounce';
import { useI18n } from '@/composables/useI18n';
import { useNDVStore } from '@/stores/ndv.store';
import type {
AssignmentCollectionValue,
AssignmentValue,
INode,
INodeProperties,
} from 'n8n-workflow';
import { computed, reactive, watch } from 'vue';
import DropArea from '../DropArea/DropArea.vue';
import ParameterOptions from '../ParameterOptions.vue';
import Assignment from './Assignment.vue';
import { inputDataToAssignments, typeFromExpression } from './utils';
import { propertyNameFromExpression } from '@/utils/mappingUtils';
import Draggable from 'vuedraggable';
interface Props {
parameter: INodeProperties;
value: AssignmentCollectionValue;
path: string;
node: INode | null;
isReadOnly?: boolean;
}
const props = withDefaults(defineProps<Props>(), { isReadOnly: false });
const emit = defineEmits<{
valueChanged: [value: { name: string; node: string; value: AssignmentCollectionValue }];
}>();
const i18n = useI18n();
const state = reactive<{ paramValue: AssignmentCollectionValue }>({
paramValue: {
assignments:
props.value.assignments?.map((assignment) => {
if (!assignment.id) assignment.id = crypto.randomUUID();
return assignment;
}) ?? [],
},
});
const ndvStore = useNDVStore();
const { callDebounced } = useDebounce();
const issues = computed(() => {
if (!ndvStore.activeNode) return {};
return ndvStore.activeNode?.issues?.parameters ?? {};
});
const empty = computed(() => state.paramValue.assignments.length === 0);
const activeDragField = computed(() => propertyNameFromExpression(ndvStore.draggableData));
const inputData = computed(() => ndvStore.ndvInputData?.[0]?.json);
const actions = computed(() => {
return [
{
label: i18n.baseText('assignment.addAll'),
value: 'addAll',
disabled: !inputData.value,
},
{
label: i18n.baseText('assignment.clearAll'),
value: 'clearAll',
disabled: state.paramValue.assignments.length === 0,
},
];
});
watch(state.paramValue, (value) => {
void callDebounced(
() => {
emit('valueChanged', { name: props.path, value, node: props.node?.name as string });
},
{ debounceTime: 1000 },
);
});
function addAssignment(): void {
state.paramValue.assignments.push({
id: crypto.randomUUID(),
name: '',
value: '',
type: 'string',
});
}
function dropAssignment(expression: string): void {
state.paramValue.assignments.push({
id: crypto.randomUUID(),
name: propertyNameFromExpression(expression),
value: `=${expression}`,
type: typeFromExpression(expression),
});
}
function onAssignmentUpdate(index: number, value: AssignmentValue): void {
state.paramValue.assignments[index] = value;
}
function onAssignmentRemove(index: number): void {
state.paramValue.assignments.splice(index, 1);
}
function getIssues(index: number): string[] {
return issues.value[`${props.parameter.name}.${index}`] ?? [];
}
function optionSelected(action: string) {
if (action === 'clearAll') {
state.paramValue.assignments = [];
} else if (action === 'addAll' && inputData.value) {
const newAssignments = inputDataToAssignments(inputData.value);
state.paramValue.assignments = state.paramValue.assignments.concat(newAssignments);
}
}
</script>
<template>
<div
:class="{ [$style.assignmentCollection]: true, [$style.empty]: empty }"
:data-test-id="`assignment-collection-${parameter.name}`"
>
<n8n-input-label
:label="parameter.displayName"
:show-expression-selector="false"
size="small"
underline
color="text-dark"
>
<template #options>
<ParameterOptions
:parameter="parameter"
:value="value"
:custom-actions="actions"
:is-read-only="isReadOnly"
:show-expression-selector="false"
@update:model-value="optionSelected"
/>
</template>
</n8n-input-label>
<div :class="$style.content">
<div :class="$style.assignments">
<Draggable
v-model="state.paramValue.assignments"
item-key="id"
handle=".drag-handle"
:drag-class="$style.dragging"
:ghost-class="$style.ghost"
>
<template #item="{ index, element: assignment }">
<Assignment
:model-value="assignment"
:index="index"
:path="`${path}.assignments.${index}`"
:issues="getIssues(index)"
:class="$style.assignment"
:is-read-only="isReadOnly"
@update:model-value="(value) => onAssignmentUpdate(index, value)"
@remove="() => onAssignmentRemove(index)"
>
</Assignment>
</template>
</Draggable>
</div>
<div
v-if="!isReadOnly"
:class="$style.dropAreaWrapper"
data-test-id="assignment-collection-drop-area"
@click="addAssignment"
>
<DropArea :sticky-offset="empty ? [-4, 32] : [92, 0]" @drop="dropAssignment">
<template #default="{ active, droppable }">
<div :class="{ [$style.active]: active, [$style.droppable]: droppable }">
<div v-if="droppable" :class="$style.dropArea">
<span>{{ i18n.baseText('assignment.dropField') }}</span>
<span :class="$style.activeField">{{ activeDragField }}</span>
</div>
<div v-else :class="$style.dropArea">
<span>{{ i18n.baseText('assignment.dragFields') }}</span>
<span :class="$style.or">{{ i18n.baseText('assignment.or') }}</span>
<span :class="$style.add">{{ i18n.baseText('assignment.add') }} </span>
</div>
</div>
</template>
</DropArea>
</div>
</div>
</div>
</template>
<style lang="scss" module>
.assignmentCollection {
display: flex;
flex-direction: column;
margin: var(--spacing-xs) 0;
}
.content {
display: flex;
gap: var(--spacing-l);
flex-direction: column;
}
.assignments {
display: flex;
flex-direction: column;
gap: var(--spacing-4xs);
}
.assignment {
padding-left: var(--spacing-l);
}
.dropAreaWrapper {
cursor: pointer;
&:not(.empty .dropAreaWrapper) {
padding-left: var(--spacing-l);
}
&:hover .add {
color: var(--color-primary-shade-1);
}
}
.dropArea {
display: flex;
align-items: center;
flex-wrap: wrap;
justify-content: center;
font-size: var(--font-size-xs);
color: var(--color-text-dark);
gap: 1ch;
min-height: 24px;
> span {
white-space: nowrap;
}
}
.or {
color: var(--color-text-light);
font-size: var(--font-size-2xs);
}
.add {
color: var(--color-primary);
font-weight: var(--font-weight-bold);
}
.activeField {
font-weight: var(--font-weight-bold);
color: var(--color-ndv-droppable-parameter);
}
.active {
.activeField {
color: var(--color-success);
}
}
.empty {
.dropArea {
flex-direction: column;
align-items: center;
gap: var(--spacing-3xs);
min-height: 20vh;
}
.droppable .dropArea {
flex-direction: row;
gap: 1ch;
}
.content {
gap: var(--spacing-s);
}
}
.icon {
font-size: var(--font-size-2xl);
}
.ghost,
.dragging {
border-radius: var(--border-radius-base);
padding-right: var(--spacing-xs);
padding-bottom: var(--spacing-xs);
}
.ghost {
background-color: var(--color-background-base);
opacity: 0.5;
}
.dragging {
background-color: var(--color-background-xlight);
opacity: 0.7;
}
</style>

View File

@@ -0,0 +1,41 @@
import { createComponentRenderer } from '@/__tests__/render';
import { createTestingPinia } from '@pinia/testing';
import userEvent from '@testing-library/user-event';
import TypeSelect from './TypeSelect.vue';
const DEFAULT_SETUP = {
pinia: createTestingPinia(),
props: {
modelValue: 'boolean',
},
};
const renderComponent = createComponentRenderer(TypeSelect, DEFAULT_SETUP);
describe('TypeSelect.vue', () => {
afterEach(() => {
vi.clearAllMocks();
});
it('renders default state correctly and emit events', async () => {
const { getByTestId, baseElement, emitted } = renderComponent();
expect(getByTestId('assignment-type-select')).toBeInTheDocument();
await userEvent.click(
getByTestId('assignment-type-select').querySelector('.select-trigger') as HTMLElement,
);
const options = baseElement.querySelectorAll('.option');
expect(options.length).toEqual(5);
expect(options[0]).toHaveTextContent('String');
expect(options[1]).toHaveTextContent('Number');
expect(options[2]).toHaveTextContent('Boolean');
expect(options[3]).toHaveTextContent('Array');
expect(options[4]).toHaveTextContent('Object');
await userEvent.click(options[2]);
expect(emitted('update:model-value')).toEqual([['boolean']]);
});
});

View File

@@ -0,0 +1,68 @@
<script setup lang="ts">
import { useI18n } from '@/composables/useI18n';
import type { BaseTextKey } from '@/plugins/i18n';
import { ASSIGNMENT_TYPES } from './constants';
import { computed } from 'vue';
interface Props {
modelValue: string;
isReadOnly?: boolean;
}
const props = defineProps<Props>();
const emit = defineEmits<{
'update:model-value': [type: string];
}>();
const i18n = useI18n();
const types = ASSIGNMENT_TYPES;
const icon = computed(() => types.find((type) => type.type === props.modelValue)?.icon ?? 'cube');
const onTypeChange = (type: string): void => {
emit('update:model-value', type);
};
</script>
<template>
<n8n-select
data-test-id="assignment-type-select"
size="small"
:model-value="modelValue"
:disabled="isReadOnly"
@update:model-value="onTypeChange"
>
<template #prefix>
<n8n-icon :class="$style.icon" :icon="icon" color="text-light" size="small" />
</template>
<n8n-option
v-for="option in types"
:key="option.type"
:value="option.type"
:label="i18n.baseText(`type.${option.type}` as BaseTextKey)"
:class="$style.option"
>
<n8n-icon
:icon="option.icon"
:color="modelValue === option.type ? 'primary' : 'text-light'"
size="small"
/>
<span>{{ i18n.baseText(`type.${option.type}` as BaseTextKey) }}</span>
</n8n-option>
</n8n-select>
</template>
<style lang="scss" module>
.icon {
color: var(--color-text-light);
}
.option {
display: flex;
gap: var(--spacing-2xs);
align-items: center;
font-size: var(--font-size-s);
}
</style>

View File

@@ -0,0 +1,7 @@
export const ASSIGNMENT_TYPES = [
{ type: 'string', icon: 'font' },
{ type: 'number', icon: 'hashtag' },
{ type: 'boolean', icon: 'check-square' },
{ type: 'array', icon: 'list' },
{ type: 'object', icon: 'cube' },
];

View File

@@ -0,0 +1,57 @@
import { isObject } from 'lodash-es';
import type { AssignmentValue, IDataObject } from 'n8n-workflow';
import { resolveParameter } from '@/composables/useWorkflowHelpers';
import { v4 as uuid } from 'uuid';
export function inferAssignmentType(value: unknown): string {
if (typeof value === 'boolean') return 'boolean';
if (typeof value === 'number') return 'number';
if (typeof value === 'string') return 'string';
if (Array.isArray(value)) return 'array';
if (isObject(value)) return 'object';
return 'string';
}
export function typeFromExpression(expression: string): string {
try {
const resolved = resolveParameter(`=${expression}`);
return inferAssignmentType(resolved);
} catch (error) {
return 'string';
}
}
export function inputDataToAssignments(input: IDataObject): AssignmentValue[] {
const assignments: AssignmentValue[] = [];
function processValue(value: IDataObject, path: Array<string | number> = []) {
if (Array.isArray(value)) {
value.forEach((element, index) => {
processValue(element, [...path, index]);
});
} else if (isObject(value)) {
for (const [key, objectValue] of Object.entries(value)) {
processValue(objectValue as IDataObject, [...path, key]);
}
} else {
const stringPath = path.reduce((fullPath: string, part) => {
if (typeof part === 'number') {
return `${fullPath}[${part}]`;
}
return `${fullPath}.${part}`;
}, '$json');
const expression = `={{ ${stringPath} }}`;
assignments.push({
id: uuid(),
name: stringPath.replace('$json.', ''),
value: expression,
type: inferAssignmentType(value),
});
}
}
processValue(input);
return assignments;
}

Some files were not shown because too many files have changed in this diff Show More