mirror of
https://github.com/n8n-io/n8n.git
synced 2025-03-05 20:50:17 -08:00
Merge branch 'master' of https://github.com/n8n-io/n8n into executions-count-component
This commit is contained in:
commit
29b1b25924
|
@ -90,6 +90,14 @@ function createRunDataWithError(inputMessage: string) {
|
||||||
routine: 'InitPostgres',
|
routine: 'InitPostgres',
|
||||||
} as unknown as Error,
|
} as unknown as Error,
|
||||||
} as ExecutionError,
|
} as ExecutionError,
|
||||||
|
metadata: {
|
||||||
|
subRun: [
|
||||||
|
{
|
||||||
|
node: 'Postgres Chat Memory',
|
||||||
|
runIndex: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
createMockNodeExecutionData(AGENT_NODE_NAME, {
|
createMockNodeExecutionData(AGENT_NODE_NAME, {
|
||||||
executionStatus: 'error',
|
executionStatus: 'error',
|
||||||
|
@ -124,14 +132,6 @@ function createRunDataWithError(inputMessage: string) {
|
||||||
description: 'Internal error',
|
description: 'Internal error',
|
||||||
message: 'Internal error',
|
message: 'Internal error',
|
||||||
} as unknown as ExecutionError,
|
} as unknown as ExecutionError,
|
||||||
metadata: {
|
|
||||||
subRun: [
|
|
||||||
{
|
|
||||||
node: 'Postgres Chat Memory',
|
|
||||||
runIndex: 0,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
|
@ -278,6 +278,9 @@ describe('Langchain Integration', () => {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
metadata: {
|
||||||
|
subRun: [{ node: AI_LANGUAGE_MODEL_OPENAI_CHAT_MODEL_NODE_NAME, runIndex: 0 }],
|
||||||
|
},
|
||||||
inputOverride: {
|
inputOverride: {
|
||||||
ai_languageModel: [
|
ai_languageModel: [
|
||||||
[
|
[
|
||||||
|
@ -316,9 +319,6 @@ describe('Langchain Integration', () => {
|
||||||
jsonData: {
|
jsonData: {
|
||||||
main: { output: 'Hi there! How can I assist you today?' },
|
main: { output: 'Hi there! How can I assist you today?' },
|
||||||
},
|
},
|
||||||
metadata: {
|
|
||||||
subRun: [{ node: AI_LANGUAGE_MODEL_OPENAI_CHAT_MODEL_NODE_NAME, runIndex: 0 }],
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
lastNodeExecuted: AGENT_NODE_NAME,
|
lastNodeExecuted: AGENT_NODE_NAME,
|
||||||
|
|
|
@ -20,6 +20,10 @@ const modelField: INodeProperties = {
|
||||||
type: 'options',
|
type: 'options',
|
||||||
// eslint-disable-next-line n8n-nodes-base/node-param-options-type-unsorted-items
|
// eslint-disable-next-line n8n-nodes-base/node-param-options-type-unsorted-items
|
||||||
options: [
|
options: [
|
||||||
|
{
|
||||||
|
name: 'Claude 3.5 Sonnet(20241022)',
|
||||||
|
value: 'claude-3-5-sonnet-20241022',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'Claude 3 Opus(20240229)',
|
name: 'Claude 3 Opus(20240229)',
|
||||||
value: 'claude-3-opus-20240229',
|
value: 'claude-3-opus-20240229',
|
||||||
|
|
|
@ -63,7 +63,7 @@ export class ToolVectorStore implements INodeType {
|
||||||
name: 'name',
|
name: 'name',
|
||||||
type: 'string',
|
type: 'string',
|
||||||
default: '',
|
default: '',
|
||||||
placeholder: 'e.g. state_of_union_address',
|
placeholder: 'e.g. company_knowledge_base',
|
||||||
validateType: 'string-alphanumeric',
|
validateType: 'string-alphanumeric',
|
||||||
description: 'Name of the vector store',
|
description: 'Name of the vector store',
|
||||||
},
|
},
|
||||||
|
@ -72,7 +72,7 @@ export class ToolVectorStore implements INodeType {
|
||||||
name: 'description',
|
name: 'description',
|
||||||
type: 'string',
|
type: 'string',
|
||||||
default: '',
|
default: '',
|
||||||
placeholder: 'The most recent state of the Union address',
|
placeholder: 'Retrieves data about [insert information about your data here]...',
|
||||||
typeOptions: {
|
typeOptions: {
|
||||||
rows: 3,
|
rows: 3,
|
||||||
},
|
},
|
||||||
|
|
|
@ -65,6 +65,42 @@ describe('Publisher', () => {
|
||||||
JSON.stringify({ ...msg, senderId: hostId, selfSend: false, debounce: true }),
|
JSON.stringify({ ...msg, senderId: hostId, selfSend: false, debounce: true }),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should not debounce `add-webhooks-triggers-and-pollers`', async () => {
|
||||||
|
const publisher = new Publisher(logger, redisClientService, instanceSettings);
|
||||||
|
const msg = mock<PubSub.Command>({ command: 'add-webhooks-triggers-and-pollers' });
|
||||||
|
|
||||||
|
await publisher.publishCommand(msg);
|
||||||
|
|
||||||
|
expect(client.publish).toHaveBeenCalledWith(
|
||||||
|
'n8n.commands',
|
||||||
|
JSON.stringify({
|
||||||
|
...msg,
|
||||||
|
_isMockObject: true,
|
||||||
|
senderId: hostId,
|
||||||
|
selfSend: true,
|
||||||
|
debounce: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not debounce `remove-triggers-and-pollers`', async () => {
|
||||||
|
const publisher = new Publisher(logger, redisClientService, instanceSettings);
|
||||||
|
const msg = mock<PubSub.Command>({ command: 'remove-triggers-and-pollers' });
|
||||||
|
|
||||||
|
await publisher.publishCommand(msg);
|
||||||
|
|
||||||
|
expect(client.publish).toHaveBeenCalledWith(
|
||||||
|
'n8n.commands',
|
||||||
|
JSON.stringify({
|
||||||
|
...msg,
|
||||||
|
_isMockObject: true,
|
||||||
|
senderId: hostId,
|
||||||
|
selfSend: true,
|
||||||
|
debounce: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('publishWorkerResponse', () => {
|
describe('publishWorkerResponse', () => {
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
import type { PubSub } from './pubsub/pubsub.types';
|
||||||
|
|
||||||
export const QUEUE_NAME = 'jobs';
|
export const QUEUE_NAME = 'jobs';
|
||||||
|
|
||||||
export const JOB_TYPE_NAME = 'job';
|
export const JOB_TYPE_NAME = 'job';
|
||||||
|
@ -11,7 +13,7 @@ export const WORKER_RESPONSE_PUBSUB_CHANNEL = 'n8n.worker-response';
|
||||||
/**
|
/**
|
||||||
* Commands that should be sent to the sender as well, e.g. during workflow activation and
|
* Commands that should be sent to the sender as well, e.g. during workflow activation and
|
||||||
* deactivation in multi-main setup. */
|
* deactivation in multi-main setup. */
|
||||||
export const SELF_SEND_COMMANDS = new Set([
|
export const SELF_SEND_COMMANDS = new Set<PubSub.Command['command']>([
|
||||||
'add-webhooks-triggers-and-pollers',
|
'add-webhooks-triggers-and-pollers',
|
||||||
'remove-triggers-and-pollers',
|
'remove-triggers-and-pollers',
|
||||||
]);
|
]);
|
||||||
|
@ -20,7 +22,8 @@ export const SELF_SEND_COMMANDS = new Set([
|
||||||
* Commands that should not be debounced when received, e.g. during webhook handling in
|
* Commands that should not be debounced when received, e.g. during webhook handling in
|
||||||
* multi-main setup.
|
* multi-main setup.
|
||||||
*/
|
*/
|
||||||
export const IMMEDIATE_COMMANDS = new Set([
|
export const IMMEDIATE_COMMANDS = new Set<PubSub.Command['command']>([
|
||||||
'add-webhooks-triggers-and-pollers',
|
'add-webhooks-triggers-and-pollers',
|
||||||
|
'remove-triggers-and-pollers',
|
||||||
'relay-execution-lifecycle-event',
|
'relay-execution-lifecycle-event',
|
||||||
]);
|
]);
|
||||||
|
|
|
@ -29,6 +29,7 @@ import CategorizedItemsRenderer from '../Renderers/CategorizedItemsRenderer.vue'
|
||||||
import type { IDataObject } from 'n8n-workflow';
|
import type { IDataObject } from 'n8n-workflow';
|
||||||
import { useTelemetry } from '@/composables/useTelemetry';
|
import { useTelemetry } from '@/composables/useTelemetry';
|
||||||
import { useI18n } from '@/composables/useI18n';
|
import { useI18n } from '@/composables/useI18n';
|
||||||
|
import { useNodeCreatorStore } from '@/stores/nodeCreator.store';
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
nodeTypeSelected: [value: [actionKey: string, nodeName: string] | [nodeName: string]];
|
nodeTypeSelected: [value: [actionKey: string, nodeName: string] | [nodeName: string]];
|
||||||
|
@ -47,6 +48,8 @@ const {
|
||||||
actionsCategoryLocales,
|
actionsCategoryLocales,
|
||||||
} = useActions();
|
} = useActions();
|
||||||
|
|
||||||
|
const nodeCreatorStore = useNodeCreatorStore();
|
||||||
|
|
||||||
// We only inject labels if search is empty
|
// We only inject labels if search is empty
|
||||||
const parsedTriggerActions = computed(() =>
|
const parsedTriggerActions = computed(() =>
|
||||||
parseActions(actions.value, actionsCategoryLocales.value.triggers, false),
|
parseActions(actions.value, actionsCategoryLocales.value.triggers, false),
|
||||||
|
@ -182,7 +185,7 @@ function trackActionsView() {
|
||||||
};
|
};
|
||||||
|
|
||||||
void useExternalHooks().run('nodeCreateList.onViewActions', trackingPayload);
|
void useExternalHooks().run('nodeCreateList.onViewActions', trackingPayload);
|
||||||
telemetry?.trackNodesPanel('nodeCreateList.onViewActions', trackingPayload);
|
nodeCreatorStore.onViewActions(trackingPayload);
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetSearch() {
|
function resetSearch() {
|
||||||
|
@ -206,7 +209,7 @@ function addHttpNode() {
|
||||||
void useExternalHooks().run('nodeCreateList.onActionsCustmAPIClicked', {
|
void useExternalHooks().run('nodeCreateList.onActionsCustmAPIClicked', {
|
||||||
app_identifier,
|
app_identifier,
|
||||||
});
|
});
|
||||||
telemetry?.trackNodesPanel('nodeCreateList.onActionsCustmAPIClicked', { app_identifier });
|
nodeCreatorStore.onActionsCustomAPIClicked({ app_identifier });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Anonymous component to handle triggers and actions rendering order
|
// Anonymous component to handle triggers and actions rendering order
|
||||||
|
|
|
@ -23,7 +23,6 @@ import ItemsRenderer from '../Renderers/ItemsRenderer.vue';
|
||||||
import CategorizedItemsRenderer from '../Renderers/CategorizedItemsRenderer.vue';
|
import CategorizedItemsRenderer from '../Renderers/CategorizedItemsRenderer.vue';
|
||||||
import NoResults from '../Panel/NoResults.vue';
|
import NoResults from '../Panel/NoResults.vue';
|
||||||
import { useI18n } from '@/composables/useI18n';
|
import { useI18n } from '@/composables/useI18n';
|
||||||
import { useTelemetry } from '@/composables/useTelemetry';
|
|
||||||
import { getNodeIcon, getNodeIconColor, getNodeIconUrl } from '@/utils/nodeTypesUtils';
|
import { getNodeIcon, getNodeIconColor, getNodeIconUrl } from '@/utils/nodeTypesUtils';
|
||||||
import { useUIStore } from '@/stores/ui.store';
|
import { useUIStore } from '@/stores/ui.store';
|
||||||
|
|
||||||
|
@ -36,11 +35,10 @@ const emit = defineEmits<{
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const i18n = useI18n();
|
const i18n = useI18n();
|
||||||
const telemetry = useTelemetry();
|
|
||||||
const uiStore = useUIStore();
|
const uiStore = useUIStore();
|
||||||
const rootStore = useRootStore();
|
const rootStore = useRootStore();
|
||||||
|
|
||||||
const { mergedNodes, actions } = useNodeCreatorStore();
|
const { mergedNodes, actions, onSubcategorySelected } = useNodeCreatorStore();
|
||||||
const { pushViewStack, popViewStack } = useViewStacks();
|
const { pushViewStack, popViewStack } = useViewStacks();
|
||||||
|
|
||||||
const { registerKeyHook } = useKeyboardNavigation();
|
const { registerKeyHook } = useKeyboardNavigation();
|
||||||
|
@ -83,7 +81,7 @@ function onSelected(item: INodeCreateElement) {
|
||||||
sections: item.properties.sections,
|
sections: item.properties.sections,
|
||||||
});
|
});
|
||||||
|
|
||||||
telemetry.trackNodesPanel('nodeCreateList.onSubcategorySelected', {
|
onSubcategorySelected({
|
||||||
subcategory: item.key,
|
subcategory: item.key,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -153,9 +151,6 @@ function onSelected(item: INodeCreateElement) {
|
||||||
|
|
||||||
if (item.type === 'link') {
|
if (item.type === 'link') {
|
||||||
window.open(item.properties.url, '_blank');
|
window.open(item.properties.url, '_blank');
|
||||||
telemetry.trackNodesPanel('nodeCreateList.onLinkSelected', {
|
|
||||||
link: item.properties.url,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -17,12 +17,15 @@ import SearchBar from './SearchBar.vue';
|
||||||
import ActionsRenderer from '../Modes/ActionsMode.vue';
|
import ActionsRenderer from '../Modes/ActionsMode.vue';
|
||||||
import NodesRenderer from '../Modes/NodesMode.vue';
|
import NodesRenderer from '../Modes/NodesMode.vue';
|
||||||
import { useI18n } from '@/composables/useI18n';
|
import { useI18n } from '@/composables/useI18n';
|
||||||
|
import { useDebounce } from '@/composables/useDebounce';
|
||||||
|
|
||||||
const i18n = useI18n();
|
const i18n = useI18n();
|
||||||
|
const { callDebounced } = useDebounce();
|
||||||
|
|
||||||
const { mergedNodes } = useNodeCreatorStore();
|
const { mergedNodes } = useNodeCreatorStore();
|
||||||
const { pushViewStack, popViewStack, updateCurrentViewStack } = useViewStacks();
|
const { pushViewStack, popViewStack, updateCurrentViewStack } = useViewStacks();
|
||||||
const { setActiveItemIndex, attachKeydownEvent, detachKeydownEvent } = useKeyboardNavigation();
|
const { setActiveItemIndex, attachKeydownEvent, detachKeydownEvent } = useKeyboardNavigation();
|
||||||
|
const nodeCreatorStore = useNodeCreatorStore();
|
||||||
|
|
||||||
const activeViewStack = computed(() => useViewStacks().activeViewStack);
|
const activeViewStack = computed(() => useViewStacks().activeViewStack);
|
||||||
|
|
||||||
|
@ -55,6 +58,19 @@ function onSearch(value: string) {
|
||||||
if (activeViewStack.value.uuid) {
|
if (activeViewStack.value.uuid) {
|
||||||
updateCurrentViewStack({ search: value });
|
updateCurrentViewStack({ search: value });
|
||||||
void setActiveItemIndex(getDefaultActiveIndex(value));
|
void setActiveItemIndex(getDefaultActiveIndex(value));
|
||||||
|
if (value.length) {
|
||||||
|
callDebounced(
|
||||||
|
nodeCreatorStore.onNodeFilterChanged,
|
||||||
|
{ trailing: true, debounceTime: 2000 },
|
||||||
|
{
|
||||||
|
newValue: value,
|
||||||
|
filteredNodes: activeViewStack.value.items ?? [],
|
||||||
|
filterMode: activeViewStack.value.rootView ?? 'Regular',
|
||||||
|
subcategory: activeViewStack.value.subcategory,
|
||||||
|
title: activeViewStack.value.title,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -299,6 +315,7 @@ function onBackButton() {
|
||||||
margin-top: var(--spacing-4xs);
|
margin-top: var(--spacing-4xs);
|
||||||
font-size: var(--font-size-s);
|
font-size: var(--font-size-s);
|
||||||
line-height: 19px;
|
line-height: 19px;
|
||||||
|
|
||||||
color: var(--color-text-base);
|
color: var(--color-text-base);
|
||||||
font-weight: var(--font-weight-regular);
|
font-weight: var(--font-weight-regular);
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,7 +8,7 @@ import { useKeyboardNavigation } from '../composables/useKeyboardNavigation';
|
||||||
import { useViewStacks } from '../composables/useViewStacks';
|
import { useViewStacks } from '../composables/useViewStacks';
|
||||||
import ItemsRenderer from './ItemsRenderer.vue';
|
import ItemsRenderer from './ItemsRenderer.vue';
|
||||||
import CategoryItem from '../ItemTypes/CategoryItem.vue';
|
import CategoryItem from '../ItemTypes/CategoryItem.vue';
|
||||||
import { useTelemetry } from '@/composables/useTelemetry';
|
import { useNodeCreatorStore } from '@/stores/nodeCreator.store';
|
||||||
|
|
||||||
export interface Props {
|
export interface Props {
|
||||||
elements: INodeCreateElement[];
|
elements: INodeCreateElement[];
|
||||||
|
@ -24,10 +24,10 @@ const props = withDefaults(defineProps<Props>(), {
|
||||||
elements: () => [],
|
elements: () => [],
|
||||||
});
|
});
|
||||||
|
|
||||||
const telemetry = useTelemetry();
|
|
||||||
const { popViewStack } = useViewStacks();
|
const { popViewStack } = useViewStacks();
|
||||||
const { registerKeyHook } = useKeyboardNavigation();
|
const { registerKeyHook } = useKeyboardNavigation();
|
||||||
const { workflowId } = useWorkflowsStore();
|
const { workflowId } = useWorkflowsStore();
|
||||||
|
const nodeCreatorStore = useNodeCreatorStore();
|
||||||
|
|
||||||
const activeItemId = computed(() => useKeyboardNavigation()?.activeItemId);
|
const activeItemId = computed(() => useKeyboardNavigation()?.activeItemId);
|
||||||
const actionCount = computed(() => props.elements.filter(({ type }) => type === 'action').length);
|
const actionCount = computed(() => props.elements.filter(({ type }) => type === 'action').length);
|
||||||
|
@ -38,10 +38,11 @@ function toggleExpanded() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function setExpanded(isExpanded: boolean) {
|
function setExpanded(isExpanded: boolean) {
|
||||||
|
const prev = expanded.value;
|
||||||
expanded.value = isExpanded;
|
expanded.value = isExpanded;
|
||||||
|
|
||||||
if (expanded.value) {
|
if (expanded.value && !prev) {
|
||||||
telemetry.trackNodesPanel('nodeCreateList.onCategoryExpanded', {
|
nodeCreatorStore.onCategoryExpanded({
|
||||||
category_name: props.category,
|
category_name: props.category,
|
||||||
workflow_id: workflowId,
|
workflow_id: workflowId,
|
||||||
});
|
});
|
||||||
|
|
|
@ -332,7 +332,11 @@ export const useActions = () => {
|
||||||
return storeWatcher;
|
return storeWatcher;
|
||||||
}
|
}
|
||||||
|
|
||||||
function trackActionSelected(action: IUpdateInformation, telemetry: Telemetry, rootView: string) {
|
function trackActionSelected(
|
||||||
|
action: IUpdateInformation,
|
||||||
|
_telemetry: Telemetry,
|
||||||
|
rootView: string,
|
||||||
|
) {
|
||||||
const payload = {
|
const payload = {
|
||||||
node_type: action.key,
|
node_type: action.key,
|
||||||
action: action.name,
|
action: action.name,
|
||||||
|
@ -340,7 +344,7 @@ export const useActions = () => {
|
||||||
resource: (action.value as INodeParameters).resource || '',
|
resource: (action.value as INodeParameters).resource || '',
|
||||||
};
|
};
|
||||||
void useExternalHooks().run('nodeCreateList.addAction', payload);
|
void useExternalHooks().run('nodeCreateList.addAction', payload);
|
||||||
telemetry?.trackNodesPanel('nodeCreateList.addAction', payload);
|
useNodeCreatorStore().onAddActions(payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
@ -100,14 +100,15 @@ const isTriggerNode = computed(() => {
|
||||||
});
|
});
|
||||||
|
|
||||||
const hasAiMetadata = computed(() => {
|
const hasAiMetadata = computed(() => {
|
||||||
|
if (isNodeRunning.value || !workflowRunData.value) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (node.value) {
|
if (node.value) {
|
||||||
const resultData = workflowsStore.getWorkflowResultDataByNodeName(node.value.name);
|
const connectedSubNodes = props.workflow.getParentNodes(node.value.name, 'ALL_NON_MAIN');
|
||||||
|
const resultData = connectedSubNodes.map(workflowsStore.getWorkflowResultDataByNodeName);
|
||||||
|
|
||||||
if (!resultData || !Array.isArray(resultData) || resultData.length === 0) {
|
return resultData && Array.isArray(resultData) && resultData.length > 0;
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return !!resultData[resultData.length - 1].metadata;
|
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
|
@ -295,6 +296,7 @@ const activatePane = () => {
|
||||||
:block-u-i="blockUI"
|
:block-u-i="blockUI"
|
||||||
:is-production-execution-preview="isProductionExecutionPreview"
|
:is-production-execution-preview="isProductionExecutionPreview"
|
||||||
:is-pane-active="isPaneActive"
|
:is-pane-active="isPaneActive"
|
||||||
|
:hide-pagination="outputMode === 'logs'"
|
||||||
pane-type="output"
|
pane-type="output"
|
||||||
:data-output-type="outputMode"
|
:data-output-type="outputMode"
|
||||||
@activate-pane="activatePane"
|
@activate-pane="activatePane"
|
||||||
|
@ -368,7 +370,7 @@ const activatePane = () => {
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-if="outputMode === 'logs' && node" #content>
|
<template v-if="outputMode === 'logs' && node" #content>
|
||||||
<RunDataAi :node="node" :run-index="runIndex" />
|
<RunDataAi :node="node" :run-index="runIndex" :workflow="workflow" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #recovered-artificial-output-data>
|
<template #recovered-artificial-output-data>
|
||||||
|
|
|
@ -162,6 +162,10 @@ export default defineComponent({
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
|
hidePagination: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
setup(props) {
|
setup(props) {
|
||||||
const ndvStore = useNDVStore();
|
const ndvStore = useNDVStore();
|
||||||
|
@ -1743,6 +1747,7 @@ export default defineComponent({
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="
|
v-if="
|
||||||
|
hidePagination === false &&
|
||||||
hasNodeRun &&
|
hasNodeRun &&
|
||||||
!hasRunError &&
|
!hasRunError &&
|
||||||
displayMode !== 'binary' &&
|
displayMode !== 'binary' &&
|
||||||
|
|
|
@ -1,8 +1,7 @@
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import type { Ref } from 'vue';
|
import type { Ref } from 'vue';
|
||||||
import { computed, ref, watch } from 'vue';
|
import { computed, ref, watch } from 'vue';
|
||||||
import type { ITaskSubRunMetadata, ITaskDataConnections } from 'n8n-workflow';
|
import type { ITaskDataConnections, NodeConnectionType, Workflow, ITaskData } from 'n8n-workflow';
|
||||||
import { NodeConnectionType } from 'n8n-workflow';
|
|
||||||
import type { IAiData, IAiDataContent, INodeUi } from '@/Interface';
|
import type { IAiData, IAiDataContent, INodeUi } from '@/Interface';
|
||||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||||
|
@ -28,29 +27,21 @@ export interface Props {
|
||||||
runIndex?: number;
|
runIndex?: number;
|
||||||
hideTitle?: boolean;
|
hideTitle?: boolean;
|
||||||
slim?: boolean;
|
slim?: boolean;
|
||||||
|
workflow: Workflow;
|
||||||
}
|
}
|
||||||
const props = withDefaults(defineProps<Props>(), { runIndex: 0 });
|
const props = withDefaults(defineProps<Props>(), { runIndex: 0 });
|
||||||
const workflowsStore = useWorkflowsStore();
|
const workflowsStore = useWorkflowsStore();
|
||||||
const nodeTypesStore = useNodeTypesStore();
|
const nodeTypesStore = useNodeTypesStore();
|
||||||
const selectedRun: Ref<IAiData[]> = ref([]);
|
const selectedRun: Ref<IAiData[]> = ref([]);
|
||||||
|
|
||||||
function isTreeNodeSelected(node: TreeNode) {
|
function isTreeNodeSelected(node: TreeNode) {
|
||||||
return selectedRun.value.some((run) => run.node === node.node && run.runIndex === node.runIndex);
|
return selectedRun.value.some((run) => run.node === node.node && run.runIndex === node.runIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getReferencedData(
|
function getReferencedData(
|
||||||
reference: ITaskSubRunMetadata,
|
taskData: ITaskData,
|
||||||
withInput: boolean,
|
withInput: boolean,
|
||||||
withOutput: boolean,
|
withOutput: boolean,
|
||||||
): IAiDataContent[] {
|
): IAiDataContent[] {
|
||||||
const resultData = workflowsStore.getWorkflowResultDataByNodeName(reference.node);
|
|
||||||
|
|
||||||
if (!resultData?.[reference.runIndex]) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const taskData = resultData[reference.runIndex];
|
|
||||||
|
|
||||||
if (!taskData) {
|
if (!taskData) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
@ -98,18 +89,18 @@ function onItemClick(data: TreeNode) {
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const selectedNodeRun = workflowsStore.getWorkflowResultDataByNodeName(data.node)?.[
|
||||||
|
data.runIndex
|
||||||
|
];
|
||||||
|
if (!selectedNodeRun) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
selectedRun.value = [
|
selectedRun.value = [
|
||||||
{
|
{
|
||||||
node: data.node,
|
node: data.node,
|
||||||
runIndex: data.runIndex,
|
runIndex: data.runIndex,
|
||||||
data: getReferencedData(
|
data: getReferencedData(selectedNodeRun, true, true),
|
||||||
{
|
|
||||||
node: data.node,
|
|
||||||
runIndex: data.runIndex,
|
|
||||||
},
|
|
||||||
true,
|
|
||||||
true,
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
@ -145,21 +136,20 @@ const createNode = (
|
||||||
});
|
});
|
||||||
|
|
||||||
function getTreeNodeData(nodeName: string, currentDepth: number): TreeNode[] {
|
function getTreeNodeData(nodeName: string, currentDepth: number): TreeNode[] {
|
||||||
const { connectionsByDestinationNode } = workflowsStore.getCurrentWorkflow();
|
const connections = props.workflow.connectionsByDestinationNode[nodeName];
|
||||||
const connections = connectionsByDestinationNode[nodeName];
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-use-before-define
|
|
||||||
const resultData = aiData.value?.filter((data) => data.node === nodeName) ?? [];
|
const resultData = aiData.value?.filter((data) => data.node === nodeName) ?? [];
|
||||||
|
|
||||||
if (!connections) {
|
if (!connections) {
|
||||||
return resultData.map((d) => createNode(nodeName, currentDepth, d));
|
return resultData.map((d) => createNode(nodeName, currentDepth, d));
|
||||||
}
|
}
|
||||||
|
|
||||||
const nonMainConnectionsKeys = Object.keys(connections).filter(
|
// Get the first level of children
|
||||||
(key) => key !== NodeConnectionType.Main,
|
const connectedSubNodes = props.workflow.getParentNodes(nodeName, 'ALL_NON_MAIN', 1);
|
||||||
);
|
|
||||||
const children = nonMainConnectionsKeys.flatMap((key) =>
|
const children = connectedSubNodes
|
||||||
connections[key][0].flatMap((node) => getTreeNodeData(node.node, currentDepth + 1)),
|
// Only include sub-nodes which have data
|
||||||
);
|
.filter((name) => aiData.value?.find((data) => data.node === name))
|
||||||
|
.flatMap((name) => getTreeNodeData(name, currentDepth + 1));
|
||||||
|
|
||||||
children.sort((a, b) => a.startTime - b.startTime);
|
children.sort((a, b) => a.startTime - b.startTime);
|
||||||
|
|
||||||
|
@ -170,35 +160,49 @@ function getTreeNodeData(nodeName: string, currentDepth: number): TreeNode[] {
|
||||||
return [createNode(nodeName, currentDepth, undefined, children)];
|
return [createNode(nodeName, currentDepth, undefined, children)];
|
||||||
}
|
}
|
||||||
|
|
||||||
const aiData = computed<AIResult[] | undefined>(() => {
|
const aiData = computed<AIResult[]>(() => {
|
||||||
const resultData = workflowsStore.getWorkflowResultDataByNodeName(props.node.name);
|
const result: AIResult[] = [];
|
||||||
|
const connectedSubNodes = props.workflow.getParentNodes(props.node.name, 'ALL_NON_MAIN');
|
||||||
|
const rootNodeResult = workflowsStore.getWorkflowResultDataByNodeName(props.node.name);
|
||||||
|
const rootNodeStartTime = rootNodeResult?.[0]?.startTime ?? 0;
|
||||||
|
const rootNodeEndTime = rootNodeStartTime + (rootNodeResult?.[0]?.executionTime ?? 0);
|
||||||
|
|
||||||
if (!resultData || !Array.isArray(resultData)) {
|
connectedSubNodes.forEach((nodeName) => {
|
||||||
return;
|
const nodeRunData = workflowsStore.getWorkflowResultDataByNodeName(nodeName) ?? [];
|
||||||
}
|
|
||||||
|
|
||||||
const subRun = resultData[props.runIndex].metadata?.subRun;
|
nodeRunData.forEach((run, runIndex) => {
|
||||||
if (!Array.isArray(subRun)) {
|
const referenceData = {
|
||||||
return;
|
data: getReferencedData(run, false, true)[0],
|
||||||
}
|
node: nodeName,
|
||||||
// Extend the subRun with the data and sort by adding execution time + startTime and comparing them
|
runIndex,
|
||||||
const subRunWithData = subRun.flatMap((run) =>
|
};
|
||||||
getReferencedData(run, false, true).map((data) => ({ ...run, data })),
|
|
||||||
);
|
|
||||||
|
|
||||||
subRunWithData.sort((a, b) => {
|
result.push(referenceData);
|
||||||
const aTime = a.data?.metadata?.startTime || 0;
|
});
|
||||||
const bTime = b.data?.metadata?.startTime || 0;
|
});
|
||||||
|
|
||||||
|
// Sort the data by start time
|
||||||
|
result.sort((a, b) => {
|
||||||
|
const aTime = a.data?.metadata?.startTime ?? 0;
|
||||||
|
const bTime = b.data?.metadata?.startTime ?? 0;
|
||||||
return aTime - bTime;
|
return aTime - bTime;
|
||||||
});
|
});
|
||||||
|
|
||||||
return subRunWithData;
|
// Only show data that is within the root node's execution time
|
||||||
|
// This is because sub-node could be connected to multiple root nodes
|
||||||
|
const currentNodeResult = result.filter((r) => {
|
||||||
|
const startTime = r.data?.metadata?.startTime ?? 0;
|
||||||
|
|
||||||
|
return startTime >= rootNodeStartTime && startTime <= rootNodeEndTime;
|
||||||
|
});
|
||||||
|
|
||||||
|
return currentNodeResult;
|
||||||
});
|
});
|
||||||
|
|
||||||
const executionTree = computed<TreeNode[]>(() => {
|
const executionTree = computed<TreeNode[]>(() => {
|
||||||
const rootNode = props.node;
|
const rootNode = props.node;
|
||||||
|
|
||||||
const tree = getTreeNodeData(rootNode.name, 0);
|
const tree = getTreeNodeData(rootNode.name, 1);
|
||||||
return tree || [];
|
return tree || [];
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -206,7 +210,7 @@ watch(() => props.runIndex, selectFirst, { immediate: true });
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div v-if="aiData" :class="$style.container">
|
<div v-if="aiData.length > 0" :class="$style.container">
|
||||||
<div :class="{ [$style.tree]: true, [$style.slim]: slim }">
|
<div :class="{ [$style.tree]: true, [$style.slim]: slim }">
|
||||||
<ElTree
|
<ElTree
|
||||||
:data="executionTree"
|
:data="executionTree"
|
||||||
|
|
|
@ -22,7 +22,6 @@ import { useUsersStore } from '@/stores/users.store';
|
||||||
import MessagesList from '@n8n/chat/components/MessagesList.vue';
|
import MessagesList from '@n8n/chat/components/MessagesList.vue';
|
||||||
import type { ArrowKeyDownPayload } from '@n8n/chat/components/Input.vue';
|
import type { ArrowKeyDownPayload } from '@n8n/chat/components/Input.vue';
|
||||||
import ChatInput from '@n8n/chat/components/Input.vue';
|
import ChatInput from '@n8n/chat/components/Input.vue';
|
||||||
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
|
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import { useRunWorkflow } from '@/composables/useRunWorkflow';
|
import { useRunWorkflow } from '@/composables/useRunWorkflow';
|
||||||
import type { Chat, ChatMessage, ChatMessageText, ChatOptions } from '@n8n/chat/types';
|
import type { Chat, ChatMessage, ChatMessageText, ChatOptions } from '@n8n/chat/types';
|
||||||
|
@ -78,7 +77,6 @@ interface MemoryOutput {
|
||||||
}
|
}
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const workflowHelpers = useWorkflowHelpers({ router });
|
|
||||||
const { runWorkflow } = useRunWorkflow({ router });
|
const { runWorkflow } = useRunWorkflow({ router });
|
||||||
const workflowsStore = useWorkflowsStore();
|
const workflowsStore = useWorkflowsStore();
|
||||||
const nodeTypesStore = useNodeTypesStore();
|
const nodeTypesStore = useNodeTypesStore();
|
||||||
|
@ -145,9 +143,9 @@ const messageVars = {
|
||||||
'--chat--color-typing': 'var(--color-text-dark)',
|
'--chat--color-typing': 'var(--color-text-dark)',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const workflow = computed(() => workflowsStore.getCurrentWorkflow());
|
||||||
function getTriggerNode() {
|
function getTriggerNode() {
|
||||||
const workflow = workflowHelpers.getCurrentWorkflow();
|
const triggerNode = workflow.value.queryNodes((nodeType: INodeType) =>
|
||||||
const triggerNode = workflow.queryNodes((nodeType: INodeType) =>
|
|
||||||
[CHAT_TRIGGER_NODE_TYPE, MANUAL_CHAT_TRIGGER_NODE_TYPE].includes(nodeType.description.name),
|
[CHAT_TRIGGER_NODE_TYPE, MANUAL_CHAT_TRIGGER_NODE_TYPE].includes(nodeType.description.name),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -164,19 +162,19 @@ function setNode() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const workflow = workflowHelpers.getCurrentWorkflow();
|
const childNodes = workflow.value.getChildNodes(triggerNode.name);
|
||||||
const childNodes = workflow.getChildNodes(triggerNode.name);
|
|
||||||
|
|
||||||
for (const childNode of childNodes) {
|
for (const childNode of childNodes) {
|
||||||
// Look for the first connected node with metadata
|
// Look for the first connected node with metadata
|
||||||
// TODO: Allow later users to change that in the UI
|
// TODO: Allow later users to change that in the UI
|
||||||
const resultData = workflowsStore.getWorkflowResultDataByNodeName(childNode);
|
const connectedSubNodes = workflow.value.getParentNodes(childNode, 'ALL_NON_MAIN');
|
||||||
|
const resultData = connectedSubNodes.map(workflowsStore.getWorkflowResultDataByNodeName);
|
||||||
|
|
||||||
if (!resultData && !Array.isArray(resultData)) {
|
if (!resultData && !Array.isArray(resultData)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (resultData[resultData.length - 1].metadata) {
|
if (resultData.some((data) => data?.[0].metadata)) {
|
||||||
node.value = workflowsStore.getNodeByName(childNode);
|
node.value = workflowsStore.getNodeByName(childNode);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
@ -190,7 +188,6 @@ function setConnectedNode() {
|
||||||
showError(new Error('Chat Trigger Node could not be found!'), 'Trigger Node not found');
|
showError(new Error('Chat Trigger Node could not be found!'), 'Trigger Node not found');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const workflow = workflowHelpers.getCurrentWorkflow();
|
|
||||||
|
|
||||||
const chatNode = workflowsStore.getNodes().find((storeNode: INodeUi): boolean => {
|
const chatNode = workflowsStore.getNodes().find((storeNode: INodeUi): boolean => {
|
||||||
if (storeNode.type === CHAIN_SUMMARIZATION_LANGCHAIN_NODE_TYPE) return false;
|
if (storeNode.type === CHAIN_SUMMARIZATION_LANGCHAIN_NODE_TYPE) return false;
|
||||||
|
@ -202,10 +199,10 @@ function setConnectedNode() {
|
||||||
|
|
||||||
let isCustomChainOrAgent = false;
|
let isCustomChainOrAgent = false;
|
||||||
if (nodeType.name === AI_CODE_NODE_TYPE) {
|
if (nodeType.name === AI_CODE_NODE_TYPE) {
|
||||||
const inputs = NodeHelpers.getNodeInputs(workflow, storeNode, nodeType);
|
const inputs = NodeHelpers.getNodeInputs(workflow.value, storeNode, nodeType);
|
||||||
const inputTypes = NodeHelpers.getConnectionTypes(inputs);
|
const inputTypes = NodeHelpers.getConnectionTypes(inputs);
|
||||||
|
|
||||||
const outputs = NodeHelpers.getNodeOutputs(workflow, storeNode, nodeType);
|
const outputs = NodeHelpers.getNodeOutputs(workflow.value, storeNode, nodeType);
|
||||||
const outputTypes = NodeHelpers.getConnectionTypes(outputs);
|
const outputTypes = NodeHelpers.getConnectionTypes(outputs);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
@ -219,7 +216,7 @@ function setConnectedNode() {
|
||||||
|
|
||||||
if (!isAgent && !isChain && !isCustomChainOrAgent) return false;
|
if (!isAgent && !isChain && !isCustomChainOrAgent) return false;
|
||||||
|
|
||||||
const parentNodes = workflow.getParentNodes(storeNode.name);
|
const parentNodes = workflow.value.getParentNodes(storeNode.name);
|
||||||
const isChatChild = parentNodes.some((parentNodeName) => parentNodeName === triggerNode.name);
|
const isChatChild = parentNodes.some((parentNodeName) => parentNodeName === triggerNode.name);
|
||||||
|
|
||||||
return Boolean(isChatChild && (isAgent || isChain || isCustomChainOrAgent));
|
return Boolean(isChatChild && (isAgent || isChain || isCustomChainOrAgent));
|
||||||
|
@ -431,10 +428,9 @@ async function sendMessage(message: string, files?: File[]) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function displayExecution(executionId: string) {
|
function displayExecution(executionId: string) {
|
||||||
const workflow = workflowHelpers.getCurrentWorkflow();
|
|
||||||
const route = router.resolve({
|
const route = router.resolve({
|
||||||
name: VIEWS.EXECUTION_PREVIEW,
|
name: VIEWS.EXECUTION_PREVIEW,
|
||||||
params: { name: workflow.id, executionId },
|
params: { name: workflow.value.id, executionId },
|
||||||
});
|
});
|
||||||
window.open(route.href, '_blank');
|
window.open(route.href, '_blank');
|
||||||
}
|
}
|
||||||
|
@ -452,9 +448,10 @@ function reuseMessage(message: ChatMessageText) {
|
||||||
function getChatMessages(): ChatMessageText[] {
|
function getChatMessages(): ChatMessageText[] {
|
||||||
if (!connectedNode.value) return [];
|
if (!connectedNode.value) return [];
|
||||||
|
|
||||||
const workflow = workflowHelpers.getCurrentWorkflow();
|
|
||||||
const connectedMemoryInputs =
|
const connectedMemoryInputs =
|
||||||
workflow.connectionsByDestinationNode[connectedNode.value.name][NodeConnectionType.AiMemory];
|
workflow.value.connectionsByDestinationNode[connectedNode.value.name][
|
||||||
|
NodeConnectionType.AiMemory
|
||||||
|
];
|
||||||
if (!connectedMemoryInputs) return [];
|
if (!connectedMemoryInputs) return [];
|
||||||
|
|
||||||
const memoryConnection = (connectedMemoryInputs ?? []).find((i) => i.length > 0)?.[0];
|
const memoryConnection = (connectedMemoryInputs ?? []).find((i) => i.length > 0)?.[0];
|
||||||
|
@ -573,7 +570,13 @@ onMounted(() => {
|
||||||
locale.baseText('chat.window.logs')
|
locale.baseText('chat.window.logs')
|
||||||
}}</n8n-text>
|
}}</n8n-text>
|
||||||
<div :class="$style.logs">
|
<div :class="$style.logs">
|
||||||
<LazyRunDataAi :key="messages.length" :node="node" hide-title slim />
|
<LazyRunDataAi
|
||||||
|
:key="messages.length"
|
||||||
|
:node="node"
|
||||||
|
hide-title
|
||||||
|
slim
|
||||||
|
:workflow="workflow"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -27,11 +27,11 @@ export default defineComponent({
|
||||||
|
|
||||||
<style lang="scss" module>
|
<style lang="scss" module>
|
||||||
.wrapper {
|
.wrapper {
|
||||||
display: grid;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 1280px;
|
max-width: 1280px;
|
||||||
grid-template-rows: auto 1fr;
|
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
align-content: start;
|
align-content: start;
|
||||||
padding: var(--spacing-l) var(--spacing-2xl) 0;
|
padding: var(--spacing-l) var(--spacing-2xl) 0;
|
||||||
|
|
|
@ -0,0 +1,20 @@
|
||||||
|
import { createComponentRenderer } from '@/__tests__/render';
|
||||||
|
import ResourceListHeader from './ResourceListHeader.vue';
|
||||||
|
|
||||||
|
const renderComponent = createComponentRenderer(ResourceListHeader);
|
||||||
|
|
||||||
|
describe('WorkflowHeader', () => {
|
||||||
|
it('should render icon prop', () => {
|
||||||
|
const icon = 'home';
|
||||||
|
const { container } = renderComponent({ props: { icon } });
|
||||||
|
expect(container.querySelector(`.fa-${icon}`)).toBeVisible();
|
||||||
|
});
|
||||||
|
test.each([
|
||||||
|
['title', 'title slot'],
|
||||||
|
['subtitle', 'subtitle slot'],
|
||||||
|
['actions', 'actions slot'],
|
||||||
|
])('should render "%s" slot', (slot, content) => {
|
||||||
|
const { getByText } = renderComponent({ props: { icon: 'home' }, slots: { [slot]: content } });
|
||||||
|
expect(getByText(content)).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,43 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { N8nHeading, N8nText, N8nIcon } from 'n8n-design-system';
|
||||||
|
defineProps<{ icon: string }>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div :class="[$style.workflowHeader]">
|
||||||
|
<div :class="[$style.icon]">
|
||||||
|
<N8nIcon :icon color="text-light"></N8nIcon>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<N8nHeading bold tag="h2" size="xlarge">
|
||||||
|
<slot name="title" />
|
||||||
|
</N8nHeading>
|
||||||
|
<N8nText v-if="$slots.subtitle" size="small" color="text-light">
|
||||||
|
<slot name="subtitle" />
|
||||||
|
</N8nText>
|
||||||
|
</div>
|
||||||
|
<div v-if="$slots.actions" :class="[$style.actions]">
|
||||||
|
<slot name="actions"></slot>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" module>
|
||||||
|
.workflowHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding-bottom: var(--spacing-m);
|
||||||
|
min-height: 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
border: 1px solid var(--color-foreground-light);
|
||||||
|
padding: 6px;
|
||||||
|
border-radius: var(--border-radius-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -1,7 +1,12 @@
|
||||||
import { setActivePinia, createPinia } from 'pinia';
|
import { setActivePinia } from 'pinia';
|
||||||
|
import { createTestingPinia } from '@pinia/testing';
|
||||||
|
import { within, waitFor } from '@testing-library/vue';
|
||||||
import { createComponentRenderer } from '@/__tests__/render';
|
import { createComponentRenderer } from '@/__tests__/render';
|
||||||
import ResourcesListLayout from '@/components/layouts/ResourcesListLayout.vue';
|
import ResourcesListLayout from '@/components/layouts/ResourcesListLayout.vue';
|
||||||
import type router from 'vue-router';
|
import type router from 'vue-router';
|
||||||
|
import { mockedStore } from '@/__tests__/utils';
|
||||||
|
import { useProjectsStore } from '@/stores/projects.store';
|
||||||
|
import { type Project, ProjectTypes } from '@/types/projects.types';
|
||||||
|
|
||||||
vi.mock('vue-router', async (importOriginal) => {
|
vi.mock('vue-router', async (importOriginal) => {
|
||||||
const { RouterLink } = await importOriginal<typeof router>();
|
const { RouterLink } = await importOriginal<typeof router>();
|
||||||
|
@ -18,7 +23,8 @@ const renderComponent = createComponentRenderer(ResourcesListLayout);
|
||||||
|
|
||||||
describe('ResourcesListLayout', () => {
|
describe('ResourcesListLayout', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
setActivePinia(createPinia());
|
const pinia = createTestingPinia();
|
||||||
|
setActivePinia(pinia);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should render loading skeleton', () => {
|
it('should render loading skeleton', () => {
|
||||||
|
@ -30,4 +36,46 @@ describe('ResourcesListLayout', () => {
|
||||||
|
|
||||||
expect(container.querySelectorAll('.el-skeleton__p')).toHaveLength(25);
|
expect(container.querySelectorAll('.el-skeleton__p')).toHaveLength(25);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('header', () => {
|
||||||
|
it('should render the correct icon', async () => {
|
||||||
|
const projects = mockedStore(useProjectsStore);
|
||||||
|
const { getByTestId } = renderComponent();
|
||||||
|
|
||||||
|
expect(getByTestId('list-layout-header').querySelector('.fa-home')).toBeVisible();
|
||||||
|
|
||||||
|
projects.currentProject = { type: ProjectTypes.Personal } as Project;
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(getByTestId('list-layout-header').querySelector('.fa-user')).toBeVisible(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const projectName = 'My Project';
|
||||||
|
projects.currentProject = { name: projectName } as Project;
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(getByTestId('list-layout-header').querySelector('.fa-layer-group')).toBeVisible(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render the correct title', async () => {
|
||||||
|
const projects = mockedStore(useProjectsStore);
|
||||||
|
const { getByTestId } = renderComponent();
|
||||||
|
|
||||||
|
expect(within(getByTestId('list-layout-header')).getByText('Home')).toBeVisible();
|
||||||
|
|
||||||
|
projects.currentProject = { type: ProjectTypes.Personal } as Project;
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(within(getByTestId('list-layout-header')).getByText('Personal')).toBeVisible(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const projectName = 'My Project';
|
||||||
|
projects.currentProject = { name: projectName } as Project;
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(within(getByTestId('list-layout-header')).getByText(projectName)).toBeVisible(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -2,16 +2,18 @@
|
||||||
import { computed, defineComponent, nextTick, ref, onMounted, watch } from 'vue';
|
import { computed, defineComponent, nextTick, ref, onMounted, watch } from 'vue';
|
||||||
import type { PropType } from 'vue';
|
import type { PropType } from 'vue';
|
||||||
|
|
||||||
import type { ProjectSharingData } from '@/types/projects.types';
|
import { type ProjectSharingData, ProjectTypes } from '@/types/projects.types';
|
||||||
import PageViewLayout from '@/components/layouts/PageViewLayout.vue';
|
import PageViewLayout from '@/components/layouts/PageViewLayout.vue';
|
||||||
import PageViewLayoutList from '@/components/layouts/PageViewLayoutList.vue';
|
import PageViewLayoutList from '@/components/layouts/PageViewLayoutList.vue';
|
||||||
import ResourceFiltersDropdown from '@/components/forms/ResourceFiltersDropdown.vue';
|
import ResourceFiltersDropdown from '@/components/forms/ResourceFiltersDropdown.vue';
|
||||||
|
import ResourceListHeader from '@/components/layouts/ResourceListHeader.vue';
|
||||||
import { useUsersStore } from '@/stores/users.store';
|
import { useUsersStore } from '@/stores/users.store';
|
||||||
import type { DatatableColumn } from 'n8n-design-system';
|
import type { DatatableColumn } from 'n8n-design-system';
|
||||||
import { useI18n } from '@/composables/useI18n';
|
import { useI18n } from '@/composables/useI18n';
|
||||||
import { useDebounce } from '@/composables/useDebounce';
|
import { useDebounce } from '@/composables/useDebounce';
|
||||||
import { useTelemetry } from '@/composables/useTelemetry';
|
import { useTelemetry } from '@/composables/useTelemetry';
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
|
import { useProjectsStore } from '@/stores/projects.store';
|
||||||
|
|
||||||
// eslint-disable-next-line unused-imports/no-unused-imports, @typescript-eslint/no-unused-vars
|
// eslint-disable-next-line unused-imports/no-unused-imports, @typescript-eslint/no-unused-vars
|
||||||
import type { BaseTextKey } from '@/plugins/i18n';
|
import type { BaseTextKey } from '@/plugins/i18n';
|
||||||
|
@ -44,6 +46,7 @@ export default defineComponent({
|
||||||
PageViewLayout,
|
PageViewLayout,
|
||||||
PageViewLayoutList,
|
PageViewLayoutList,
|
||||||
ResourceFiltersDropdown,
|
ResourceFiltersDropdown,
|
||||||
|
ResourceListHeader,
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
resourceKey: {
|
resourceKey: {
|
||||||
|
@ -113,6 +116,7 @@ export default defineComponent({
|
||||||
const i18n = useI18n();
|
const i18n = useI18n();
|
||||||
const { callDebounced } = useDebounce();
|
const { callDebounced } = useDebounce();
|
||||||
const usersStore = useUsersStore();
|
const usersStore = useUsersStore();
|
||||||
|
const projectsStore = useProjectsStore();
|
||||||
const telemetry = useTelemetry();
|
const telemetry = useTelemetry();
|
||||||
|
|
||||||
const sortBy = ref(props.sortOptions[0]);
|
const sortBy = ref(props.sortOptions[0]);
|
||||||
|
@ -339,10 +343,31 @@ export default defineComponent({
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const headerIcon = computed(() => {
|
||||||
|
if (projectsStore.currentProject?.type === ProjectTypes.Personal) {
|
||||||
|
return 'user';
|
||||||
|
} else if (projectsStore.currentProject?.name) {
|
||||||
|
return 'layer-group';
|
||||||
|
} else {
|
||||||
|
return 'home';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const projectName = computed(() => {
|
||||||
|
if (!projectsStore.currentProject) {
|
||||||
|
return i18n.baseText('projects.menu.home');
|
||||||
|
} else if (projectsStore.currentProject.type === ProjectTypes.Personal) {
|
||||||
|
return i18n.baseText('projects.menu.personal');
|
||||||
|
} else {
|
||||||
|
return projectsStore.currentProject.name;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
i18n,
|
i18n,
|
||||||
search,
|
search,
|
||||||
usersStore,
|
usersStore,
|
||||||
|
projectsStore,
|
||||||
filterKeys,
|
filterKeys,
|
||||||
currentPage,
|
currentPage,
|
||||||
rowsPerPage,
|
rowsPerPage,
|
||||||
|
@ -362,6 +387,8 @@ export default defineComponent({
|
||||||
setCurrentPage,
|
setCurrentPage,
|
||||||
setRowsPerPage,
|
setRowsPerPage,
|
||||||
onSearch,
|
onSearch,
|
||||||
|
headerIcon,
|
||||||
|
projectName,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
@ -369,7 +396,14 @@ export default defineComponent({
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<PageViewLayout>
|
<PageViewLayout>
|
||||||
<template #header> <slot name="header" /> </template>
|
<template #header>
|
||||||
|
<ResourceListHeader :icon="headerIcon" data-test-id="list-layout-header">
|
||||||
|
<template #title>
|
||||||
|
{{ projectName }}
|
||||||
|
</template>
|
||||||
|
</ResourceListHeader>
|
||||||
|
<slot name="header" />
|
||||||
|
</template>
|
||||||
<div v-if="loading" class="resource-list-loading">
|
<div v-if="loading" class="resource-list-loading">
|
||||||
<n8n-loading :rows="25" :shrink-last="false" />
|
<n8n-loading :rows="25" :shrink-last="false" />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -84,7 +84,6 @@ import type {
|
||||||
INodeTypeDescription,
|
INodeTypeDescription,
|
||||||
INodeTypeNameVersion,
|
INodeTypeNameVersion,
|
||||||
IPinData,
|
IPinData,
|
||||||
ITelemetryTrackProperties,
|
|
||||||
IWorkflowBase,
|
IWorkflowBase,
|
||||||
NodeInputConnections,
|
NodeInputConnections,
|
||||||
NodeParameterValueType,
|
NodeParameterValueType,
|
||||||
|
@ -733,25 +732,22 @@ export function useCanvasOperations({ router }: { router: ReturnType<typeof useR
|
||||||
}
|
}
|
||||||
|
|
||||||
function trackAddStickyNoteNode() {
|
function trackAddStickyNoteNode() {
|
||||||
telemetry.trackNodesPanel('nodeView.addSticky', {
|
telemetry.track('User inserted workflow note', {
|
||||||
workflow_id: workflowsStore.workflowId,
|
workflow_id: workflowsStore.workflowId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function trackAddDefaultNode(nodeData: INodeUi, options: AddNodeOptions) {
|
function trackAddDefaultNode(nodeData: INodeUi, options: AddNodeOptions) {
|
||||||
const trackProperties: ITelemetryTrackProperties = {
|
nodeCreatorStore.onNodeAddedToCanvas({
|
||||||
node_type: nodeData.type,
|
node_type: nodeData.type,
|
||||||
node_version: nodeData.typeVersion,
|
node_version: nodeData.typeVersion,
|
||||||
is_auto_add: options.isAutoAdd,
|
is_auto_add: options.isAutoAdd,
|
||||||
workflow_id: workflowsStore.workflowId,
|
workflow_id: workflowsStore.workflowId,
|
||||||
drag_and_drop: options.dragAndDrop,
|
drag_and_drop: options.dragAndDrop,
|
||||||
};
|
input_node_type: uiStore.lastInteractedWithNode
|
||||||
|
? uiStore.lastInteractedWithNode.type
|
||||||
if (uiStore.lastInteractedWithNode) {
|
: undefined,
|
||||||
trackProperties.input_node_type = uiStore.lastInteractedWithNode.type;
|
});
|
||||||
}
|
|
||||||
|
|
||||||
telemetry.trackNodesPanel('nodeView.addNodeButton', trackProperties);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -1,30 +0,0 @@
|
||||||
export const nodesPanelSession = {
|
|
||||||
pushRef: '',
|
|
||||||
data: {
|
|
||||||
nodeFilter: '',
|
|
||||||
resultsNodes: [] as string[],
|
|
||||||
filterMode: 'Regular',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const hooksGenerateNodesPanelEvent = () => {
|
|
||||||
return {
|
|
||||||
eventName: 'User entered nodes panel search term',
|
|
||||||
properties: {
|
|
||||||
search_string: nodesPanelSession.data.nodeFilter,
|
|
||||||
results_count: nodesPanelSession.data.resultsNodes.length,
|
|
||||||
results_nodes: nodesPanelSession.data.resultsNodes,
|
|
||||||
filter_mode: nodesPanelSession.data.filterMode,
|
|
||||||
nodes_panel_session_id: nodesPanelSession.pushRef,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const hooksResetNodesPanelSession = () => {
|
|
||||||
nodesPanelSession.pushRef = `nodes_panel_session_${new Date().valueOf()}`;
|
|
||||||
nodesPanelSession.data = {
|
|
||||||
nodeFilter: '',
|
|
||||||
resultsNodes: [],
|
|
||||||
filterMode: 'Regular',
|
|
||||||
};
|
|
||||||
};
|
|
|
@ -1,2 +1 @@
|
||||||
export * from './hooksAddFakeDoorFeatures';
|
export * from './hooksAddFakeDoorFeatures';
|
||||||
export * from './hooksNodesPanel';
|
|
||||||
|
|
|
@ -3,8 +3,8 @@ import type { ITelemetrySettings } from '@n8n/api-types';
|
||||||
import type { ITelemetryTrackProperties, IDataObject } from 'n8n-workflow';
|
import type { ITelemetryTrackProperties, IDataObject } from 'n8n-workflow';
|
||||||
import type { RouteLocation } from 'vue-router';
|
import type { RouteLocation } from 'vue-router';
|
||||||
|
|
||||||
import type { INodeCreateElement, IUpdateInformation } from '@/Interface';
|
import type { IUpdateInformation } from '@/Interface';
|
||||||
import type { IUserNodesPanelSession, RudderStack } from './telemetry.types';
|
import type { RudderStack } from './telemetry.types';
|
||||||
import {
|
import {
|
||||||
APPEND_ATTRIBUTION_DEFAULT_PATH,
|
APPEND_ATTRIBUTION_DEFAULT_PATH,
|
||||||
MICROSOFT_TEAMS_NODE_TYPE,
|
MICROSOFT_TEAMS_NODE_TYPE,
|
||||||
|
@ -26,15 +26,6 @@ export class Telemetry {
|
||||||
return window.rudderanalytics;
|
return window.rudderanalytics;
|
||||||
}
|
}
|
||||||
|
|
||||||
private userNodesPanelSession: IUserNodesPanelSession = {
|
|
||||||
pushRef: '',
|
|
||||||
data: {
|
|
||||||
nodeFilter: '',
|
|
||||||
resultsNodes: [],
|
|
||||||
filterMode: 'Regular',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.pageEventQueue = [];
|
this.pageEventQueue = [];
|
||||||
this.previousPath = '';
|
this.previousPath = '';
|
||||||
|
@ -200,78 +191,6 @@ export class Telemetry {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
trackNodesPanel(event: string, properties: IDataObject = {}) {
|
|
||||||
if (this.rudderStack) {
|
|
||||||
properties.nodes_panel_session_id = this.userNodesPanelSession.pushRef;
|
|
||||||
switch (event) {
|
|
||||||
case 'nodeView.createNodeActiveChanged':
|
|
||||||
if (properties.createNodeActive !== false) {
|
|
||||||
this.resetNodesPanelSession();
|
|
||||||
properties.nodes_panel_session_id = this.userNodesPanelSession.pushRef;
|
|
||||||
this.track('User opened nodes panel', properties);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 'nodeCreateList.destroyed':
|
|
||||||
if (
|
|
||||||
this.userNodesPanelSession.data.nodeFilter.length > 0 &&
|
|
||||||
this.userNodesPanelSession.data.nodeFilter !== ''
|
|
||||||
) {
|
|
||||||
this.track('User entered nodes panel search term', this.generateNodesPanelEvent());
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 'nodeCreateList.nodeFilterChanged':
|
|
||||||
if (
|
|
||||||
(properties.newValue as string).length === 0 &&
|
|
||||||
this.userNodesPanelSession.data.nodeFilter.length > 0
|
|
||||||
) {
|
|
||||||
this.track('User entered nodes panel search term', this.generateNodesPanelEvent());
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
(properties.newValue as string).length > ((properties.oldValue as string) || '').length
|
|
||||||
) {
|
|
||||||
this.userNodesPanelSession.data.nodeFilter = properties.newValue as string;
|
|
||||||
this.userNodesPanelSession.data.resultsNodes = (
|
|
||||||
(properties.filteredNodes || []) as INodeCreateElement[]
|
|
||||||
).map((node: INodeCreateElement) => node.key);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 'nodeCreateList.onCategoryExpanded':
|
|
||||||
properties.is_subcategory = false;
|
|
||||||
properties.nodes_panel_session_id = this.userNodesPanelSession.pushRef;
|
|
||||||
this.track('User viewed node category', properties);
|
|
||||||
break;
|
|
||||||
case 'nodeCreateList.onViewActions':
|
|
||||||
properties.nodes_panel_session_id = this.userNodesPanelSession.pushRef;
|
|
||||||
this.track('User viewed node actions', properties);
|
|
||||||
break;
|
|
||||||
case 'nodeCreateList.onActionsCustmAPIClicked':
|
|
||||||
properties.nodes_panel_session_id = this.userNodesPanelSession.pushRef;
|
|
||||||
this.track('User clicked custom API from node actions', properties);
|
|
||||||
break;
|
|
||||||
case 'nodeCreateList.addAction':
|
|
||||||
properties.nodes_panel_session_id = this.userNodesPanelSession.pushRef;
|
|
||||||
this.track('User added action', properties);
|
|
||||||
break;
|
|
||||||
case 'nodeCreateList.onSubcategorySelected':
|
|
||||||
properties.category_name = properties.subcategory;
|
|
||||||
properties.is_subcategory = true;
|
|
||||||
properties.nodes_panel_session_id = this.userNodesPanelSession.pushRef;
|
|
||||||
delete properties.selected;
|
|
||||||
this.track('User viewed node category', properties);
|
|
||||||
break;
|
|
||||||
case 'nodeView.addNodeButton':
|
|
||||||
this.track('User added node to workflow canvas', properties, { withPostHog: true });
|
|
||||||
break;
|
|
||||||
case 'nodeView.addSticky':
|
|
||||||
this.track('User inserted workflow note', properties);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// We currently do not support tracking directly from within node implementation
|
// We currently do not support tracking directly from within node implementation
|
||||||
// so we are using this method as centralized way to track node parameters changes
|
// so we are using this method as centralized way to track node parameters changes
|
||||||
trackNodeParametersValuesChange(nodeType: string, change: IUpdateInformation) {
|
trackNodeParametersValuesChange(nodeType: string, change: IUpdateInformation) {
|
||||||
|
@ -295,24 +214,6 @@ export class Telemetry {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private resetNodesPanelSession() {
|
|
||||||
this.userNodesPanelSession.pushRef = `nodes_panel_session_${new Date().valueOf()}`;
|
|
||||||
this.userNodesPanelSession.data = {
|
|
||||||
nodeFilter: '',
|
|
||||||
resultsNodes: [],
|
|
||||||
filterMode: 'All',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private generateNodesPanelEvent() {
|
|
||||||
return {
|
|
||||||
search_string: this.userNodesPanelSession.data.nodeFilter,
|
|
||||||
results_count: this.userNodesPanelSession.data.resultsNodes.length,
|
|
||||||
filter_mode: this.userNodesPanelSession.data.filterMode,
|
|
||||||
nodes_panel_session_id: this.userNodesPanelSession.pushRef,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private initRudderStack(key: string, url: string, options: IDataObject) {
|
private initRudderStack(key: string, url: string, options: IDataObject) {
|
||||||
window.rudderanalytics = window.rudderanalytics || [];
|
window.rudderanalytics = window.rudderanalytics || [];
|
||||||
if (!this.rudderStack) {
|
if (!this.rudderStack) {
|
||||||
|
|
297
packages/editor-ui/src/stores/nodeCreator.store.test.ts
Normal file
297
packages/editor-ui/src/stores/nodeCreator.store.test.ts
Normal file
|
@ -0,0 +1,297 @@
|
||||||
|
import { createPinia, setActivePinia } from 'pinia';
|
||||||
|
import { useNodeCreatorStore } from './nodeCreator.store';
|
||||||
|
import { useTelemetry } from '@/composables/useTelemetry';
|
||||||
|
import { CUSTOM_API_CALL_KEY, REGULAR_NODE_CREATOR_VIEW } from '@/constants';
|
||||||
|
import type { INodeCreateElement } from '@/Interface';
|
||||||
|
|
||||||
|
const workflow_id = 'workflow-id';
|
||||||
|
const category_name = 'category-name';
|
||||||
|
const source = 'source';
|
||||||
|
const mode = 'mode';
|
||||||
|
const now = 1717602004819;
|
||||||
|
const now1 = 1718602004819;
|
||||||
|
const node_type = 'node-type';
|
||||||
|
const node_version = 1;
|
||||||
|
const input_node_type = 'input-node-type';
|
||||||
|
const action = 'action';
|
||||||
|
const source_mode = 'source-mode';
|
||||||
|
const resource = 'resource';
|
||||||
|
const actions = ['action1'];
|
||||||
|
|
||||||
|
vi.mock('@/composables/useTelemetry', () => {
|
||||||
|
const track = vi.fn();
|
||||||
|
return {
|
||||||
|
useTelemetry: () => {
|
||||||
|
return {
|
||||||
|
track,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useNodeCreatorStore', () => {
|
||||||
|
let nodeCreatorStore: ReturnType<typeof useNodeCreatorStore>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
setActivePinia(createPinia());
|
||||||
|
nodeCreatorStore = useNodeCreatorStore();
|
||||||
|
vi.setSystemTime(now);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tracks when node creator is opened', () => {
|
||||||
|
nodeCreatorStore.onCreatorOpened({
|
||||||
|
source,
|
||||||
|
mode,
|
||||||
|
workflow_id,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(useTelemetry().track).toHaveBeenCalledWith(
|
||||||
|
'User opened nodes panel',
|
||||||
|
{
|
||||||
|
mode,
|
||||||
|
source,
|
||||||
|
nodes_panel_session_id: getSessionId(now),
|
||||||
|
workflow_id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
withPostHog: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resets session id every time node creator is opened', () => {
|
||||||
|
nodeCreatorStore.onCreatorOpened({
|
||||||
|
source,
|
||||||
|
mode,
|
||||||
|
workflow_id,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(useTelemetry().track).toHaveBeenCalledWith(
|
||||||
|
'User opened nodes panel',
|
||||||
|
{
|
||||||
|
mode,
|
||||||
|
source,
|
||||||
|
nodes_panel_session_id: getSessionId(now),
|
||||||
|
workflow_id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
withPostHog: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
vi.setSystemTime(now1);
|
||||||
|
|
||||||
|
nodeCreatorStore.onCreatorOpened({
|
||||||
|
source,
|
||||||
|
mode,
|
||||||
|
workflow_id,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(useTelemetry().track).toHaveBeenCalledWith(
|
||||||
|
'User opened nodes panel',
|
||||||
|
{
|
||||||
|
mode,
|
||||||
|
source,
|
||||||
|
nodes_panel_session_id: getSessionId(now1),
|
||||||
|
workflow_id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
withPostHog: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tracks event on category expanded', () => {
|
||||||
|
nodeCreatorStore.onCreatorOpened({
|
||||||
|
source,
|
||||||
|
mode,
|
||||||
|
workflow_id,
|
||||||
|
});
|
||||||
|
nodeCreatorStore.onCategoryExpanded({ workflow_id, category_name });
|
||||||
|
|
||||||
|
expect(useTelemetry().track).toHaveBeenCalledWith(
|
||||||
|
'User viewed node category',
|
||||||
|
{
|
||||||
|
category_name,
|
||||||
|
is_subcategory: false,
|
||||||
|
nodes_panel_session_id: getSessionId(now),
|
||||||
|
workflow_id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
withPostHog: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tracks event when node is added to canvas', () => {
|
||||||
|
nodeCreatorStore.onCreatorOpened({
|
||||||
|
source,
|
||||||
|
mode,
|
||||||
|
workflow_id,
|
||||||
|
});
|
||||||
|
nodeCreatorStore.onNodeAddedToCanvas({
|
||||||
|
node_type,
|
||||||
|
node_version,
|
||||||
|
is_auto_add: true,
|
||||||
|
workflow_id,
|
||||||
|
drag_and_drop: true,
|
||||||
|
input_node_type,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(useTelemetry().track).toHaveBeenCalledWith(
|
||||||
|
'User added node to workflow canvas',
|
||||||
|
{
|
||||||
|
node_type,
|
||||||
|
node_version,
|
||||||
|
is_auto_add: true,
|
||||||
|
drag_and_drop: true,
|
||||||
|
input_node_type,
|
||||||
|
nodes_panel_session_id: getSessionId(now),
|
||||||
|
workflow_id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
withPostHog: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tracks event when action is added', () => {
|
||||||
|
nodeCreatorStore.onCreatorOpened({
|
||||||
|
source,
|
||||||
|
mode,
|
||||||
|
workflow_id,
|
||||||
|
});
|
||||||
|
nodeCreatorStore.onAddActions({
|
||||||
|
node_type,
|
||||||
|
action,
|
||||||
|
source_mode,
|
||||||
|
resource,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(useTelemetry().track).toHaveBeenCalledWith(
|
||||||
|
'User added action',
|
||||||
|
{
|
||||||
|
node_type,
|
||||||
|
action,
|
||||||
|
source_mode,
|
||||||
|
resource,
|
||||||
|
nodes_panel_session_id: getSessionId(now),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
withPostHog: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tracks when custom api action is clicked', () => {
|
||||||
|
nodeCreatorStore.onCreatorOpened({
|
||||||
|
source,
|
||||||
|
mode,
|
||||||
|
workflow_id,
|
||||||
|
});
|
||||||
|
nodeCreatorStore.onActionsCustomAPIClicked({
|
||||||
|
app_identifier: node_type,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(useTelemetry().track).toHaveBeenCalledWith(
|
||||||
|
'User clicked custom API from node actions',
|
||||||
|
{
|
||||||
|
app_identifier: node_type,
|
||||||
|
nodes_panel_session_id: getSessionId(now),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
withPostHog: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tracks when action is viewed', () => {
|
||||||
|
nodeCreatorStore.onCreatorOpened({
|
||||||
|
source,
|
||||||
|
mode,
|
||||||
|
workflow_id,
|
||||||
|
});
|
||||||
|
nodeCreatorStore.onViewActions({
|
||||||
|
app_identifier: node_type,
|
||||||
|
actions,
|
||||||
|
regular_action_count: 1,
|
||||||
|
trigger_action_count: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(useTelemetry().track).toHaveBeenCalledWith(
|
||||||
|
'User viewed node actions',
|
||||||
|
{
|
||||||
|
app_identifier: node_type,
|
||||||
|
actions,
|
||||||
|
regular_action_count: 1,
|
||||||
|
trigger_action_count: 2,
|
||||||
|
nodes_panel_session_id: getSessionId(now),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
withPostHog: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tracks when search filter is updated, ignoring custom actions in count', () => {
|
||||||
|
const newValue = 'new-value';
|
||||||
|
const subcategory = 'subcategory';
|
||||||
|
const title = 'title';
|
||||||
|
|
||||||
|
const mockTrigger = {
|
||||||
|
key: 'n8n-node.exampleTrigger',
|
||||||
|
properties: {
|
||||||
|
name: 'n8n-node.exampleTrigge',
|
||||||
|
displayName: 'Example Trigger',
|
||||||
|
},
|
||||||
|
} as INodeCreateElement;
|
||||||
|
|
||||||
|
const mockCustom = {
|
||||||
|
key: 'action',
|
||||||
|
properties: {
|
||||||
|
actionKey: CUSTOM_API_CALL_KEY,
|
||||||
|
},
|
||||||
|
} as INodeCreateElement;
|
||||||
|
|
||||||
|
const mockRegular = {
|
||||||
|
key: 'n8n-node.example',
|
||||||
|
properties: {},
|
||||||
|
} as INodeCreateElement;
|
||||||
|
|
||||||
|
nodeCreatorStore.onCreatorOpened({
|
||||||
|
source,
|
||||||
|
mode,
|
||||||
|
workflow_id,
|
||||||
|
});
|
||||||
|
nodeCreatorStore.onNodeFilterChanged({
|
||||||
|
newValue,
|
||||||
|
filteredNodes: [mockCustom, mockRegular, mockTrigger],
|
||||||
|
filterMode: REGULAR_NODE_CREATOR_VIEW,
|
||||||
|
subcategory,
|
||||||
|
title,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(useTelemetry().track).toHaveBeenCalledWith(
|
||||||
|
'User entered nodes panel search term',
|
||||||
|
{
|
||||||
|
search_string: newValue,
|
||||||
|
filter_mode: 'regular',
|
||||||
|
category_name: subcategory,
|
||||||
|
results_count: 2,
|
||||||
|
trigger_count: 1,
|
||||||
|
regular_count: 1,
|
||||||
|
nodes_panel_session_id: getSessionId(now),
|
||||||
|
title,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
withPostHog: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function getSessionId(time: number) {
|
||||||
|
return `nodes_panel_session_${time}`;
|
||||||
|
}
|
|
@ -1,6 +1,8 @@
|
||||||
import { defineStore } from 'pinia';
|
import { defineStore } from 'pinia';
|
||||||
import {
|
import {
|
||||||
AI_NODE_CREATOR_VIEW,
|
AI_NODE_CREATOR_VIEW,
|
||||||
|
AI_OTHERS_NODE_CREATOR_VIEW,
|
||||||
|
CUSTOM_API_CALL_KEY,
|
||||||
NODE_CREATOR_OPEN_SOURCES,
|
NODE_CREATOR_OPEN_SOURCES,
|
||||||
REGULAR_NODE_CREATOR_VIEW,
|
REGULAR_NODE_CREATOR_VIEW,
|
||||||
STORES,
|
STORES,
|
||||||
|
@ -12,17 +14,17 @@ import type {
|
||||||
SimplifiedNodeType,
|
SimplifiedNodeType,
|
||||||
ActionsRecord,
|
ActionsRecord,
|
||||||
ToggleNodeCreatorOptions,
|
ToggleNodeCreatorOptions,
|
||||||
|
INodeCreateElement,
|
||||||
} from '@/Interface';
|
} from '@/Interface';
|
||||||
|
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import { transformNodeType } from '@/components/Node/NodeCreator/utils';
|
import { transformNodeType } from '@/components/Node/NodeCreator/utils';
|
||||||
import type { INodeInputConfiguration } from 'n8n-workflow';
|
import type { IDataObject, INodeInputConfiguration, NodeParameterValueType } from 'n8n-workflow';
|
||||||
import { NodeConnectionType, nodeConnectionTypes, NodeHelpers } from 'n8n-workflow';
|
import { NodeConnectionType, nodeConnectionTypes, NodeHelpers } from 'n8n-workflow';
|
||||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||||
import { useUIStore } from '@/stores/ui.store';
|
import { useUIStore } from '@/stores/ui.store';
|
||||||
import { useNDVStore } from '@/stores/ndv.store';
|
import { useNDVStore } from '@/stores/ndv.store';
|
||||||
import { useExternalHooks } from '@/composables/useExternalHooks';
|
import { useExternalHooks } from '@/composables/useExternalHooks';
|
||||||
import { useTelemetry } from '@/composables/useTelemetry';
|
|
||||||
import { useViewStacks } from '@/components/Node/NodeCreator/composables/useViewStacks';
|
import { useViewStacks } from '@/components/Node/NodeCreator/composables/useViewStacks';
|
||||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||||
import {
|
import {
|
||||||
|
@ -33,15 +35,15 @@ import type { Connection } from '@vue-flow/core';
|
||||||
import { CanvasConnectionMode } from '@/types';
|
import { CanvasConnectionMode } from '@/types';
|
||||||
import { isVueFlowConnection } from '@/utils/typeGuards';
|
import { isVueFlowConnection } from '@/utils/typeGuards';
|
||||||
import type { PartialBy } from '@/utils/typeHelpers';
|
import type { PartialBy } from '@/utils/typeHelpers';
|
||||||
|
import { useTelemetry } from '@/composables/useTelemetry';
|
||||||
|
|
||||||
export const useNodeCreatorStore = defineStore(STORES.NODE_CREATOR, () => {
|
export const useNodeCreatorStore = defineStore(STORES.NODE_CREATOR, () => {
|
||||||
const workflowsStore = useWorkflowsStore();
|
const workflowsStore = useWorkflowsStore();
|
||||||
const ndvStore = useNDVStore();
|
const ndvStore = useNDVStore();
|
||||||
const uiStore = useUIStore();
|
const uiStore = useUIStore();
|
||||||
const nodeTypesStore = useNodeTypesStore();
|
const nodeTypesStore = useNodeTypesStore();
|
||||||
|
|
||||||
const externalHooks = useExternalHooks();
|
|
||||||
const telemetry = useTelemetry();
|
const telemetry = useTelemetry();
|
||||||
|
const externalHooks = useExternalHooks();
|
||||||
|
|
||||||
const selectedView = ref<NodeFilterType>(TRIGGER_NODE_CREATOR_VIEW);
|
const selectedView = ref<NodeFilterType>(TRIGGER_NODE_CREATOR_VIEW);
|
||||||
const mergedNodes = ref<SimplifiedNodeType[]>([]);
|
const mergedNodes = ref<SimplifiedNodeType[]>([]);
|
||||||
|
@ -50,6 +52,10 @@ export const useNodeCreatorStore = defineStore(STORES.NODE_CREATOR, () => {
|
||||||
const showScrim = ref(false);
|
const showScrim = ref(false);
|
||||||
const openSource = ref<NodeCreatorOpenSource>('');
|
const openSource = ref<NodeCreatorOpenSource>('');
|
||||||
|
|
||||||
|
const isCreateNodeActive = ref<boolean>(false);
|
||||||
|
|
||||||
|
const nodePanelSessionId = ref<string>('');
|
||||||
|
|
||||||
const allNodeCreatorNodes = computed(() =>
|
const allNodeCreatorNodes = computed(() =>
|
||||||
Object.values(mergedNodes.value).map((i) => transformNodeType(i)),
|
Object.values(mergedNodes.value).map((i) => transformNodeType(i)),
|
||||||
);
|
);
|
||||||
|
@ -115,7 +121,7 @@ export const useNodeCreatorStore = defineStore(STORES.NODE_CREATOR, () => {
|
||||||
createNodeActive,
|
createNodeActive,
|
||||||
nodeCreatorView,
|
nodeCreatorView,
|
||||||
}: ToggleNodeCreatorOptions) {
|
}: ToggleNodeCreatorOptions) {
|
||||||
if (createNodeActive === uiStore.isCreateNodeActive) {
|
if (createNodeActive === isCreateNodeActive.value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -128,54 +134,24 @@ export const useNodeCreatorStore = defineStore(STORES.NODE_CREATOR, () => {
|
||||||
// Default to the trigger tab in node creator if there's no trigger node yet
|
// Default to the trigger tab in node creator if there's no trigger node yet
|
||||||
setSelectedView(nodeCreatorView);
|
setSelectedView(nodeCreatorView);
|
||||||
|
|
||||||
let mode;
|
isCreateNodeActive.value = createNodeActive;
|
||||||
switch (selectedView.value) {
|
|
||||||
case AI_NODE_CREATOR_VIEW:
|
|
||||||
mode = 'ai';
|
|
||||||
break;
|
|
||||||
case REGULAR_NODE_CREATOR_VIEW:
|
|
||||||
mode = 'regular';
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
mode = 'regular';
|
|
||||||
}
|
|
||||||
|
|
||||||
uiStore.isCreateNodeActive = createNodeActive;
|
|
||||||
if (createNodeActive && source) {
|
if (createNodeActive && source) {
|
||||||
setOpenSource(source);
|
setOpenSource(source);
|
||||||
}
|
}
|
||||||
|
|
||||||
void externalHooks.run('nodeView.createNodeActiveChanged', {
|
void externalHooks.run('nodeView.createNodeActiveChanged', {
|
||||||
source,
|
source,
|
||||||
mode,
|
mode: getMode(nodeCreatorView),
|
||||||
createNodeActive,
|
createNodeActive,
|
||||||
});
|
});
|
||||||
|
|
||||||
trackNodesPanelActiveChanged({
|
if (createNodeActive) {
|
||||||
source,
|
onCreatorOpened({
|
||||||
mode,
|
source,
|
||||||
createNodeActive,
|
mode: getMode(nodeCreatorView),
|
||||||
workflowId: workflowsStore.workflowId,
|
workflow_id: workflowsStore.workflowId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function trackNodesPanelActiveChanged({
|
|
||||||
source,
|
|
||||||
mode,
|
|
||||||
createNodeActive,
|
|
||||||
workflowId,
|
|
||||||
}: {
|
|
||||||
source?: string;
|
|
||||||
mode?: string;
|
|
||||||
createNodeActive?: boolean;
|
|
||||||
workflowId?: string;
|
|
||||||
}) {
|
|
||||||
telemetry.trackNodesPanel('nodeView.createNodeActiveChanged', {
|
|
||||||
source,
|
|
||||||
mode,
|
|
||||||
createNodeActive,
|
|
||||||
workflow_id: workflowId,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function openNodeCreatorForConnectingNode({
|
function openNodeCreatorForConnectingNode({
|
||||||
|
@ -264,7 +240,150 @@ export const useNodeCreatorStore = defineStore(STORES.NODE_CREATOR, () => {
|
||||||
return filter;
|
return filter;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resetNodesPanelSession() {
|
||||||
|
nodePanelSessionId.value = `nodes_panel_session_${new Date().valueOf()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function trackNodeCreatorEvent(event: string, properties: IDataObject = {}, withPostHog = false) {
|
||||||
|
telemetry.track(
|
||||||
|
event,
|
||||||
|
{
|
||||||
|
...properties,
|
||||||
|
nodes_panel_session_id: nodePanelSessionId.value,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
withPostHog,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCreatorOpened({
|
||||||
|
source,
|
||||||
|
mode,
|
||||||
|
workflow_id,
|
||||||
|
}: {
|
||||||
|
source?: string;
|
||||||
|
mode: string;
|
||||||
|
workflow_id?: string;
|
||||||
|
}) {
|
||||||
|
resetNodesPanelSession();
|
||||||
|
trackNodeCreatorEvent('User opened nodes panel', {
|
||||||
|
source,
|
||||||
|
mode,
|
||||||
|
workflow_id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function onNodeFilterChanged({
|
||||||
|
newValue,
|
||||||
|
filteredNodes,
|
||||||
|
filterMode,
|
||||||
|
subcategory,
|
||||||
|
title,
|
||||||
|
}: {
|
||||||
|
newValue: string;
|
||||||
|
filteredNodes: INodeCreateElement[];
|
||||||
|
filterMode: NodeFilterType;
|
||||||
|
subcategory?: string;
|
||||||
|
title?: string;
|
||||||
|
}) {
|
||||||
|
if (!newValue.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { results_count, trigger_count, regular_count } = filteredNodes.reduce(
|
||||||
|
(accu, node) => {
|
||||||
|
if (!('properties' in node)) {
|
||||||
|
return accu;
|
||||||
|
}
|
||||||
|
const isCustomAction =
|
||||||
|
'actionKey' in node.properties && node.properties.actionKey === CUSTOM_API_CALL_KEY;
|
||||||
|
if (isCustomAction) {
|
||||||
|
return accu;
|
||||||
|
}
|
||||||
|
const isTrigger = node.key.includes('Trigger');
|
||||||
|
return {
|
||||||
|
results_count: accu.results_count + 1,
|
||||||
|
trigger_count: accu.trigger_count + (isTrigger ? 1 : 0),
|
||||||
|
regular_count: accu.regular_count + (isTrigger ? 0 : 1),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
{
|
||||||
|
results_count: 0,
|
||||||
|
trigger_count: 0,
|
||||||
|
regular_count: 0,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
trackNodeCreatorEvent('User entered nodes panel search term', {
|
||||||
|
search_string: newValue,
|
||||||
|
filter_mode: getMode(filterMode),
|
||||||
|
category_name: subcategory,
|
||||||
|
results_count,
|
||||||
|
trigger_count,
|
||||||
|
regular_count,
|
||||||
|
title,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCategoryExpanded(properties: { category_name: string; workflow_id: string }) {
|
||||||
|
trackNodeCreatorEvent('User viewed node category', { ...properties, is_subcategory: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
function onViewActions(properties: {
|
||||||
|
app_identifier: string;
|
||||||
|
actions: string[];
|
||||||
|
regular_action_count: number;
|
||||||
|
trigger_action_count: number;
|
||||||
|
}) {
|
||||||
|
trackNodeCreatorEvent('User viewed node actions', properties);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onActionsCustomAPIClicked(properties: { app_identifier: string }) {
|
||||||
|
trackNodeCreatorEvent('User clicked custom API from node actions', properties);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onAddActions(properties: {
|
||||||
|
node_type?: string;
|
||||||
|
action: string;
|
||||||
|
source_mode: string;
|
||||||
|
resource: NodeParameterValueType;
|
||||||
|
}) {
|
||||||
|
trackNodeCreatorEvent('User added action', properties);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSubcategorySelected(properties: { subcategory: string }) {
|
||||||
|
trackNodeCreatorEvent('User viewed node category', {
|
||||||
|
category_name: properties.subcategory,
|
||||||
|
is_subcategory: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function onNodeAddedToCanvas(properties: {
|
||||||
|
node_type: string;
|
||||||
|
node_version: number;
|
||||||
|
is_auto_add?: boolean;
|
||||||
|
workflow_id: string;
|
||||||
|
drag_and_drop?: boolean;
|
||||||
|
input_node_type?: string;
|
||||||
|
}) {
|
||||||
|
trackNodeCreatorEvent('User added node to workflow canvas', properties, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMode(mode: NodeFilterType): string {
|
||||||
|
if (mode === AI_NODE_CREATOR_VIEW || mode === AI_OTHERS_NODE_CREATOR_VIEW) {
|
||||||
|
return 'ai';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === TRIGGER_NODE_CREATOR_VIEW) {
|
||||||
|
return 'trigger';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'regular';
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
isCreateNodeActive,
|
||||||
openSource,
|
openSource,
|
||||||
selectedView,
|
selectedView,
|
||||||
showScrim,
|
showScrim,
|
||||||
|
@ -280,5 +399,13 @@ export const useNodeCreatorStore = defineStore(STORES.NODE_CREATOR, () => {
|
||||||
openNodeCreatorForConnectingNode,
|
openNodeCreatorForConnectingNode,
|
||||||
openNodeCreatorForTriggerNodes,
|
openNodeCreatorForTriggerNodes,
|
||||||
allNodeCreatorNodes,
|
allNodeCreatorNodes,
|
||||||
|
onCreatorOpened,
|
||||||
|
onNodeFilterChanged,
|
||||||
|
onCategoryExpanded,
|
||||||
|
onActionsCustomAPIClicked,
|
||||||
|
onViewActions,
|
||||||
|
onAddActions,
|
||||||
|
onSubcategorySelected,
|
||||||
|
onNodeAddedToCanvas,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
|
@ -192,7 +192,6 @@ export const useUIStore = defineStore(STORES.UI, () => {
|
||||||
const bannersHeight = ref<number>(0);
|
const bannersHeight = ref<number>(0);
|
||||||
const bannerStack = ref<BannerName[]>([]);
|
const bannerStack = ref<BannerName[]>([]);
|
||||||
const pendingNotificationsForViews = ref<{ [key in VIEWS]?: NotificationOptions[] }>({});
|
const pendingNotificationsForViews = ref<{ [key in VIEWS]?: NotificationOptions[] }>({});
|
||||||
const isCreateNodeActive = ref<boolean>(false);
|
|
||||||
|
|
||||||
const appGridWidth = ref<number>(0);
|
const appGridWidth = ref<number>(0);
|
||||||
|
|
||||||
|
@ -659,7 +658,6 @@ export const useUIStore = defineStore(STORES.UI, () => {
|
||||||
nodeViewMoveInProgress,
|
nodeViewMoveInProgress,
|
||||||
nodeViewInitialized,
|
nodeViewInitialized,
|
||||||
addFirstStepOnLoad,
|
addFirstStepOnLoad,
|
||||||
isCreateNodeActive,
|
|
||||||
sidebarMenuCollapsed,
|
sidebarMenuCollapsed,
|
||||||
fakeDoorFeatures,
|
fakeDoorFeatures,
|
||||||
bannerStack,
|
bannerStack,
|
||||||
|
|
|
@ -1450,7 +1450,7 @@ function selectNodes(ids: string[]) {
|
||||||
|
|
||||||
function onClickPane(position: CanvasNode['position']) {
|
function onClickPane(position: CanvasNode['position']) {
|
||||||
lastClickPosition.value = [position.x, position.y];
|
lastClickPosition.value = [position.x, position.y];
|
||||||
uiStore.isCreateNodeActive = false;
|
nodeCreatorStore.isCreateNodeActive = false;
|
||||||
setNodeSelected();
|
setNodeSelected();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1643,7 +1643,7 @@ onBeforeUnmount(() => {
|
||||||
<Suspense>
|
<Suspense>
|
||||||
<LazyNodeCreation
|
<LazyNodeCreation
|
||||||
v-if="!isCanvasReadOnly"
|
v-if="!isCanvasReadOnly"
|
||||||
:create-node-active="uiStore.isCreateNodeActive"
|
:create-node-active="nodeCreatorStore.isCreateNodeActive"
|
||||||
:node-view-scale="viewportTransform.zoom"
|
:node-view-scale="viewportTransform.zoom"
|
||||||
@toggle-node-creator="onToggleNodeCreator"
|
@toggle-node-creator="onToggleNodeCreator"
|
||||||
@add-nodes="onAddNodesAndConnections"
|
@add-nodes="onAddNodesAndConnections"
|
||||||
|
|
|
@ -71,7 +71,6 @@ import type {
|
||||||
INodeInputConfiguration,
|
INodeInputConfiguration,
|
||||||
INodeTypeDescription,
|
INodeTypeDescription,
|
||||||
ITaskData,
|
ITaskData,
|
||||||
ITelemetryTrackProperties,
|
|
||||||
IWorkflowBase,
|
IWorkflowBase,
|
||||||
Workflow,
|
Workflow,
|
||||||
INodeOutputConfiguration,
|
INodeOutputConfiguration,
|
||||||
|
@ -2398,24 +2397,19 @@ export default defineComponent({
|
||||||
this.uiStore.stateIsDirty = true;
|
this.uiStore.stateIsDirty = true;
|
||||||
|
|
||||||
if (nodeTypeName === STICKY_NODE_TYPE) {
|
if (nodeTypeName === STICKY_NODE_TYPE) {
|
||||||
this.$telemetry.trackNodesPanel('nodeView.addSticky', {
|
this.$telemetry.track('User inserted workflow note', {
|
||||||
workflow_id: this.workflowsStore.workflowId,
|
workflow_id: this.workflowsStore.workflowId,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
void this.externalHooks.run('nodeView.addNodeButton', { nodeTypeName });
|
void this.externalHooks.run('nodeView.addNodeButton', { nodeTypeName });
|
||||||
const trackProperties: ITelemetryTrackProperties = {
|
this.nodeCreatorStore.onNodeAddedToCanvas({
|
||||||
node_type: nodeTypeName,
|
node_type: nodeTypeName,
|
||||||
node_version: newNodeData.typeVersion,
|
node_version: newNodeData.typeVersion,
|
||||||
is_auto_add: isAutoAdd,
|
is_auto_add: isAutoAdd,
|
||||||
workflow_id: this.workflowsStore.workflowId,
|
workflow_id: this.workflowsStore.workflowId,
|
||||||
drag_and_drop: options.dragAndDrop,
|
drag_and_drop: options.dragAndDrop,
|
||||||
};
|
input_node_type: lastSelectedNode ? lastSelectedNode.type : undefined,
|
||||||
|
});
|
||||||
if (lastSelectedNode) {
|
|
||||||
trackProperties.input_node_type = lastSelectedNode.type;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.$telemetry.trackNodesPanel('nodeView.addNodeButton', trackProperties);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Automatically deselect all nodes and select the current one and also active
|
// Automatically deselect all nodes and select the current one and also active
|
||||||
|
@ -4242,12 +4236,13 @@ export default defineComponent({
|
||||||
mode,
|
mode,
|
||||||
createNodeActive,
|
createNodeActive,
|
||||||
});
|
});
|
||||||
this.$telemetry.trackNodesPanel('nodeView.createNodeActiveChanged', {
|
if (createNodeActive) {
|
||||||
source,
|
this.nodeCreatorStore.onCreatorOpened({
|
||||||
mode,
|
source,
|
||||||
createNodeActive,
|
mode,
|
||||||
workflow_id: this.workflowsStore.workflowId,
|
workflow_id: this.workflowsStore.workflowId,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
},
|
},
|
||||||
async onAddNodes(
|
async onAddNodes(
|
||||||
{ nodes, connections }: AddedNodesAndConnections,
|
{ nodes, connections }: AddedNodesAndConnections,
|
||||||
|
|
|
@ -8,7 +8,7 @@ import type { IUser } from '@/Interface';
|
||||||
import { useI18n } from '@/composables/useI18n';
|
import { useI18n } from '@/composables/useI18n';
|
||||||
import { useProjectsStore } from '@/stores/projects.store';
|
import { useProjectsStore } from '@/stores/projects.store';
|
||||||
import ProjectTabs from '@/components/Projects/ProjectTabs.vue';
|
import ProjectTabs from '@/components/Projects/ProjectTabs.vue';
|
||||||
import type { Project, ProjectRelation } from '@/types/projects.types';
|
import { type Project, type ProjectRelation, ProjectTypes } from '@/types/projects.types';
|
||||||
import { useToast } from '@/composables/useToast';
|
import { useToast } from '@/composables/useToast';
|
||||||
import { VIEWS } from '@/constants';
|
import { VIEWS } from '@/constants';
|
||||||
import ProjectDeleteDialog from '@/components/Projects/ProjectDeleteDialog.vue';
|
import ProjectDeleteDialog from '@/components/Projects/ProjectDeleteDialog.vue';
|
||||||
|
@ -18,6 +18,7 @@ import type { ProjectRole } from '@/types/roles.types';
|
||||||
import { useCloudPlanStore } from '@/stores/cloudPlan.store';
|
import { useCloudPlanStore } from '@/stores/cloudPlan.store';
|
||||||
import { useTelemetry } from '@/composables/useTelemetry';
|
import { useTelemetry } from '@/composables/useTelemetry';
|
||||||
import { useDocumentTitle } from '@/composables/useDocumentTitle';
|
import { useDocumentTitle } from '@/composables/useDocumentTitle';
|
||||||
|
import ResourceListHeader from '@/components/layouts/ResourceListHeader.vue';
|
||||||
|
|
||||||
type FormDataDiff = {
|
type FormDataDiff = {
|
||||||
name?: string;
|
name?: string;
|
||||||
|
@ -247,6 +248,26 @@ watch(
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const headerIcon = computed(() => {
|
||||||
|
if (projectsStore.currentProject?.type === ProjectTypes.Personal) {
|
||||||
|
return 'user';
|
||||||
|
} else if (projectsStore.currentProject?.name) {
|
||||||
|
return 'layer-group';
|
||||||
|
} else {
|
||||||
|
return 'home';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const projectName = computed(() => {
|
||||||
|
if (!projectsStore.currentProject) {
|
||||||
|
return locale.baseText('projects.menu.home');
|
||||||
|
} else if (projectsStore.currentProject.type === ProjectTypes.Personal) {
|
||||||
|
return locale.baseText('projects.menu.personal');
|
||||||
|
} else {
|
||||||
|
return projectsStore.currentProject.name;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
onBeforeMount(async () => {
|
onBeforeMount(async () => {
|
||||||
await usersStore.fetchUsers();
|
await usersStore.fetchUsers();
|
||||||
});
|
});
|
||||||
|
@ -260,6 +281,11 @@ onMounted(() => {
|
||||||
<template>
|
<template>
|
||||||
<div :class="$style.projectSettings">
|
<div :class="$style.projectSettings">
|
||||||
<div :class="$style.header">
|
<div :class="$style.header">
|
||||||
|
<ResourceListHeader :icon="headerIcon" data-test-id="list-layout-header">
|
||||||
|
<template #title>
|
||||||
|
{{ projectName }}
|
||||||
|
</template>
|
||||||
|
</ResourceListHeader>
|
||||||
<ProjectTabs />
|
<ProjectTabs />
|
||||||
</div>
|
</div>
|
||||||
<form @submit.prevent="onSubmit">
|
<form @submit.prevent="onSubmit">
|
||||||
|
|
|
@ -53,17 +53,17 @@ describe('WorkflowsView', () => {
|
||||||
|
|
||||||
describe('should show empty state', () => {
|
describe('should show empty state', () => {
|
||||||
it('for non setup user', () => {
|
it('for non setup user', () => {
|
||||||
const { getByRole } = renderComponent({ pinia: createTestingPinia({ initialState }) });
|
const { getByText } = renderComponent({ pinia: createTestingPinia({ initialState }) });
|
||||||
expect(getByRole('heading').textContent).toBe('👋 Welcome!');
|
expect(getByText('👋 Welcome!')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('for currentUser user', () => {
|
it('for currentUser user', () => {
|
||||||
const pinia = createTestingPinia({ initialState });
|
const pinia = createTestingPinia({ initialState });
|
||||||
const userStore = mockedStore(useUsersStore);
|
const userStore = mockedStore(useUsersStore);
|
||||||
userStore.currentUser = { firstName: 'John' } as IUser;
|
userStore.currentUser = { firstName: 'John' } as IUser;
|
||||||
const { getByRole } = renderComponent({ pinia });
|
const { getByText } = renderComponent({ pinia });
|
||||||
|
|
||||||
expect(getByRole('heading').textContent).toBe('👋 Welcome John!');
|
expect(getByText('👋 Welcome John!')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('when onboardingExperiment -> False', () => {
|
describe('when onboardingExperiment -> False', () => {
|
||||||
|
|
|
@ -1,4 +1,9 @@
|
||||||
import type { ICredentialType, INodeProperties } from 'n8n-workflow';
|
import type {
|
||||||
|
IAuthenticateGeneric,
|
||||||
|
ICredentialTestRequest,
|
||||||
|
ICredentialType,
|
||||||
|
INodeProperties,
|
||||||
|
} from 'n8n-workflow';
|
||||||
|
|
||||||
export class IntercomApi implements ICredentialType {
|
export class IntercomApi implements ICredentialType {
|
||||||
name = 'intercomApi';
|
name = 'intercomApi';
|
||||||
|
@ -16,4 +21,22 @@ export class IntercomApi implements ICredentialType {
|
||||||
default: '',
|
default: '',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
authenticate: IAuthenticateGeneric = {
|
||||||
|
type: 'generic',
|
||||||
|
properties: {
|
||||||
|
headers: {
|
||||||
|
Authorization: '=Bearer {{$credentials.apiKey}}',
|
||||||
|
Accept: 'application/json',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
test: ICredentialTestRequest = {
|
||||||
|
request: {
|
||||||
|
baseURL: 'https://api.intercom.io',
|
||||||
|
url: '/me',
|
||||||
|
method: 'GET',
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,7 +4,7 @@ import type {
|
||||||
IHookFunctions,
|
IHookFunctions,
|
||||||
ILoadOptionsFunctions,
|
ILoadOptionsFunctions,
|
||||||
JsonObject,
|
JsonObject,
|
||||||
IRequestOptions,
|
IHttpRequestOptions,
|
||||||
IHttpRequestMethods,
|
IHttpRequestMethods,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
import { NodeApiError } from 'n8n-workflow';
|
import { NodeApiError } from 'n8n-workflow';
|
||||||
|
@ -18,24 +18,16 @@ export async function intercomApiRequest(
|
||||||
query?: IDataObject,
|
query?: IDataObject,
|
||||||
uri?: string,
|
uri?: string,
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
const credentials = await this.getCredentials('intercomApi');
|
const options: IHttpRequestOptions = {
|
||||||
|
|
||||||
const headerWithAuthentication = Object.assign(
|
|
||||||
{},
|
|
||||||
{ Authorization: `Bearer ${credentials.apiKey}`, Accept: 'application/json' },
|
|
||||||
);
|
|
||||||
|
|
||||||
const options: IRequestOptions = {
|
|
||||||
headers: headerWithAuthentication,
|
|
||||||
method,
|
method,
|
||||||
qs: query,
|
qs: query,
|
||||||
uri: uri || `https://api.intercom.io${endpoint}`,
|
url: uri ?? `https://api.intercom.io${endpoint}`,
|
||||||
body,
|
body,
|
||||||
json: true,
|
json: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await this.helpers.request(options);
|
return await this.helpers.httpRequestWithAuthentication.call(this, 'intercomApi', options);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new NodeApiError(this.getNode(), error as JsonObject);
|
throw new NodeApiError(this.getNode(), error as JsonObject);
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue