fix(core): Add optional context parameter to track creation source for workflows, credentials, and projects (#18736)

Co-authored-by: r00gm <raul00gm@gmail.com>
This commit is contained in:
Csaba Tuncsik
2025-08-27 10:50:53 +02:00
committed by GitHub
parent e665cbf278
commit 98bde4f478
20 changed files with 173 additions and 22 deletions

View File

@@ -6,4 +6,5 @@ export class CreateCredentialDto extends Z.class({
type: z.string().min(1).max(128),
data: z.record(z.string(), z.unknown()),
projectId: z.string().optional(),
uiContext: z.string().optional(),
}) {}

View File

@@ -1,3 +1,4 @@
import { z } from 'zod';
import { Z } from 'zod-class';
import { projectIconSchema, projectNameSchema } from '../../schemas/project.schema';
@@ -5,4 +6,5 @@ import { projectIconSchema, projectNameSchema } from '../../schemas/project.sche
export class CreateProjectDto extends Z.class({
name: projectNameSchema,
icon: projectIconSchema.optional(),
uiContext: z.string().optional(),
}) {}

View File

@@ -66,6 +66,7 @@ export class ProjectController {
this.eventService.emit('team-project-created', {
userId: req.user.id,
role: req.user.role.slug,
uiContext: payload.uiContext,
});
return {

View File

@@ -80,6 +80,7 @@ describe('CredentialsController', () => {
projectId: projectOwningCredentialData.id,
projectType: projectOwningCredentialData.type,
publicApi: false,
uiContext: newCredentialsPayload.uiContext,
});
expect(newApiKey).toEqual(createdCredentials);

View File

@@ -192,6 +192,7 @@ export class CredentialsController {
publicApi: false,
projectId: project?.id,
projectType: project?.type,
uiContext: payload.uiContext,
});
return newCredential;

View File

@@ -61,6 +61,7 @@ export type RelayEventMap = {
publicApi: boolean;
projectId: string;
projectType: string;
uiContext?: string;
};
'workflow-deleted': {
@@ -292,6 +293,7 @@ export type RelayEventMap = {
publicApi: boolean;
projectId?: string;
projectType?: string;
uiContext?: string;
};
'credentials-shared': {
@@ -385,6 +387,7 @@ export type RelayEventMap = {
'team-project-created': {
userId: string;
role: string;
uiContext?: string;
};
// #endregion

View File

@@ -149,10 +149,11 @@ export class TelemetryEventRelay extends EventRelay {
});
}
private teamProjectCreated({ userId, role }: RelayEventMap['team-project-created']) {
private teamProjectCreated({ userId, role, uiContext }: RelayEventMap['team-project-created']) {
this.telemetry.track('User created project', {
user_id: userId,
role,
uiContext,
});
}
@@ -404,6 +405,7 @@ export class TelemetryEventRelay extends EventRelay {
credentialId,
projectId,
projectType,
uiContext,
}: RelayEventMap['credentials-created']) {
this.telemetry.track('User created credentials', {
user_id: user.id,
@@ -411,6 +413,7 @@ export class TelemetryEventRelay extends EventRelay {
credential_id: credentialId,
project_id: projectId,
project_type: projectType,
uiContext,
});
}
@@ -524,6 +527,7 @@ export class TelemetryEventRelay extends EventRelay {
publicApi,
projectId,
projectType,
uiContext,
}: RelayEventMap['workflow-created']) {
const { nodeGraph } = TelemetryHelpers.generateNodesGraph(workflow, this.nodeTypes);
@@ -534,6 +538,7 @@ export class TelemetryEventRelay extends EventRelay {
public_api: publicApi,
project_id: projectId,
project_type: projectType,
uiContext,
});
}

View File

@@ -25,6 +25,7 @@ export declare namespace WorkflowRequest {
meta: Record<string, unknown>;
projectId: string;
parentFolderId?: string;
uiContext?: string;
}>;
type ManualRunPayload = {

View File

@@ -218,6 +218,7 @@ export class WorkflowsController {
publicApi: false,
projectId: project!.id,
projectType: project!.type,
uiContext: req.body.uiContext,
});
const scopes = await this.workflowService.getWorkflowScopes(req.user, savedWorkflow.id);

View File

@@ -822,6 +822,25 @@ describe('POST /credentials', () => {
expect(sharedCredential.credentials.name).toBe(payload.name);
});
test('should create cred with uiContext parameter', async () => {
const payload = { ...randomCredentialPayload(), uiContext: 'credentials_list' };
const response = await authMemberAgent.post('/credentials').send(payload);
expect(response.statusCode).toBe(200);
const { id, name, type } = response.body.data;
expect(name).toBe(payload.name);
expect(type).toBe(payload.type);
const credential = await getCredentialById(id);
a.ok(credential);
expect(credential.name).toBe(payload.name);
expect(credential.type).toBe(payload.type);
});
test('should fail with invalid inputs', async () => {
for (const invalidPayload of INVALID_PAYLOADS) {
const response = await authOwnerAgent.post('/credentials').send(invalidPayload);

View File

@@ -398,6 +398,23 @@ describe('POST /projects/', () => {
}
});
test('should create a team project with context parameter', async () => {
const ownerUser = await createOwner();
const ownerAgent = testServer.authAgentFor(ownerUser);
const resp = await ownerAgent.post('/projects/').send({
name: 'Test Team Project with Context',
uiContext: 'universal_button',
});
expect(resp.status).toBe(200);
const respProject = resp.body.data as Project;
expect(respProject.name).toEqual('Test Team Project with Context');
expect(async () => {
await findProject(respProject.id);
}).not.toThrow();
expect(resp.body.data.role).toBe('project:admin');
});
test('should allow to create a team projects if below the quota', async () => {
testServer.license.setQuota('quota:maxTeamProjects', 1);
const ownerUser = await createOwner();

View File

@@ -145,6 +145,38 @@ describe('POST /workflows', () => {
);
});
test('should create workflow with uiContext parameter', async () => {
const payload = {
name: 'testing with context',
nodes: [
{
id: 'uuid-1234',
parameters: {},
name: 'Start',
type: 'n8n-nodes-base.start',
typeVersion: 1,
position: [240, 300],
},
],
connections: {},
staticData: null,
settings: {},
active: false,
uiContext: 'workflow_list',
};
const response = await authMemberAgent.post('/workflows').send(payload);
expect(response.statusCode).toBe(200);
const {
data: { id, name },
} = response.body;
expect(id).toBeDefined();
expect(name).toBe('testing with context');
});
test('should create workflow history version when licensed', async () => {
license.enable('feat:workflowHistory');
const payload = {

View File

@@ -35,6 +35,7 @@ export interface WorkflowDataUpdate {
versionId?: string;
meta?: WorkflowMetadata;
parentFolderId?: string;
uiContext?: string;
}
export interface WorkflowDataCreate extends WorkflowDataUpdate {

View File

@@ -52,6 +52,7 @@ import {
import { isCredentialModalState, isValidCredentialResponse } from '@/utils/typeGuards';
import { useI18n } from '@n8n/i18n';
import { useElementSize } from '@vueuse/core';
import { useRouter } from 'vue-router';
type Props = {
modalName: string;
@@ -75,6 +76,7 @@ const toast = useToast();
const message = useMessage();
const i18n = useI18n();
const telemetry = useTelemetry();
const router = useRouter();
const activeTab = ref('connection');
const authError = ref('');
@@ -801,7 +803,16 @@ async function createCredential(
let credential;
try {
credential = await credentialsStore.createNewCredential(credentialDetails, project?.id);
credential = await credentialsStore.createNewCredential(
credentialDetails,
project?.id,
router.currentRoute.value.query.uiContext?.toString(),
);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { uiContext, ...rest } = router.currentRoute.value.query;
void router.replace({ query: rest });
hasUnsavedChanges.value = false;
const { title, message } = createToastMessagingForNewCredentials(project);

View File

@@ -266,13 +266,15 @@ describe('ProjectHeader', () => {
await userEvent.click(getByTestId('action-credential'));
expect(mockPush).toHaveBeenCalledWith({
name: VIEWS.PROJECTS_CREDENTIALS,
params: {
projectId: project.id,
credentialId: 'create',
},
});
expect(mockPush).toHaveBeenCalledWith(
expect.objectContaining({
name: VIEWS.PROJECTS_CREDENTIALS,
params: {
projectId: project.id,
credentialId: 'create',
},
}),
);
});
});

View File

@@ -162,6 +162,37 @@ const showProjectIcon = computed(() => {
);
});
function isCredentialsListView(routeName: string) {
const CREDENTIAL_VIEWS: string[] = [
VIEWS.PROJECTS_CREDENTIALS,
VIEWS.CREDENTIALS,
VIEWS.SHARED_CREDENTIALS,
];
return CREDENTIAL_VIEWS.includes(routeName);
}
function isWorkflowListView(routeName: string) {
const WORKFLOWS_VIEWS: string[] = [
VIEWS.PROJECTS_WORKFLOWS,
VIEWS.WORKFLOWS,
VIEWS.SHARED_WORKFLOWS,
VIEWS.PROJECTS_FOLDERS,
];
return WORKFLOWS_VIEWS.includes(routeName);
}
function getUIContext(routeName: string) {
if (isCredentialsListView(routeName)) {
return 'credentials_list';
} else if (isWorkflowListView(routeName)) {
return 'workflow_list';
} else {
return;
}
}
const actions: Record<ActionTypes, (projectId: string) => void> = {
[ACTION_TYPES.WORKFLOW]: (projectId: string) => {
void router.push({
@@ -169,6 +200,7 @@ const actions: Record<ActionTypes, (projectId: string) => void> = {
query: {
projectId,
parentFolderId: route.params.folderId as string,
uiContext: getUIContext(route.name?.toString() ?? ''),
},
});
},
@@ -179,6 +211,9 @@ const actions: Record<ActionTypes, (projectId: string) => void> = {
projectId,
credentialId: 'create',
},
query: {
uiContext: getUIContext(route.name?.toString() ?? ''),
},
});
},
[ACTION_TYPES.FOLDER]: () => {

View File

@@ -1,14 +1,14 @@
<script lang="ts" setup>
import { computed, onBeforeMount } from 'vue';
import type { IMenuItem } from '@n8n/design-system/types';
import { useI18n } from '@n8n/i18n';
import { useGlobalEntityCreation } from '@/composables/useGlobalEntityCreation';
import { VIEWS } from '@/constants';
import { useProjectsStore } from '@/stores/projects.store';
import type { ProjectListItem } from '@/types/projects.types';
import { useGlobalEntityCreation } from '@/composables/useGlobalEntityCreation';
import { useSettingsStore } from '@/stores/settings.store';
import { useUsersStore } from '@/stores/users.store';
import type { ProjectListItem } from '@/types/projects.types';
import type { IMenuItem } from '@n8n/design-system/types';
import { useI18n } from '@n8n/i18n';
import { ElMenu } from 'element-plus';
import { computed, onBeforeMount } from 'vue';
type Props = {
collapsed: boolean;
@@ -28,7 +28,7 @@ const isCreatingProject = computed(() => globalEntityCreation.isCreatingProject.
const displayProjects = computed(() => globalEntityCreation.displayProjects.value);
const isFoldersFeatureEnabled = computed(() => settingsStore.isFoldersFeatureEnabled);
const hasMultipleVerifiedUsers = computed(
() => usersStore.allUsers.filter((user) => user.isPendingUser === false).length > 1,
() => usersStore.allUsers.filter((user) => !user.isPendingUser).length > 1,
);
const home = computed<IMenuItem>(() => ({
@@ -140,7 +140,7 @@ onBeforeMount(async () => {
data-test-id="project-plus-button"
:disabled="isCreatingProject || !projectsStore.hasPermissionToCreateProjects"
:class="$style.plusBtn"
@click="globalEntityCreation.createProject"
@click="globalEntityCreation.createProject('add_icon')"
/>
</N8nTooltip>
</N8nText>

View File

@@ -184,13 +184,14 @@ export const useGlobalEntityCreation = () => {
] satisfies Item[];
});
const createProject = async () => {
const createProject = async (uiContext?: string) => {
isCreatingProject.value = true;
try {
const newProject = await projectsStore.createProject({
name: i18n.baseText('projects.settings.newProjectName'),
icon: { type: 'icon', value: DEFAULT_ICON },
uiContext,
});
await router.push({ name: VIEWS.PROJECT_SETTINGS, params: { projectId: newProject.id } });
toast.showMessage({
@@ -210,7 +211,7 @@ export const useGlobalEntityCreation = () => {
if (id !== CREATE_PROJECT_ID) return;
if (projectsStore.canCreateProjects && projectsStore.hasPermissionToCreateProjects) {
void createProject();
void createProject('universal_button');
return;
}

View File

@@ -1,6 +1,6 @@
import { useNpsSurveyStore } from '@/stores/npsSurvey.store';
import { useUIStore } from '@/stores/ui.store';
import type { NavigationGuardNext, useRouter } from 'vue-router';
import type { LocationQuery, NavigationGuardNext, useRouter } from 'vue-router';
import { useMessage } from './useMessage';
import { useI18n } from '@n8n/i18n';
import {
@@ -156,6 +156,13 @@ export function useWorkflowSaving({ router }: { router: ReturnType<typeof useRou
return undefined;
}
function getQueryParam(query: LocationQuery, key: string): string | undefined {
const value = query[key];
if (Array.isArray(value)) return value[0] ?? undefined;
if (value === null) return undefined;
return value;
}
async function saveCurrentWorkflow(
{ id, name, tags }: { id?: string; name?: string; tags?: string[] } = {},
redirect = true,
@@ -167,11 +174,12 @@ export function useWorkflowSaving({ router }: { router: ReturnType<typeof useRou
}
const isLoading = useCanvasStore().isLoading;
const currentWorkflow = id || (router.currentRoute.value.params.name as string);
const parentFolderId = router.currentRoute.value.query.parentFolderId as string;
const currentWorkflow = id ?? getQueryParam(router.currentRoute.value.params, 'name');
const parentFolderId = getQueryParam(router.currentRoute.value.query, 'parentFolderId');
const uiContext = getQueryParam(router.currentRoute.value.query, 'uiContext');
if (!currentWorkflow || ['new', PLACEHOLDER_EMPTY_WORKFLOW_ID].includes(currentWorkflow)) {
return !!(await saveAsNewWorkflow({ name, tags, parentFolderId }, redirect));
return !!(await saveAsNewWorkflow({ name, tags, parentFolderId, uiContext }, redirect));
}
// Workflow exists already so update it
@@ -292,6 +300,7 @@ export function useWorkflowSaving({ router }: { router: ReturnType<typeof useRou
resetNodeIds,
openInNewWindow,
parentFolderId,
uiContext,
data,
}: {
name?: string;
@@ -300,6 +309,7 @@ export function useWorkflowSaving({ router }: { router: ReturnType<typeof useRou
openInNewWindow?: boolean;
resetNodeIds?: boolean;
parentFolderId?: string;
uiContext?: string;
data?: WorkflowDataCreate;
} = {},
redirect = true,
@@ -340,6 +350,11 @@ export function useWorkflowSaving({ router }: { router: ReturnType<typeof useRou
if (parentFolderId) {
workflowDataRequest.parentFolderId = parentFolderId;
}
if (uiContext) {
workflowDataRequest.uiContext = uiContext;
}
const workflowData = await workflowsStore.createNewWorkflow(workflowDataRequest);
workflowsStore.addWorkflow(workflowData);

View File

@@ -317,6 +317,7 @@ export const useCredentialsStore = defineStore(STORES.CREDENTIALS, () => {
const createNewCredential = async (
data: ICredentialsDecrypted,
projectId?: string,
uiContext?: string,
): Promise<ICredentialsResponse> => {
const settingsStore = useSettingsStore();
const credential = await credentialsApi.createNewCredential(rootStore.restApiContext, {
@@ -324,6 +325,7 @@ export const useCredentialsStore = defineStore(STORES.CREDENTIALS, () => {
type: data.type,
data: data.data ?? {},
projectId,
uiContext,
});
if (data?.homeProject && !credential.homeProject) {