mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-16 17:46:45 +00:00
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:
@@ -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(),
|
||||
}) {}
|
||||
|
||||
@@ -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(),
|
||||
}) {}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -80,6 +80,7 @@ describe('CredentialsController', () => {
|
||||
projectId: projectOwningCredentialData.id,
|
||||
projectType: projectOwningCredentialData.type,
|
||||
publicApi: false,
|
||||
uiContext: newCredentialsPayload.uiContext,
|
||||
});
|
||||
|
||||
expect(newApiKey).toEqual(createdCredentials);
|
||||
|
||||
@@ -192,6 +192,7 @@ export class CredentialsController {
|
||||
publicApi: false,
|
||||
projectId: project?.id,
|
||||
projectType: project?.type,
|
||||
uiContext: payload.uiContext,
|
||||
});
|
||||
|
||||
return newCredential;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ export declare namespace WorkflowRequest {
|
||||
meta: Record<string, unknown>;
|
||||
projectId: string;
|
||||
parentFolderId?: string;
|
||||
uiContext?: string;
|
||||
}>;
|
||||
|
||||
type ManualRunPayload = {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -35,6 +35,7 @@ export interface WorkflowDataUpdate {
|
||||
versionId?: string;
|
||||
meta?: WorkflowMetadata;
|
||||
parentFolderId?: string;
|
||||
uiContext?: string;
|
||||
}
|
||||
|
||||
export interface WorkflowDataCreate extends WorkflowDataUpdate {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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]: () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user