diff --git a/packages/@n8n/api-types/src/dto/credentials/create-credential.dto.ts b/packages/@n8n/api-types/src/dto/credentials/create-credential.dto.ts index 8dd4461813..7b8690a0a1 100644 --- a/packages/@n8n/api-types/src/dto/credentials/create-credential.dto.ts +++ b/packages/@n8n/api-types/src/dto/credentials/create-credential.dto.ts @@ -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(), }) {} diff --git a/packages/@n8n/api-types/src/dto/project/create-project.dto.ts b/packages/@n8n/api-types/src/dto/project/create-project.dto.ts index cf748f5e13..a585a40695 100644 --- a/packages/@n8n/api-types/src/dto/project/create-project.dto.ts +++ b/packages/@n8n/api-types/src/dto/project/create-project.dto.ts @@ -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(), }) {} diff --git a/packages/cli/src/controllers/project.controller.ts b/packages/cli/src/controllers/project.controller.ts index 98725fc443..e873856031 100644 --- a/packages/cli/src/controllers/project.controller.ts +++ b/packages/cli/src/controllers/project.controller.ts @@ -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 { diff --git a/packages/cli/src/credentials/__tests__/credentials.controller.test.ts b/packages/cli/src/credentials/__tests__/credentials.controller.test.ts index 8a4c99876d..6ac0518ae8 100644 --- a/packages/cli/src/credentials/__tests__/credentials.controller.test.ts +++ b/packages/cli/src/credentials/__tests__/credentials.controller.test.ts @@ -80,6 +80,7 @@ describe('CredentialsController', () => { projectId: projectOwningCredentialData.id, projectType: projectOwningCredentialData.type, publicApi: false, + uiContext: newCredentialsPayload.uiContext, }); expect(newApiKey).toEqual(createdCredentials); diff --git a/packages/cli/src/credentials/credentials.controller.ts b/packages/cli/src/credentials/credentials.controller.ts index 9483cdada0..53dab5bba9 100644 --- a/packages/cli/src/credentials/credentials.controller.ts +++ b/packages/cli/src/credentials/credentials.controller.ts @@ -192,6 +192,7 @@ export class CredentialsController { publicApi: false, projectId: project?.id, projectType: project?.type, + uiContext: payload.uiContext, }); return newCredential; diff --git a/packages/cli/src/events/maps/relay.event-map.ts b/packages/cli/src/events/maps/relay.event-map.ts index 0967c9314d..aba94c79cd 100644 --- a/packages/cli/src/events/maps/relay.event-map.ts +++ b/packages/cli/src/events/maps/relay.event-map.ts @@ -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 diff --git a/packages/cli/src/events/relays/telemetry.event-relay.ts b/packages/cli/src/events/relays/telemetry.event-relay.ts index 894c936dd3..0f0e159a68 100644 --- a/packages/cli/src/events/relays/telemetry.event-relay.ts +++ b/packages/cli/src/events/relays/telemetry.event-relay.ts @@ -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, }); } diff --git a/packages/cli/src/workflows/workflow.request.ts b/packages/cli/src/workflows/workflow.request.ts index 62f508a643..1bbd66b275 100644 --- a/packages/cli/src/workflows/workflow.request.ts +++ b/packages/cli/src/workflows/workflow.request.ts @@ -25,6 +25,7 @@ export declare namespace WorkflowRequest { meta: Record; projectId: string; parentFolderId?: string; + uiContext?: string; }>; type ManualRunPayload = { diff --git a/packages/cli/src/workflows/workflows.controller.ts b/packages/cli/src/workflows/workflows.controller.ts index a15044719e..3cfc129921 100644 --- a/packages/cli/src/workflows/workflows.controller.ts +++ b/packages/cli/src/workflows/workflows.controller.ts @@ -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); diff --git a/packages/cli/test/integration/credentials/credentials.api.test.ts b/packages/cli/test/integration/credentials/credentials.api.test.ts index edbdc8e3e7..eb5fff028e 100644 --- a/packages/cli/test/integration/credentials/credentials.api.test.ts +++ b/packages/cli/test/integration/credentials/credentials.api.test.ts @@ -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); diff --git a/packages/cli/test/integration/project.api.test.ts b/packages/cli/test/integration/project.api.test.ts index e0de9be6bf..a0bc1d4797 100644 --- a/packages/cli/test/integration/project.api.test.ts +++ b/packages/cli/test/integration/project.api.test.ts @@ -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(); diff --git a/packages/cli/test/integration/workflows/workflows.controller.test.ts b/packages/cli/test/integration/workflows/workflows.controller.test.ts index 3a2fccec3d..5cf7040c94 100644 --- a/packages/cli/test/integration/workflows/workflows.controller.test.ts +++ b/packages/cli/test/integration/workflows/workflows.controller.test.ts @@ -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 = { diff --git a/packages/frontend/@n8n/rest-api-client/src/api/workflows.ts b/packages/frontend/@n8n/rest-api-client/src/api/workflows.ts index 941d71f23e..cb6bae7827 100644 --- a/packages/frontend/@n8n/rest-api-client/src/api/workflows.ts +++ b/packages/frontend/@n8n/rest-api-client/src/api/workflows.ts @@ -35,6 +35,7 @@ export interface WorkflowDataUpdate { versionId?: string; meta?: WorkflowMetadata; parentFolderId?: string; + uiContext?: string; } export interface WorkflowDataCreate extends WorkflowDataUpdate { diff --git a/packages/frontend/editor-ui/src/components/CredentialEdit/CredentialEdit.vue b/packages/frontend/editor-ui/src/components/CredentialEdit/CredentialEdit.vue index d785515757..1fdb6dd857 100644 --- a/packages/frontend/editor-ui/src/components/CredentialEdit/CredentialEdit.vue +++ b/packages/frontend/editor-ui/src/components/CredentialEdit/CredentialEdit.vue @@ -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); diff --git a/packages/frontend/editor-ui/src/components/Projects/ProjectHeader.test.ts b/packages/frontend/editor-ui/src/components/Projects/ProjectHeader.test.ts index ed64094b95..f5bea57e2f 100644 --- a/packages/frontend/editor-ui/src/components/Projects/ProjectHeader.test.ts +++ b/packages/frontend/editor-ui/src/components/Projects/ProjectHeader.test.ts @@ -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', + }, + }), + ); }); }); diff --git a/packages/frontend/editor-ui/src/components/Projects/ProjectHeader.vue b/packages/frontend/editor-ui/src/components/Projects/ProjectHeader.vue index d2c71a3fa8..13fa619b4e 100644 --- a/packages/frontend/editor-ui/src/components/Projects/ProjectHeader.vue +++ b/packages/frontend/editor-ui/src/components/Projects/ProjectHeader.vue @@ -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 void> = { [ACTION_TYPES.WORKFLOW]: (projectId: string) => { void router.push({ @@ -169,6 +200,7 @@ const actions: Record void> = { query: { projectId, parentFolderId: route.params.folderId as string, + uiContext: getUIContext(route.name?.toString() ?? ''), }, }); }, @@ -179,6 +211,9 @@ const actions: Record void> = { projectId, credentialId: 'create', }, + query: { + uiContext: getUIContext(route.name?.toString() ?? ''), + }, }); }, [ACTION_TYPES.FOLDER]: () => { diff --git a/packages/frontend/editor-ui/src/components/Projects/ProjectNavigation.vue b/packages/frontend/editor-ui/src/components/Projects/ProjectNavigation.vue index 62b04f9324..805ef2fab7 100644 --- a/packages/frontend/editor-ui/src/components/Projects/ProjectNavigation.vue +++ b/packages/frontend/editor-ui/src/components/Projects/ProjectNavigation.vue @@ -1,14 +1,14 @@