mirror of
https://github.com/n8n-io/n8n.git
synced 2025-01-11 04:47:29 -08:00
fix(editor): Allow resources to move between personal and team projects (#10683)
Co-authored-by: Danny Martini <danny@n8n.io>
This commit is contained in:
parent
2df5a5b649
commit
136d491325
|
@ -32,8 +32,6 @@ export const addProjectMember = (email: string, role?: string) => {
|
|||
}
|
||||
};
|
||||
export const getResourceMoveModal = () => cy.getByTestId('project-move-resource-modal');
|
||||
export const getResourceMoveConfirmModal = () =>
|
||||
cy.getByTestId('project-move-resource-confirm-modal');
|
||||
export const getProjectMoveSelect = () => cy.getByTestId('project-move-resource-modal-select');
|
||||
|
||||
export function createProject(name: string) {
|
||||
|
|
|
@ -1,5 +1,11 @@
|
|||
import * as projects from '../composables/projects';
|
||||
import { INSTANCE_MEMBERS, MANUAL_TRIGGER_NODE_NAME, NOTION_NODE_NAME } from '../constants';
|
||||
import {
|
||||
INSTANCE_ADMIN,
|
||||
INSTANCE_MEMBERS,
|
||||
INSTANCE_OWNER,
|
||||
MANUAL_TRIGGER_NODE_NAME,
|
||||
NOTION_NODE_NAME,
|
||||
} from '../constants';
|
||||
import {
|
||||
WorkflowsPage,
|
||||
WorkflowPage,
|
||||
|
@ -481,44 +487,15 @@ describe('Projects', { disableAutoLogin: true }, () => {
|
|||
projects
|
||||
.getResourceMoveModal()
|
||||
.should('be.visible')
|
||||
.find('button:contains("Next")')
|
||||
.find('button:contains("Move workflow")')
|
||||
.should('be.disabled');
|
||||
projects.getProjectMoveSelect().click();
|
||||
getVisibleSelect()
|
||||
.find('li')
|
||||
.should('have.length', 2)
|
||||
.first()
|
||||
.should('contain.text', 'Project 1')
|
||||
.click();
|
||||
projects.getResourceMoveModal().find('button:contains("Next")').click();
|
||||
|
||||
projects
|
||||
.getResourceMoveConfirmModal()
|
||||
.should('be.visible')
|
||||
.find('button:contains("Confirm")')
|
||||
.should('be.disabled');
|
||||
|
||||
projects
|
||||
.getResourceMoveConfirmModal()
|
||||
.find('input[type="checkbox"]')
|
||||
.first()
|
||||
.parents('label')
|
||||
.click();
|
||||
projects
|
||||
.getResourceMoveConfirmModal()
|
||||
.find('button:contains("Confirm")')
|
||||
.should('be.disabled');
|
||||
projects
|
||||
.getResourceMoveConfirmModal()
|
||||
.find('input[type="checkbox"]')
|
||||
.last()
|
||||
.parents('label')
|
||||
.click();
|
||||
projects
|
||||
.getResourceMoveConfirmModal()
|
||||
.find('button:contains("Confirm")')
|
||||
.should('not.be.disabled')
|
||||
.should('have.length', 5)
|
||||
.filter(':contains("Project 1")')
|
||||
.click();
|
||||
projects.getResourceMoveModal().find('button:contains("Move workflow")').click();
|
||||
|
||||
workflowsPage.getters
|
||||
.workflowCards()
|
||||
|
@ -526,9 +503,77 @@ describe('Projects', { disableAutoLogin: true }, () => {
|
|||
.filter(':contains("Owned by me")')
|
||||
.should('not.exist');
|
||||
|
||||
// Move the credential from Project 1 to Project 2
|
||||
// Move the workflow from Project 1 to Project 2
|
||||
projects.getMenuItems().first().click();
|
||||
workflowsPage.getters.workflowCards().should('have.length', 2);
|
||||
workflowsPage.getters.workflowCardActions('Workflow in Home project').click();
|
||||
workflowsPage.getters.workflowMoveButton().click();
|
||||
|
||||
projects
|
||||
.getResourceMoveModal()
|
||||
.should('be.visible')
|
||||
.find('button:contains("Move workflow")')
|
||||
.should('be.disabled');
|
||||
projects.getProjectMoveSelect().click();
|
||||
getVisibleSelect()
|
||||
.find('li')
|
||||
.should('have.length', 5)
|
||||
.filter(':contains("Project 2")')
|
||||
.click();
|
||||
projects.getResourceMoveModal().find('button:contains("Move workflow")').click();
|
||||
|
||||
// Move the workflow from Project 2 to a member user
|
||||
projects.getMenuItems().last().click();
|
||||
workflowsPage.getters.workflowCards().should('have.length', 2);
|
||||
workflowsPage.getters.workflowCardActions('Workflow in Home project').click();
|
||||
workflowsPage.getters.workflowMoveButton().click();
|
||||
|
||||
projects
|
||||
.getResourceMoveModal()
|
||||
.should('be.visible')
|
||||
.find('button:contains("Move workflow")')
|
||||
.should('be.disabled');
|
||||
projects.getProjectMoveSelect().click();
|
||||
getVisibleSelect()
|
||||
.find('li')
|
||||
.should('have.length', 5)
|
||||
.filter(`:contains("${INSTANCE_MEMBERS[0].email}")`)
|
||||
.click();
|
||||
|
||||
projects.getResourceMoveModal().find('button:contains("Move workflow")').click();
|
||||
workflowsPage.getters.workflowCards().should('have.length', 1);
|
||||
|
||||
// Move the workflow from member user back to Home
|
||||
projects.getHomeButton().click();
|
||||
workflowsPage.getters
|
||||
.workflowCards()
|
||||
.should('have.length', 3)
|
||||
.filter(':has(.n8n-badge:contains("Project"))')
|
||||
.should('have.length', 2);
|
||||
workflowsPage.getters.workflowCardActions('Workflow in Home project').click();
|
||||
workflowsPage.getters.workflowMoveButton().click();
|
||||
|
||||
projects
|
||||
.getResourceMoveModal()
|
||||
.should('be.visible')
|
||||
.find('button:contains("Move workflow")')
|
||||
.should('be.disabled');
|
||||
projects.getProjectMoveSelect().click();
|
||||
getVisibleSelect()
|
||||
.find('li')
|
||||
.should('have.length', 5)
|
||||
.filter(`:contains("${INSTANCE_OWNER.email}")`)
|
||||
.click();
|
||||
|
||||
projects.getResourceMoveModal().find('button:contains("Move workflow")').click();
|
||||
workflowsPage.getters
|
||||
.workflowCards()
|
||||
.should('have.length', 3)
|
||||
.filter(':contains("Owned by me")')
|
||||
.should('have.length', 1);
|
||||
|
||||
// Move the credential from Project 1 to Project 2
|
||||
projects.getMenuItems().first().click();
|
||||
projects.getProjectTabCredentials().click();
|
||||
credentialsPage.getters.credentialCards().should('have.length', 1);
|
||||
credentialsPage.getters.credentialCardActions('Credential in Project 1').click();
|
||||
|
@ -537,48 +582,162 @@ describe('Projects', { disableAutoLogin: true }, () => {
|
|||
projects
|
||||
.getResourceMoveModal()
|
||||
.should('be.visible')
|
||||
.find('button:contains("Next")')
|
||||
.find('button:contains("Move credential")')
|
||||
.should('be.disabled');
|
||||
projects.getProjectMoveSelect().click();
|
||||
getVisibleSelect()
|
||||
.find('li')
|
||||
.should('have.length', 1)
|
||||
.first()
|
||||
.should('contain.text', 'Project 2')
|
||||
.should('have.length', 5)
|
||||
.filter(':contains("Project 2")')
|
||||
.click();
|
||||
projects.getResourceMoveModal().find('button:contains("Next")').click();
|
||||
projects.getResourceMoveModal().find('button:contains("Move credential")').click();
|
||||
|
||||
projects
|
||||
.getResourceMoveConfirmModal()
|
||||
.should('be.visible')
|
||||
.find('button:contains("Confirm")')
|
||||
.should('be.disabled');
|
||||
|
||||
projects
|
||||
.getResourceMoveConfirmModal()
|
||||
.find('input[type="checkbox"]')
|
||||
.first()
|
||||
.parents('label')
|
||||
.click();
|
||||
projects
|
||||
.getResourceMoveConfirmModal()
|
||||
.find('button:contains("Confirm")')
|
||||
.should('be.disabled');
|
||||
projects
|
||||
.getResourceMoveConfirmModal()
|
||||
.find('input[type="checkbox"]')
|
||||
.last()
|
||||
.parents('label')
|
||||
.click();
|
||||
projects
|
||||
.getResourceMoveConfirmModal()
|
||||
.find('button:contains("Confirm")')
|
||||
.should('not.be.disabled')
|
||||
.click();
|
||||
credentialsPage.getters.credentialCards().should('not.have.length');
|
||||
|
||||
// Move the credential from Project 2 to admin user
|
||||
projects.getMenuItems().last().click();
|
||||
projects.getProjectTabCredentials().click();
|
||||
credentialsPage.getters.credentialCards().should('have.length', 2);
|
||||
|
||||
credentialsPage.getters.credentialCardActions('Credential in Project 1').click();
|
||||
credentialsPage.getters.credentialMoveButton().click();
|
||||
|
||||
projects
|
||||
.getResourceMoveModal()
|
||||
.should('be.visible')
|
||||
.find('button:contains("Move credential")')
|
||||
.should('be.disabled');
|
||||
projects.getProjectMoveSelect().click();
|
||||
getVisibleSelect()
|
||||
.find('li')
|
||||
.should('have.length', 5)
|
||||
.filter(`:contains("${INSTANCE_ADMIN.email}")`)
|
||||
.click();
|
||||
projects.getResourceMoveModal().find('button:contains("Move credential")').click();
|
||||
credentialsPage.getters.credentialCards().should('have.length', 1);
|
||||
|
||||
// Move the credential from admin user back to instance owner
|
||||
projects.getHomeButton().click();
|
||||
projects.getProjectTabCredentials().click();
|
||||
credentialsPage.getters.credentialCards().should('have.length', 3);
|
||||
|
||||
credentialsPage.getters.credentialCardActions('Credential in Project 1').click();
|
||||
credentialsPage.getters.credentialMoveButton().click();
|
||||
|
||||
projects
|
||||
.getResourceMoveModal()
|
||||
.should('be.visible')
|
||||
.find('button:contains("Move credential")')
|
||||
.should('be.disabled');
|
||||
projects.getProjectMoveSelect().click();
|
||||
getVisibleSelect()
|
||||
.find('li')
|
||||
.should('have.length', 5)
|
||||
.filter(`:contains("${INSTANCE_OWNER.email}")`)
|
||||
.click();
|
||||
projects.getResourceMoveModal().find('button:contains("Move credential")').click();
|
||||
|
||||
credentialsPage.getters
|
||||
.credentialCards()
|
||||
.should('have.length', 3)
|
||||
.filter(':contains("Owned by me")')
|
||||
.should('have.length', 2);
|
||||
|
||||
// Move the credential from admin user back to its original project (Project 1)
|
||||
credentialsPage.getters.credentialCardActions('Credential in Project 1').click();
|
||||
credentialsPage.getters.credentialMoveButton().click();
|
||||
|
||||
projects
|
||||
.getResourceMoveModal()
|
||||
.should('be.visible')
|
||||
.find('button:contains("Move credential")')
|
||||
.should('be.disabled');
|
||||
projects.getProjectMoveSelect().click();
|
||||
getVisibleSelect()
|
||||
.find('li')
|
||||
.should('have.length', 5)
|
||||
.filter(':contains("Project 1")')
|
||||
.click();
|
||||
projects.getResourceMoveModal().find('button:contains("Move credential")').click();
|
||||
|
||||
projects.getMenuItems().first().click();
|
||||
projects.getProjectTabCredentials().click();
|
||||
credentialsPage.getters
|
||||
.credentialCards()
|
||||
.filter(':contains("Credential in Project 1")')
|
||||
.should('have.length', 1);
|
||||
});
|
||||
|
||||
it('should allow to change inaccessible credential when the workflow was moved to a team project', () => {
|
||||
cy.signinAsOwner();
|
||||
cy.visit(workflowsPage.url);
|
||||
|
||||
// Create a credential in the Home project
|
||||
projects.getProjectTabCredentials().should('be.visible').click();
|
||||
credentialsPage.getters.emptyListCreateCredentialButton().click();
|
||||
projects.createCredential('Credential in Home project');
|
||||
|
||||
// Create a workflow in the Home project
|
||||
projects.getHomeButton().click();
|
||||
workflowsPage.getters.workflowCards().should('not.have.length');
|
||||
workflowsPage.getters.newWorkflowButtonCard().click();
|
||||
workflowsPage.getters.workflowCards().should('not.have.length');
|
||||
|
||||
workflowsPage.getters.newWorkflowButtonCard().click();
|
||||
workflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
|
||||
workflowPage.actions.addNodeToCanvas(NOTION_NODE_NAME, true, true);
|
||||
ndv.getters.backToCanvas().click();
|
||||
workflowPage.actions.saveWorkflowOnButtonClick();
|
||||
|
||||
// Create a project and add a user to it
|
||||
projects.createProject('Project 1');
|
||||
projects.addProjectMember(INSTANCE_MEMBERS[0].email);
|
||||
projects.getProjectSettingsSaveButton().click();
|
||||
|
||||
// Move the workflow from Home to Project 1
|
||||
projects.getHomeButton().click();
|
||||
workflowsPage.getters
|
||||
.workflowCards()
|
||||
.should('have.length', 1)
|
||||
.filter(':contains("Owned by me")')
|
||||
.should('exist');
|
||||
workflowsPage.getters.workflowCardActions('My workflow').click();
|
||||
workflowsPage.getters.workflowMoveButton().click();
|
||||
|
||||
projects
|
||||
.getResourceMoveModal()
|
||||
.should('be.visible')
|
||||
.find('button:contains("Move workflow")')
|
||||
.should('be.disabled');
|
||||
projects.getProjectMoveSelect().click();
|
||||
getVisibleSelect()
|
||||
.find('li')
|
||||
.should('have.length', 4)
|
||||
.filter(':contains("Project 1")')
|
||||
.click();
|
||||
projects.getResourceMoveModal().find('button:contains("Move workflow")').click();
|
||||
|
||||
workflowsPage.getters
|
||||
.workflowCards()
|
||||
.should('have.length', 1)
|
||||
.filter(':contains("Owned by me")')
|
||||
.should('not.exist');
|
||||
|
||||
//Log out with instance owner and log in with the member user
|
||||
mainSidebar.actions.openUserMenu();
|
||||
cy.getByTestId('user-menu-item-logout').click();
|
||||
|
||||
cy.get('input[name="email"]').type(INSTANCE_MEMBERS[0].email);
|
||||
cy.get('input[name="password"]').type(INSTANCE_MEMBERS[0].password);
|
||||
cy.getByTestId('form-submit-button').click();
|
||||
|
||||
// Open the moved workflow
|
||||
workflowsPage.getters.workflowCards().should('have.length', 1);
|
||||
workflowsPage.getters.workflowCards().first().click();
|
||||
|
||||
// Check if the credential can be changed
|
||||
workflowPage.getters.canvasNodeByName(NOTION_NODE_NAME).should('be.visible').dblclick();
|
||||
ndv.getters.credentialInput().find('input').should('be.enabled');
|
||||
});
|
||||
|
||||
it('should handle viewer role', () => {
|
||||
|
|
|
@ -157,14 +157,6 @@ export class EnterpriseCredentialsService {
|
|||
"You can't transfer a credential into the project that's already owning it.",
|
||||
);
|
||||
}
|
||||
if (sourceProject.type !== 'team' && sourceProject.type !== 'personal') {
|
||||
throw new TransferCredentialError(
|
||||
'You can only transfer credentials out of personal or team projects.',
|
||||
);
|
||||
}
|
||||
if (destinationProject.type !== 'team') {
|
||||
throw new TransferCredentialError('You can only transfer credentials into team projects.');
|
||||
}
|
||||
|
||||
await this.sharedCredentialsRepository.manager.transaction(async (trx) => {
|
||||
// 6. transfer the credential
|
||||
|
|
|
@ -285,14 +285,6 @@ export class EnterpriseWorkflowService {
|
|||
"You can't transfer a workflow into the project that's already owning it.",
|
||||
);
|
||||
}
|
||||
if (sourceProject.type !== 'team' && sourceProject.type !== 'personal') {
|
||||
throw new TransferWorkflowError(
|
||||
'You can only transfer workflows out of personal or team projects.',
|
||||
);
|
||||
}
|
||||
if (destinationProject.type !== 'team') {
|
||||
throw new TransferWorkflowError('You can only transfer workflows into team projects.');
|
||||
}
|
||||
|
||||
// 6. deactivate workflow if necessary
|
||||
const wasActive = workflow.active;
|
||||
|
|
|
@ -3,6 +3,7 @@ import { Container } from 'typedi';
|
|||
|
||||
import config from '@/config';
|
||||
import type { Project } from '@/databases/entities/project';
|
||||
import type { ProjectRole } from '@/databases/entities/project-relation';
|
||||
import type { User } from '@/databases/entities/user';
|
||||
import { ProjectRepository } from '@/databases/repositories/project.repository';
|
||||
import { SharedCredentialsRepository } from '@/databases/repositories/shared-credentials.repository';
|
||||
|
@ -1118,18 +1119,6 @@ describe('PUT /:credentialId/transfer', () => {
|
|||
.expect(400);
|
||||
});
|
||||
|
||||
test('cannot transfer into a personal project', async () => {
|
||||
const credential = await saveCredential(randomCredentialPayload(), {
|
||||
user: member,
|
||||
});
|
||||
|
||||
await testServer
|
||||
.authAgentFor(member)
|
||||
.put(`/credentials/${credential.id}/transfer`)
|
||||
.send({ destinationProjectId: memberPersonalProject.id })
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
test('cannot transfer somebody elses credential', async () => {
|
||||
const destinationProject = await createTeamProject('Destination Project', member);
|
||||
|
||||
|
@ -1158,187 +1147,139 @@ describe('PUT /:credentialId/transfer', () => {
|
|||
.expect(404);
|
||||
});
|
||||
|
||||
test('project:editors cannot transfer credentials', async () => {
|
||||
//
|
||||
// ARRANGE
|
||||
//
|
||||
const sourceProject = await createTeamProject('Source Project');
|
||||
await linkUserToProject(member, sourceProject, 'project:editor');
|
||||
|
||||
const credential = await saveCredential(randomCredentialPayload(), {
|
||||
project: sourceProject,
|
||||
});
|
||||
|
||||
const destinationProject = await createTeamProject('Destination Project', member);
|
||||
|
||||
//
|
||||
// ACT & ASSERT
|
||||
//
|
||||
await testServer
|
||||
.authAgentFor(member)
|
||||
.put(`/credentials/${credential.id}/transfer`)
|
||||
.send({ destinationProjectId: destinationProject.id })
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
test('transferring from a personal project to a team project severs all sharings', async () => {
|
||||
//
|
||||
// ARRANGE
|
||||
//
|
||||
const credential = await saveCredential(randomCredentialPayload(), { user: member });
|
||||
|
||||
// these sharings should be deleted by the transfer
|
||||
await shareCredentialWithUsers(credential, [anotherMember, owner]);
|
||||
|
||||
const destinationProject = await createTeamProject('Destination Project', member);
|
||||
|
||||
//
|
||||
// ACT
|
||||
//
|
||||
const response = await testServer
|
||||
.authAgentFor(member)
|
||||
.put(`/credentials/${credential.id}/transfer`)
|
||||
.send({ destinationProjectId: destinationProject.id })
|
||||
.expect(200);
|
||||
|
||||
//
|
||||
// ASSERT
|
||||
//
|
||||
expect(response.body).toEqual({});
|
||||
|
||||
const allSharings = await getCredentialSharings(credential);
|
||||
expect(allSharings).toHaveLength(1);
|
||||
expect(allSharings[0]).toMatchObject({
|
||||
projectId: destinationProject.id,
|
||||
credentialsId: credential.id,
|
||||
role: 'credential:owner',
|
||||
});
|
||||
});
|
||||
|
||||
test('can transfer from team to another team project', async () => {
|
||||
//
|
||||
// ARRANGE
|
||||
//
|
||||
const sourceProject = await createTeamProject('Team Project 1', member);
|
||||
const credential = await saveCredential(randomCredentialPayload(), {
|
||||
project: sourceProject,
|
||||
});
|
||||
|
||||
const destinationProject = await createTeamProject('Team Project 2', member);
|
||||
|
||||
//
|
||||
// ACT
|
||||
//
|
||||
const response = await testServer
|
||||
.authAgentFor(member)
|
||||
.put(`/credentials/${credential.id}/transfer`)
|
||||
.send({ destinationProjectId: destinationProject.id })
|
||||
.expect(200);
|
||||
|
||||
//
|
||||
// ASSERT
|
||||
//
|
||||
expect(response.body).toEqual({});
|
||||
|
||||
const allSharings = await getCredentialSharings(credential);
|
||||
expect(allSharings).toHaveLength(1);
|
||||
expect(allSharings[0]).toMatchObject({
|
||||
projectId: destinationProject.id,
|
||||
credentialsId: credential.id,
|
||||
role: 'credential:owner',
|
||||
});
|
||||
});
|
||||
|
||||
test.each([
|
||||
['owners', () => owner],
|
||||
['admins', () => admin],
|
||||
])(
|
||||
'%s can always transfer from any personal or team project into any team project',
|
||||
async (_name, actor) => {
|
||||
test.each<ProjectRole>(['project:editor', 'project:viewer'])(
|
||||
'%ss cannot transfer credentials',
|
||||
async (projectRole) => {
|
||||
//
|
||||
// ARRANGE
|
||||
//
|
||||
const sourceProject = await createTeamProject('Source Project', member);
|
||||
const teamCredential = await saveCredential(randomCredentialPayload(), {
|
||||
const sourceProject = await createTeamProject('Source Project');
|
||||
await linkUserToProject(member, sourceProject, projectRole);
|
||||
|
||||
const credential = await saveCredential(randomCredentialPayload(), {
|
||||
project: sourceProject,
|
||||
});
|
||||
|
||||
const personalCredential = await saveCredential(randomCredentialPayload(), { user: member });
|
||||
|
||||
const destinationProject = await createTeamProject('Destination Project', member);
|
||||
|
||||
//
|
||||
// ACT & ASSERT
|
||||
//
|
||||
await testServer
|
||||
.authAgentFor(member)
|
||||
.put(`/credentials/${credential.id}/transfer`)
|
||||
.send({ destinationProjectId: destinationProject.id })
|
||||
.expect(403);
|
||||
},
|
||||
);
|
||||
|
||||
test.each<
|
||||
[
|
||||
// user role
|
||||
'owners' | 'admins',
|
||||
// source project type
|
||||
'team' | 'personal',
|
||||
// destination project type
|
||||
'team' | 'personal',
|
||||
// actor
|
||||
() => User,
|
||||
// source project
|
||||
() => Promise<Project> | Project,
|
||||
// destination project
|
||||
() => Promise<Project> | Project,
|
||||
]
|
||||
>([
|
||||
// owner
|
||||
[
|
||||
'owners',
|
||||
'team',
|
||||
'team',
|
||||
() => owner,
|
||||
async () => await createTeamProject('Source Project'),
|
||||
async () => await createTeamProject('Destination Project'),
|
||||
],
|
||||
[
|
||||
'owners',
|
||||
'team',
|
||||
'personal',
|
||||
() => owner,
|
||||
async () => await createTeamProject('Source Project'),
|
||||
() => memberPersonalProject,
|
||||
],
|
||||
[
|
||||
'owners',
|
||||
'personal',
|
||||
'team',
|
||||
() => owner,
|
||||
() => memberPersonalProject,
|
||||
async () => await createTeamProject('Destination Project'),
|
||||
],
|
||||
|
||||
// admin
|
||||
[
|
||||
'admins',
|
||||
'team',
|
||||
'team',
|
||||
() => admin,
|
||||
async () => await createTeamProject('Source Project'),
|
||||
async () => await createTeamProject('Destination Project'),
|
||||
],
|
||||
[
|
||||
'admins',
|
||||
'team',
|
||||
'personal',
|
||||
() => admin,
|
||||
async () => await createTeamProject('Source Project'),
|
||||
() => memberPersonalProject,
|
||||
],
|
||||
[
|
||||
'admins',
|
||||
'personal',
|
||||
'team',
|
||||
() => admin,
|
||||
() => memberPersonalProject,
|
||||
async () => await createTeamProject('Destination Project'),
|
||||
],
|
||||
])(
|
||||
'%s can always transfer from a %s project to a %s project',
|
||||
async (
|
||||
_roleName,
|
||||
_sourceProjectName,
|
||||
_destinationProjectName,
|
||||
getUser,
|
||||
getSourceProject,
|
||||
getDestinationProject,
|
||||
) => {
|
||||
// ARRANGE
|
||||
const user = getUser();
|
||||
const sourceProject = await getSourceProject();
|
||||
const destinationProject = await getDestinationProject();
|
||||
|
||||
const credential = await saveCredential(randomCredentialPayload(), {
|
||||
project: sourceProject,
|
||||
});
|
||||
|
||||
// ACT
|
||||
//
|
||||
const response1 = await testServer
|
||||
.authAgentFor(actor())
|
||||
.put(`/credentials/${teamCredential.id}/transfer`)
|
||||
.send({ destinationProjectId: destinationProject.id })
|
||||
.expect(200);
|
||||
const response2 = await testServer
|
||||
.authAgentFor(actor())
|
||||
.put(`/credentials/${personalCredential.id}/transfer`)
|
||||
const response = await testServer
|
||||
.authAgentFor(user)
|
||||
.put(`/credentials/${credential.id}/transfer`)
|
||||
.send({ destinationProjectId: destinationProject.id })
|
||||
.expect(200);
|
||||
|
||||
//
|
||||
// ASSERT
|
||||
//
|
||||
expect(response1.body).toEqual({});
|
||||
expect(response2.body).toEqual({});
|
||||
expect(response.body).toEqual({});
|
||||
|
||||
{
|
||||
const allSharings = await getCredentialSharings(teamCredential);
|
||||
const allSharings = await getCredentialSharings(credential);
|
||||
expect(allSharings).toHaveLength(1);
|
||||
expect(allSharings[0]).toMatchObject({
|
||||
projectId: destinationProject.id,
|
||||
credentialsId: teamCredential.id,
|
||||
role: 'credential:owner',
|
||||
});
|
||||
}
|
||||
|
||||
{
|
||||
const allSharings = await getCredentialSharings(personalCredential);
|
||||
expect(allSharings).toHaveLength(1);
|
||||
expect(allSharings[0]).toMatchObject({
|
||||
projectId: destinationProject.id,
|
||||
credentialsId: personalCredential.id,
|
||||
credentialsId: credential.id,
|
||||
role: 'credential:owner',
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test.each([
|
||||
['owners', () => owner],
|
||||
['admins', () => admin],
|
||||
])('%s cannot transfer into personal projects', async (_name, actor) => {
|
||||
//
|
||||
// ARRANGE
|
||||
//
|
||||
const sourceProject = await createTeamProject('Source Project', member);
|
||||
const teamCredential = await saveCredential(randomCredentialPayload(), {
|
||||
project: sourceProject,
|
||||
});
|
||||
|
||||
const personalCredential = await saveCredential(randomCredentialPayload(), { user: member });
|
||||
|
||||
const destinationProject = anotherMemberPersonalProject;
|
||||
|
||||
//
|
||||
// ACT & ASSERT
|
||||
//
|
||||
await testServer
|
||||
.authAgentFor(actor())
|
||||
.put(`/credentials/${teamCredential.id}/transfer`)
|
||||
.send({ destinationProjectId: destinationProject.id })
|
||||
.expect(400);
|
||||
await testServer
|
||||
.authAgentFor(actor())
|
||||
.put(`/credentials/${personalCredential.id}/transfer`)
|
||||
.send({ destinationProjectId: destinationProject.id })
|
||||
.expect(400);
|
||||
});
|
||||
});
|
||||
|
||||
function validateMainCredentialData(credential: ListQuery.Credentials.WithOwnedByAndSharedWith) {
|
||||
|
|
|
@ -5,6 +5,7 @@ import { v4 as uuid } from 'uuid';
|
|||
import { ActiveWorkflowManager } from '@/active-workflow-manager';
|
||||
import config from '@/config';
|
||||
import type { Project } from '@/databases/entities/project';
|
||||
import type { ProjectRole } from '@/databases/entities/project-relation';
|
||||
import type { User } from '@/databases/entities/user';
|
||||
import { ProjectRepository } from '@/databases/repositories/project.repository';
|
||||
import { WorkflowHistoryRepository } from '@/databases/repositories/workflow-history.repository';
|
||||
|
@ -1385,18 +1386,6 @@ describe('PUT /:workflowId/transfer', () => {
|
|||
.expect(400);
|
||||
});
|
||||
|
||||
test('cannot transfer into a personal project', async () => {
|
||||
const sourceProject = await createTeamProject('Team Project', member);
|
||||
|
||||
const workflow = await createWorkflow({}, sourceProject);
|
||||
|
||||
await testServer
|
||||
.authAgentFor(member)
|
||||
.put(`/workflows/${workflow.id}/transfer`)
|
||||
.send({ destinationProjectId: memberPersonalProject.id })
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
test('cannot transfer somebody elses workflow', async () => {
|
||||
const destinationProject = await createTeamProject('Team Project', member);
|
||||
|
||||
|
@ -1421,180 +1410,133 @@ describe('PUT /:workflowId/transfer', () => {
|
|||
.expect(404);
|
||||
});
|
||||
|
||||
test('project:editors cannot transfer workflows', async () => {
|
||||
//
|
||||
// ARRANGE
|
||||
//
|
||||
const sourceProject = await createTeamProject();
|
||||
await linkUserToProject(member, sourceProject, 'project:editor');
|
||||
|
||||
const workflow = await createWorkflow({}, sourceProject);
|
||||
|
||||
const destinationProject = await createTeamProject();
|
||||
await linkUserToProject(member, destinationProject, 'project:admin');
|
||||
|
||||
//
|
||||
// ACT & ASSERT
|
||||
//
|
||||
await testServer
|
||||
.authAgentFor(member)
|
||||
.put(`/workflows/${workflow.id}/transfer`)
|
||||
.send({ destinationProjectId: destinationProject.id })
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
test('transferring from a personal project to a team project severs all sharings', async () => {
|
||||
//
|
||||
// ARRANGE
|
||||
//
|
||||
const workflow = await createWorkflow({}, member);
|
||||
|
||||
// these sharings should be deleted by the transfer
|
||||
await shareWorkflowWithUsers(workflow, [anotherMember, owner]);
|
||||
|
||||
const destinationProject = await createTeamProject('Team Project', member);
|
||||
|
||||
//
|
||||
// ACT
|
||||
//
|
||||
const response = await testServer
|
||||
.authAgentFor(member)
|
||||
.put(`/workflows/${workflow.id}/transfer`)
|
||||
.send({ destinationProjectId: destinationProject.id })
|
||||
.expect(200);
|
||||
|
||||
//
|
||||
// ASSERT
|
||||
//
|
||||
expect(response.body).toEqual({});
|
||||
|
||||
const allSharings = await getWorkflowSharing(workflow);
|
||||
expect(allSharings).toHaveLength(1);
|
||||
expect(allSharings[0]).toMatchObject({
|
||||
projectId: destinationProject.id,
|
||||
workflowId: workflow.id,
|
||||
role: 'workflow:owner',
|
||||
});
|
||||
});
|
||||
|
||||
test('can transfer from team to another team project', async () => {
|
||||
//
|
||||
// ARRANGE
|
||||
//
|
||||
const sourceProject = await createTeamProject('Team Project 1', member);
|
||||
const workflow = await createWorkflow({}, sourceProject);
|
||||
|
||||
const destinationProject = await createTeamProject('Team Project 2', member);
|
||||
|
||||
//
|
||||
// ACT
|
||||
//
|
||||
const response = await testServer
|
||||
.authAgentFor(member)
|
||||
.put(`/workflows/${workflow.id}/transfer`)
|
||||
.send({ destinationProjectId: destinationProject.id })
|
||||
.expect(200);
|
||||
|
||||
//
|
||||
// ASSERT
|
||||
//
|
||||
expect(response.body).toEqual({});
|
||||
|
||||
const allSharings = await getWorkflowSharing(workflow);
|
||||
expect(allSharings).toHaveLength(1);
|
||||
expect(allSharings[0]).toMatchObject({
|
||||
projectId: destinationProject.id,
|
||||
workflowId: workflow.id,
|
||||
role: 'workflow:owner',
|
||||
});
|
||||
});
|
||||
|
||||
test.each([
|
||||
['owners', () => owner],
|
||||
['admins', () => admin],
|
||||
])(
|
||||
'global %s can always transfer from any personal or team project into any team project',
|
||||
async (_name, actor) => {
|
||||
test.each<ProjectRole>(['project:editor', 'project:viewer'])(
|
||||
'%ss cannot transfer workflows',
|
||||
async (projectRole) => {
|
||||
//
|
||||
// ARRANGE
|
||||
//
|
||||
const sourceProject = await createTeamProject('Source Project', member);
|
||||
const teamWorkflow = await createWorkflow({}, sourceProject);
|
||||
const sourceProject = await createTeamProject();
|
||||
await linkUserToProject(member, sourceProject, projectRole);
|
||||
|
||||
const personalWorkflow = await createWorkflow({}, member);
|
||||
const workflow = await createWorkflow({}, sourceProject);
|
||||
|
||||
const destinationProject = await createTeamProject('Destination Project', member);
|
||||
const destinationProject = await createTeamProject();
|
||||
await linkUserToProject(member, destinationProject, 'project:admin');
|
||||
|
||||
//
|
||||
// ACT
|
||||
// ACT & ASSERT
|
||||
//
|
||||
const response1 = await testServer
|
||||
.authAgentFor(actor())
|
||||
.put(`/workflows/${teamWorkflow.id}/transfer`)
|
||||
await testServer
|
||||
.authAgentFor(member)
|
||||
.put(`/workflows/${workflow.id}/transfer`)
|
||||
.send({ destinationProjectId: destinationProject.id })
|
||||
.expect(200);
|
||||
const response2 = await testServer
|
||||
.authAgentFor(actor())
|
||||
.put(`/workflows/${personalWorkflow.id}/transfer`)
|
||||
.send({ destinationProjectId: destinationProject.id })
|
||||
.expect(200);
|
||||
|
||||
//
|
||||
// ASSERT
|
||||
//
|
||||
expect(response1.body).toEqual({});
|
||||
expect(response2.body).toEqual({});
|
||||
|
||||
{
|
||||
const allSharings = await getWorkflowSharing(teamWorkflow);
|
||||
expect(allSharings).toHaveLength(1);
|
||||
expect(allSharings[0]).toMatchObject({
|
||||
projectId: destinationProject.id,
|
||||
workflowId: teamWorkflow.id,
|
||||
role: 'workflow:owner',
|
||||
});
|
||||
}
|
||||
|
||||
{
|
||||
const allSharings = await getWorkflowSharing(personalWorkflow);
|
||||
expect(allSharings).toHaveLength(1);
|
||||
expect(allSharings[0]).toMatchObject({
|
||||
projectId: destinationProject.id,
|
||||
workflowId: personalWorkflow.id,
|
||||
role: 'workflow:owner',
|
||||
});
|
||||
}
|
||||
.expect(403);
|
||||
},
|
||||
);
|
||||
|
||||
test.each([
|
||||
['owners', () => owner],
|
||||
['admins', () => admin],
|
||||
])('global %s cannot transfer into personal projects', async (_name, actor) => {
|
||||
//
|
||||
// ARRANGE
|
||||
//
|
||||
const sourceProject = await createTeamProject('Source Project', member);
|
||||
const teamWorkflow = await createWorkflow({}, sourceProject);
|
||||
test.each<
|
||||
[
|
||||
// user role
|
||||
'owners' | 'admins',
|
||||
// source project type
|
||||
'team' | 'personal',
|
||||
// destination project type
|
||||
'team' | 'personal',
|
||||
// actor
|
||||
() => User,
|
||||
// source project
|
||||
() => Promise<Project> | Project,
|
||||
// destination project
|
||||
() => Promise<Project> | Project,
|
||||
]
|
||||
>([
|
||||
// owner
|
||||
[
|
||||
'owners',
|
||||
'team',
|
||||
'team',
|
||||
() => owner,
|
||||
async () => await createTeamProject('Source Project'),
|
||||
async () => await createTeamProject('Destination Project'),
|
||||
],
|
||||
[
|
||||
'owners',
|
||||
'team',
|
||||
'personal',
|
||||
() => owner,
|
||||
async () => await createTeamProject('Source Project'),
|
||||
() => memberPersonalProject,
|
||||
],
|
||||
[
|
||||
'owners',
|
||||
'personal',
|
||||
'team',
|
||||
() => owner,
|
||||
() => memberPersonalProject,
|
||||
async () => await createTeamProject('Destination Project'),
|
||||
],
|
||||
|
||||
const personalWorkflow = await createWorkflow({}, member);
|
||||
// admin
|
||||
[
|
||||
'admins',
|
||||
'team',
|
||||
'team',
|
||||
() => admin,
|
||||
async () => await createTeamProject('Source Project'),
|
||||
async () => await createTeamProject('Destination Project'),
|
||||
],
|
||||
[
|
||||
'admins',
|
||||
'team',
|
||||
'personal',
|
||||
() => admin,
|
||||
async () => await createTeamProject('Source Project'),
|
||||
() => memberPersonalProject,
|
||||
],
|
||||
[
|
||||
'admins',
|
||||
'personal',
|
||||
'team',
|
||||
() => admin,
|
||||
() => memberPersonalProject,
|
||||
async () => await createTeamProject('Destination Project'),
|
||||
],
|
||||
])(
|
||||
'global %s can transfer workflows from a %s project to a %s project',
|
||||
async (
|
||||
_roleName,
|
||||
_sourceProjectName,
|
||||
_destinationProjectName,
|
||||
getActor,
|
||||
getSourceProject,
|
||||
getDestinationProject,
|
||||
) => {
|
||||
// ARRANGE
|
||||
const actor = getActor();
|
||||
const sourceProject = await getSourceProject();
|
||||
const destinationProject = await getDestinationProject();
|
||||
const workflow = await createWorkflow({}, sourceProject);
|
||||
|
||||
const destinationProject = anotherMemberPersonalProject;
|
||||
// ACT
|
||||
const response = await testServer
|
||||
.authAgentFor(actor)
|
||||
.put(`/workflows/${workflow.id}/transfer`)
|
||||
.send({ destinationProjectId: destinationProject.id })
|
||||
.expect(200);
|
||||
|
||||
//
|
||||
// ACT & ASSERT
|
||||
//
|
||||
await testServer
|
||||
.authAgentFor(actor())
|
||||
.put(`/workflows/${teamWorkflow.id}/transfer`)
|
||||
.send({ destinationProjectId: destinationProject.id })
|
||||
.expect(400);
|
||||
await testServer
|
||||
.authAgentFor(actor())
|
||||
.put(`/workflows/${personalWorkflow.id}/transfer`)
|
||||
.send({ destinationProjectId: destinationProject.id })
|
||||
.expect(400);
|
||||
});
|
||||
// ASSERT
|
||||
expect(response.body).toEqual({});
|
||||
|
||||
const allSharings = await getWorkflowSharing(workflow);
|
||||
expect(allSharings).toHaveLength(1);
|
||||
expect(allSharings[0]).toMatchObject({
|
||||
projectId: destinationProject.id,
|
||||
workflowId: workflow.id,
|
||||
role: 'workflow:owner',
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
test('removes and re-adds the workflow from the active workflow manager during the transfer', async () => {
|
||||
//
|
||||
|
|
|
@ -24,7 +24,7 @@ describe('Directive n8n-truncate', () => {
|
|||
},
|
||||
},
|
||||
);
|
||||
expect(html()).toBe('<div>This is a very long text that...</div>');
|
||||
expect(html()).toBe('<div>This is a very long text that ...</div>');
|
||||
});
|
||||
|
||||
it('should truncate text to 30 chars in case of wrong argument', async () => {
|
||||
|
@ -48,7 +48,7 @@ describe('Directive n8n-truncate', () => {
|
|||
},
|
||||
},
|
||||
);
|
||||
expect(html()).toBe('<div>This is a very long text that...</div>');
|
||||
expect(html()).toBe('<div>This is a very long text that ...</div>');
|
||||
});
|
||||
|
||||
it('should truncate text to given length', async () => {
|
||||
|
@ -72,6 +72,6 @@ describe('Directive n8n-truncate', () => {
|
|||
},
|
||||
},
|
||||
);
|
||||
expect(html()).toBe('<div>This is a very long text...</div>');
|
||||
expect(html()).toBe('<div>This is a very long text ...</div>');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -5,3 +5,4 @@ export * from './typeguards';
|
|||
export * from './uid';
|
||||
export * from './valueByPath';
|
||||
export * from './testUtils';
|
||||
export * from './string';
|
||||
|
|
|
@ -4,13 +4,13 @@ describe('Utils string', () => {
|
|||
describe('truncate', () => {
|
||||
it('should truncate text to 30 chars by default', () => {
|
||||
expect(truncate('This is a very long text that should be truncated')).toBe(
|
||||
'This is a very long text that...',
|
||||
'This is a very long text that ...',
|
||||
);
|
||||
});
|
||||
|
||||
it('should truncate text to given length', () => {
|
||||
expect(truncate('This is a very long text that should be truncated', 25)).toBe(
|
||||
'This is a very long text...',
|
||||
'This is a very long text ...',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,2 +1,2 @@
|
|||
export const truncate = (text: string, length = 30): string =>
|
||||
text.length > length ? text.slice(0, length).trim() + '...' : text;
|
||||
text.length > length ? text.slice(0, length) + '...' : text;
|
||||
|
|
|
@ -7,6 +7,7 @@ import { isPresent } from '@/utils/typesUtils';
|
|||
import type { IConnectedNode, Workflow } from 'n8n-workflow';
|
||||
import { computed } from 'vue';
|
||||
import NodeIcon from './NodeIcon.vue';
|
||||
import { truncate } from 'n8n-design-system';
|
||||
|
||||
type Props = {
|
||||
nodes: IConnectedNode[];
|
||||
|
@ -100,11 +101,7 @@ function getMultipleNodesText(nodeName: string): string {
|
|||
}
|
||||
|
||||
function title(nodeName: string, length = 30) {
|
||||
const truncated = nodeName.substring(0, length);
|
||||
if (truncated.length < nodeName.length) {
|
||||
return `${truncated}...`;
|
||||
}
|
||||
return truncated;
|
||||
return truncate(nodeName, length);
|
||||
}
|
||||
|
||||
function subtitle(nodeName: string, depth: number) {
|
||||
|
|
|
@ -31,7 +31,6 @@ import {
|
|||
WORKFLOW_HISTORY_VERSION_RESTORE,
|
||||
SETUP_CREDENTIALS_MODAL_KEY,
|
||||
PROJECT_MOVE_RESOURCE_MODAL,
|
||||
PROJECT_MOVE_RESOURCE_CONFIRM_MODAL,
|
||||
PROMPT_MFA_CODE_MODAL_KEY,
|
||||
} from '@/constants';
|
||||
|
||||
|
@ -66,7 +65,6 @@ import DebugPaywallModal from '@/components/DebugPaywallModal.vue';
|
|||
import WorkflowHistoryVersionRestoreModal from '@/components/WorkflowHistory/WorkflowHistoryVersionRestoreModal.vue';
|
||||
import SetupWorkflowCredentialsModal from '@/components/SetupWorkflowCredentialsModal/SetupWorkflowCredentialsModal.vue';
|
||||
import ProjectMoveResourceModal from '@/components/Projects/ProjectMoveResourceModal.vue';
|
||||
import ProjectMoveResourceConfirmModal from '@/components/Projects/ProjectMoveResourceConfirmModal.vue';
|
||||
import NewAssistantSessionModal from '@/components/AskAssistant/NewAssistantSessionModal.vue';
|
||||
import PromptMfaCodeModal from './PromptMfaCodeModal/PromptMfaCodeModal.vue';
|
||||
</script>
|
||||
|
@ -249,15 +247,6 @@ import PromptMfaCodeModal from './PromptMfaCodeModal/PromptMfaCodeModal.vue';
|
|||
/>
|
||||
</template>
|
||||
</ModalRoot>
|
||||
<ModalRoot :name="PROJECT_MOVE_RESOURCE_CONFIRM_MODAL">
|
||||
<template #default="{ modalName, data }">
|
||||
<ProjectMoveResourceConfirmModal
|
||||
data-test-id="project-move-resource-confirm-modal"
|
||||
:modal-name="modalName"
|
||||
:data="data"
|
||||
/>
|
||||
</template>
|
||||
</ModalRoot>
|
||||
<ModalRoot :name="NEW_ASSISTANT_SESSION_MODAL">
|
||||
<template #default="{ modalName, data }">
|
||||
<NewAssistantSessionModal :name="modalName" :data="data" />
|
||||
|
|
|
@ -51,6 +51,7 @@ import { useToast } from '@/composables/useToast';
|
|||
import { useI18n } from '@/composables/useI18n';
|
||||
import { useTelemetry } from '@/composables/useTelemetry';
|
||||
import { importCurlEventBus, ndvEventBus } from '@/event-bus';
|
||||
import { ProjectTypes } from '@/types/projects.types';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
|
@ -117,17 +118,29 @@ const hiddenIssuesInputs = ref<string[]>([]);
|
|||
const nodeSettings = ref<INodeProperties[]>([]);
|
||||
const subConnections = ref<InstanceType<typeof NDVSubConnections> | null>(null);
|
||||
|
||||
const isReadOnly = computed(() => props.readOnly || props.foreignCredentials.length > 0);
|
||||
|
||||
const currentWorkflowInstance = computed(() => workflowsStore.getCurrentWorkflow());
|
||||
const currentWorkflow = computed(() =>
|
||||
workflowsStore.getWorkflowById(currentWorkflowInstance.value.id),
|
||||
);
|
||||
const hasForeignCredential = computed(() => props.foreignCredentials.length > 0);
|
||||
const isHomeProjectTeam = computed(
|
||||
() => currentWorkflow.value.homeProject?.type === ProjectTypes.Team,
|
||||
);
|
||||
const isReadOnly = computed(
|
||||
() => props.readOnly || (hasForeignCredential.value && !isHomeProjectTeam.value),
|
||||
);
|
||||
const node = computed(() => ndvStore.activeNode);
|
||||
|
||||
const isTriggerNode = computed(() => !!node.value && nodeTypesStore.isTriggerNode(node.value.type));
|
||||
|
||||
const isExecutable = computed(() => {
|
||||
if (props.nodeType && node.value) {
|
||||
const workflow = workflowsStore.getCurrentWorkflow();
|
||||
const workflowNode = workflow.getNode(node.value.name);
|
||||
const inputs = NodeHelpers.getNodeInputs(workflow, workflowNode!, props.nodeType);
|
||||
const workflowNode = currentWorkflowInstance.value.getNode(node.value.name);
|
||||
const inputs = NodeHelpers.getNodeInputs(
|
||||
currentWorkflowInstance.value,
|
||||
workflowNode!,
|
||||
props.nodeType,
|
||||
);
|
||||
const inputNames = NodeHelpers.getConnectionTypes(inputs);
|
||||
|
||||
if (!inputNames.includes(NodeConnectionType.Main) && !isTriggerNode.value) {
|
||||
|
@ -195,8 +208,6 @@ const outputPanelEditMode = computed(() => ndvStore.outputPanelEditMode);
|
|||
|
||||
const isCommunityNode = computed(() => !!node.value && isCommunityPackageName(node.value.type));
|
||||
|
||||
const hasForeignCredential = computed(() => props.foreignCredentials.length > 0);
|
||||
|
||||
const usedCredentials = computed(() =>
|
||||
Object.values(workflowsStore.usedCredentials).filter((credential) =>
|
||||
Object.values(node.value?.credentials || []).find(
|
||||
|
@ -1017,7 +1028,7 @@ onBeforeUnmount(() => {
|
|||
</div>
|
||||
<div v-if="node && nodeValid" class="node-parameters-wrapper" data-test-id="node-parameters">
|
||||
<n8n-notice
|
||||
v-if="hasForeignCredential"
|
||||
v-if="hasForeignCredential && !isHomeProjectTeam"
|
||||
:content="
|
||||
$locale.baseText('nodeSettings.hasForeignCredential', {
|
||||
interpolate: { owner: credentialOwnerName },
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import ProjectCardBadge from '@/components/Projects/ProjectCardBadge.vue';
|
||||
import { truncate } from 'n8n-design-system';
|
||||
|
||||
const renderComponent = createComponentRenderer(ProjectCardBadge);
|
||||
|
||||
|
@ -56,6 +57,6 @@ describe('ProjectCardBadge', () => {
|
|||
},
|
||||
},
|
||||
});
|
||||
expect(getByText(result)).toBeVisible();
|
||||
expect(getByText(truncate(result, 20))).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -119,7 +119,7 @@ const badgeTooltip = computed(() => {
|
|||
<template>
|
||||
<N8nTooltip :disabled="!badgeTooltip" placement="top">
|
||||
<N8nBadge v-if="badgeText" class="mr-xs" theme="tertiary" bold data-test-id="card-badge">
|
||||
{{ badgeText }}
|
||||
<span v-n8n-truncate:20>{{ badgeText }}</span>
|
||||
<N8nIcon v-if="badgeIcon" :icon="badgeIcon" size="small" class="ml-5xs" />
|
||||
</N8nBadge>
|
||||
<template #content>
|
||||
|
|
|
@ -1,68 +0,0 @@
|
|||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import { PROJECT_MOVE_RESOURCE_CONFIRM_MODAL } from '@/constants';
|
||||
import ProjectMoveResourceConfirmModal from '@/components/Projects/ProjectMoveResourceConfirmModal.vue';
|
||||
import { useProjectsStore } from '@/stores/projects.store';
|
||||
import { useTelemetry } from '@/composables/useTelemetry';
|
||||
|
||||
vi.mock('@/stores/ui.store', () => ({
|
||||
useUIStore: vi.fn().mockReturnValue({
|
||||
modalsById: vi.fn().mockReturnValue(() => {
|
||||
open: true;
|
||||
}),
|
||||
closeModal: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
const renderComponent = createComponentRenderer(ProjectMoveResourceConfirmModal, {
|
||||
global: {
|
||||
stubs: {
|
||||
Modal: {
|
||||
template:
|
||||
'<div role="dialog"><slot name="header" /><slot name="content" /><slot name="footer" /></div>',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
let projectsStore: ReturnType<typeof useProjectsStore>;
|
||||
let telemetry: ReturnType<typeof useTelemetry>;
|
||||
|
||||
describe('ProjectMoveResourceConfirmModal', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
projectsStore = useProjectsStore();
|
||||
telemetry = useTelemetry();
|
||||
});
|
||||
|
||||
it('should send telemetry when resource moving is confirmed', async () => {
|
||||
vi.spyOn(projectsStore, 'moveResourceToProject').mockResolvedValue();
|
||||
|
||||
const telemetryTrackSpy = vi.spyOn(telemetry, 'track');
|
||||
|
||||
const props = {
|
||||
modalName: PROJECT_MOVE_RESOURCE_CONFIRM_MODAL,
|
||||
data: {
|
||||
resourceType: 'workflow',
|
||||
resource: {
|
||||
id: '1',
|
||||
},
|
||||
projectId: '1',
|
||||
projectName: 'My Project',
|
||||
},
|
||||
};
|
||||
const { getByRole, getAllByRole } = renderComponent({ props });
|
||||
const confirmBtn = getByRole('button', { name: /confirm/i });
|
||||
expect(confirmBtn).toBeDisabled();
|
||||
await Promise.all(
|
||||
getAllByRole('checkbox').map(async (checkbox) => await userEvent.click(checkbox)),
|
||||
);
|
||||
expect(confirmBtn).toBeEnabled();
|
||||
await userEvent.click(confirmBtn);
|
||||
expect(telemetryTrackSpy).toHaveBeenCalledWith(
|
||||
'User successfully moved workflow',
|
||||
expect.objectContaining({ workflow_id: '1' }),
|
||||
);
|
||||
});
|
||||
});
|
|
@ -1,131 +0,0 @@
|
|||
<script lang="ts" setup>
|
||||
import { ref, computed, h } from 'vue';
|
||||
import type { ICredentialsResponse, IWorkflowDb } from '@/Interface';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
import { useProjectsStore } from '@/stores/projects.store';
|
||||
import Modal from '@/components/Modal.vue';
|
||||
import { N8nCheckbox, N8nText } from 'n8n-design-system';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
import { useTelemetry } from '@/composables/useTelemetry';
|
||||
import { VIEWS } from '@/constants';
|
||||
import ProjectMoveSuccessToastMessage from '@/components/Projects/ProjectMoveSuccessToastMessage.vue';
|
||||
import { ResourceType } from '@/utils/projects.utils';
|
||||
|
||||
const props = defineProps<{
|
||||
modalName: string;
|
||||
data: {
|
||||
resource: IWorkflowDb | ICredentialsResponse;
|
||||
resourceType: ResourceType;
|
||||
resourceTypeLabel: string;
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
};
|
||||
}>();
|
||||
|
||||
const i18n = useI18n();
|
||||
const toast = useToast();
|
||||
const uiStore = useUIStore();
|
||||
const projectsStore = useProjectsStore();
|
||||
const telemetry = useTelemetry();
|
||||
|
||||
const checks = ref([false, false]);
|
||||
const allChecked = computed(() => checks.value.every(Boolean));
|
||||
|
||||
const moveResourceLabel = computed(() =>
|
||||
props.data.resourceType === ResourceType.Workflow
|
||||
? i18n.baseText('projects.move.workflow.confirm.modal.label')
|
||||
: i18n.baseText('projects.move.credential.confirm.modal.label'),
|
||||
);
|
||||
|
||||
const closeModal = () => {
|
||||
uiStore.closeModal(props.modalName);
|
||||
};
|
||||
|
||||
const confirm = async () => {
|
||||
try {
|
||||
await projectsStore.moveResourceToProject(
|
||||
props.data.resourceType,
|
||||
props.data.resource.id,
|
||||
props.data.projectId,
|
||||
);
|
||||
closeModal();
|
||||
telemetry.track(`User successfully moved ${props.data.resourceType}`, {
|
||||
[`${props.data.resourceType}_id`]: props.data.resource.id,
|
||||
project_from_type: projectsStore.currentProject?.type ?? projectsStore.personalProject?.type,
|
||||
});
|
||||
toast.showToast({
|
||||
title: i18n.baseText('projects.move.resource.success.title', {
|
||||
interpolate: {
|
||||
resourceTypeLabel: props.data.resourceTypeLabel,
|
||||
},
|
||||
}),
|
||||
message: h(ProjectMoveSuccessToastMessage, {
|
||||
routeName:
|
||||
props.data.resourceType === ResourceType.Workflow
|
||||
? VIEWS.PROJECTS_WORKFLOWS
|
||||
: VIEWS.PROJECTS_CREDENTIALS,
|
||||
resource: props.data.resource,
|
||||
resourceTypeLabel: props.data.resourceTypeLabel,
|
||||
projectId: props.data.projectId,
|
||||
projectName: props.data.projectName,
|
||||
}),
|
||||
type: 'success',
|
||||
});
|
||||
} catch (error) {
|
||||
toast.showError(
|
||||
error.message,
|
||||
i18n.baseText('projects.move.resource.error.title', {
|
||||
interpolate: {
|
||||
resourceTypeLabel: props.data.resourceTypeLabel,
|
||||
resourceName: props.data.resource.name,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
<template>
|
||||
<Modal width="500px" :name="props.modalName" data-test-id="project-move-resource-confirm-modal">
|
||||
<template #header>
|
||||
<N8nHeading tag="h2" size="xlarge" class="mb-m">
|
||||
{{ i18n.baseText('projects.move.resource.confirm.modal.title') }}
|
||||
</N8nHeading>
|
||||
</template>
|
||||
<template #content>
|
||||
<N8nCheckbox v-model="checks[0]" :label="moveResourceLabel" />
|
||||
<N8nCheckbox v-model="checks[1]">
|
||||
<N8nText>
|
||||
<i18n-t keypath="projects.move.resource.confirm.modal.label">
|
||||
<template #resourceTypeLabel>{{ props.data.resourceTypeLabel }}</template>
|
||||
<template #numberOfUsers>{{
|
||||
i18n.baseText('projects.move.resource.confirm.modal.numberOfUsers', {
|
||||
interpolate: {
|
||||
numberOfUsers: props.data.resource.sharedWithProjects?.length ?? 0,
|
||||
},
|
||||
adjustToNumber: props.data.resource.sharedWithProjects?.length,
|
||||
})
|
||||
}}</template>
|
||||
</i18n-t>
|
||||
</N8nText>
|
||||
</N8nCheckbox>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div :class="$style.buttons">
|
||||
<N8nButton type="secondary" text class="mr-2xs" @click="closeModal">
|
||||
{{ i18n.baseText('generic.cancel') }}
|
||||
</N8nButton>
|
||||
<N8nButton :disabled="!allChecked" type="primary" @click="confirm">
|
||||
{{ i18n.baseText('projects.move.resource.confirm.modal.button.confirm') }}
|
||||
</N8nButton>
|
||||
</div>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.buttons {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
|
@ -1,8 +1,10 @@
|
|||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import { PROJECT_MOVE_RESOURCE_CONFIRM_MODAL } from '@/constants';
|
||||
import { PROJECT_MOVE_RESOURCE_MODAL } from '@/constants';
|
||||
import ProjectMoveResourceModal from '@/components/Projects/ProjectMoveResourceModal.vue';
|
||||
import { useTelemetry } from '@/composables/useTelemetry';
|
||||
import { mockedStore } from '@/__tests__/utils';
|
||||
import { useProjectsStore } from '@/stores/projects.store';
|
||||
|
||||
const renderComponent = createComponentRenderer(ProjectMoveResourceModal, {
|
||||
global: {
|
||||
|
@ -19,28 +21,67 @@ let telemetry: ReturnType<typeof useTelemetry>;
|
|||
|
||||
describe('ProjectMoveResourceModal', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
telemetry = useTelemetry();
|
||||
});
|
||||
|
||||
it('should send telemetry when mounted', async () => {
|
||||
const pinia = createTestingPinia();
|
||||
const telemetryTrackSpy = vi.spyOn(telemetry, 'track');
|
||||
|
||||
const projectsStore = mockedStore(useProjectsStore);
|
||||
projectsStore.availableProjects = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'My Project',
|
||||
type: 'personal',
|
||||
role: 'project:personalOwner',
|
||||
createdAt: '2021-01-01T00:00:00.000Z',
|
||||
updatedAt: '2021-01-01T00:00:00.000Z',
|
||||
},
|
||||
];
|
||||
|
||||
const props = {
|
||||
modalName: PROJECT_MOVE_RESOURCE_CONFIRM_MODAL,
|
||||
modalName: PROJECT_MOVE_RESOURCE_MODAL,
|
||||
data: {
|
||||
resourceType: 'workflow',
|
||||
resourceTypeLabel: 'Workflow',
|
||||
resource: {
|
||||
id: '1',
|
||||
homeProject: {
|
||||
id: '2',
|
||||
name: 'My Project',
|
||||
},
|
||||
},
|
||||
projectId: '1',
|
||||
projectName: 'My Project',
|
||||
},
|
||||
};
|
||||
renderComponent({ props });
|
||||
renderComponent({ props, pinia });
|
||||
expect(telemetryTrackSpy).toHaveBeenCalledWith(
|
||||
'User clicked to move a workflow',
|
||||
expect.objectContaining({ workflow_id: '1' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should show no available projects message', async () => {
|
||||
const pinia = createTestingPinia();
|
||||
|
||||
const projectsStore = mockedStore(useProjectsStore);
|
||||
projectsStore.availableProjects = [];
|
||||
|
||||
const props = {
|
||||
modalName: PROJECT_MOVE_RESOURCE_MODAL,
|
||||
data: {
|
||||
resourceType: 'workflow',
|
||||
resourceTypeLabel: 'Workflow',
|
||||
resource: {
|
||||
id: '1',
|
||||
homeProject: {
|
||||
id: '2',
|
||||
name: 'My Project',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const { getByText } = renderComponent({ props, pinia });
|
||||
expect(getByText(/Currently there are not any projects or users available/)).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,14 +1,17 @@
|
|||
<script lang="ts" setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { ref, computed, onMounted, h } from 'vue';
|
||||
import type { ICredentialsResponse, IWorkflowDb } from '@/Interface';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
import { useProjectsStore } from '@/stores/projects.store';
|
||||
import Modal from '@/components/Modal.vue';
|
||||
import { PROJECT_MOVE_RESOURCE_CONFIRM_MODAL } from '@/constants';
|
||||
import type { ResourceType } from '@/utils/projects.utils';
|
||||
import { splitName } from '@/utils/projects.utils';
|
||||
import { VIEWS } from '@/constants';
|
||||
import { ResourceType, splitName } from '@/utils/projects.utils';
|
||||
import { useTelemetry } from '@/composables/useTelemetry';
|
||||
import { ProjectTypes } from '@/types/projects.types';
|
||||
import ProjectMoveSuccessToastMessage from '@/components/Projects/ProjectMoveSuccessToastMessage.vue';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
import { getResourcePermissions } from '@/permissions';
|
||||
|
||||
const props = defineProps<{
|
||||
modalName: string;
|
||||
|
@ -21,17 +24,37 @@ const props = defineProps<{
|
|||
|
||||
const i18n = useI18n();
|
||||
const uiStore = useUIStore();
|
||||
const toast = useToast();
|
||||
const projectsStore = useProjectsStore();
|
||||
const telemetry = useTelemetry();
|
||||
|
||||
const filter = ref('');
|
||||
const projectId = ref<string | null>(null);
|
||||
const processedName = computed(() => {
|
||||
const { name, email } = splitName(props.data.resource.homeProject?.name ?? '');
|
||||
const processedName = computed(
|
||||
() => processProjectName(props.data.resource.homeProject?.name ?? '') ?? '',
|
||||
);
|
||||
const availableProjects = computed(() =>
|
||||
projectsStore.availableProjects
|
||||
.filter(
|
||||
(p) =>
|
||||
p.name?.toLowerCase().includes(filter.value.toLowerCase()) &&
|
||||
p.id !== props.data.resource.homeProject?.id &&
|
||||
(!p.scopes || getResourcePermissions(p.scopes)[props.data.resourceType].create),
|
||||
)
|
||||
.sort((a, b) => a.name?.localeCompare(b.name ?? '') ?? 0),
|
||||
);
|
||||
const selectedProject = computed(() =>
|
||||
availableProjects.value.find((p) => p.id === projectId.value),
|
||||
);
|
||||
const isResourceInTeamProject = computed(() => isHomeProjectTeam(props.data.resource));
|
||||
|
||||
const isHomeProjectTeam = (resource: IWorkflowDb | ICredentialsResponse) =>
|
||||
resource.homeProject?.type === ProjectTypes.Team;
|
||||
|
||||
const processProjectName = (projectName: string) => {
|
||||
const { name, email } = splitName(projectName);
|
||||
return name ?? email;
|
||||
});
|
||||
const availableProjects = computed(() => {
|
||||
return projectsStore.teamProjects.filter((p) => p.id !== props.data.resource.homeProject?.id);
|
||||
});
|
||||
};
|
||||
|
||||
const updateProject = (value: string) => {
|
||||
projectId.value = value;
|
||||
|
@ -41,18 +64,53 @@ const closeModal = () => {
|
|||
uiStore.closeModal(props.modalName);
|
||||
};
|
||||
|
||||
const next = () => {
|
||||
closeModal();
|
||||
uiStore.openModalWithData({
|
||||
name: PROJECT_MOVE_RESOURCE_CONFIRM_MODAL,
|
||||
data: {
|
||||
resource: props.data.resource,
|
||||
resourceType: props.data.resourceType,
|
||||
resourceTypeLabel: props.data.resourceTypeLabel,
|
||||
projectId: projectId.value,
|
||||
projectName: availableProjects.value.find((p) => p.id === projectId.value)?.name ?? '',
|
||||
},
|
||||
});
|
||||
const setFilter = (query: string) => {
|
||||
filter.value = query;
|
||||
};
|
||||
|
||||
const moveResource = async () => {
|
||||
if (!selectedProject.value) return;
|
||||
try {
|
||||
await projectsStore.moveResourceToProject(
|
||||
props.data.resourceType,
|
||||
props.data.resource.id,
|
||||
selectedProject.value.id,
|
||||
);
|
||||
closeModal();
|
||||
telemetry.track(`User successfully moved ${props.data.resourceType}`, {
|
||||
[`${props.data.resourceType}_id`]: props.data.resource.id,
|
||||
project_from_type: projectsStore.currentProject?.type ?? projectsStore.personalProject?.type,
|
||||
});
|
||||
toast.showToast({
|
||||
title: i18n.baseText('projects.move.resource.success.title', {
|
||||
interpolate: {
|
||||
resourceTypeLabel: props.data.resourceTypeLabel,
|
||||
},
|
||||
}),
|
||||
message: h(ProjectMoveSuccessToastMessage, {
|
||||
routeName:
|
||||
props.data.resourceType === ResourceType.Workflow
|
||||
? VIEWS.PROJECTS_WORKFLOWS
|
||||
: VIEWS.PROJECTS_CREDENTIALS,
|
||||
resource: props.data.resource,
|
||||
resourceType: props.data.resourceType,
|
||||
resourceTypeLabel: props.data.resourceTypeLabel,
|
||||
targetProject: selectedProject.value,
|
||||
}),
|
||||
type: 'success',
|
||||
duration: 8000,
|
||||
});
|
||||
} catch (error) {
|
||||
toast.showError(
|
||||
error.message,
|
||||
i18n.baseText('projects.move.resource.error.title', {
|
||||
interpolate: {
|
||||
resourceTypeLabel: props.data.resourceTypeLabel,
|
||||
resourceName: props.data.resource.name,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
|
@ -65,7 +123,7 @@ onMounted(() => {
|
|||
<template>
|
||||
<Modal width="500px" :name="props.modalName" data-test-id="project-move-resource-modal">
|
||||
<template #header>
|
||||
<N8nHeading tag="h2" size="xlarge" class="mb-m">
|
||||
<N8nHeading tag="h2" size="xlarge" class="mb-m pr-s">
|
||||
{{
|
||||
i18n.baseText('projects.move.resource.modal.title', {
|
||||
interpolate: { resourceTypeLabel: props.data.resourceTypeLabel },
|
||||
|
@ -77,20 +135,37 @@ onMounted(() => {
|
|||
<template #resourceName
|
||||
><strong>{{ props.data.resource.name }}</strong></template
|
||||
>
|
||||
<template #resourceHomeProjectName>{{ processedName }}</template>
|
||||
<template #resourceTypeLabel>{{ props.data.resourceTypeLabel }}</template>
|
||||
<template v-if="isResourceInTeamProject" #inTeamProject>
|
||||
<i18n-t keypath="projects.move.resource.modal.message.team">
|
||||
<template #resourceHomeProjectName
|
||||
><strong>{{ processedName }}</strong></template
|
||||
>
|
||||
</i18n-t>
|
||||
</template>
|
||||
<template v-else #inPersonalProject>
|
||||
<i18n-t keypath="projects.move.resource.modal.message.personal">
|
||||
<template #resourceHomeProjectName
|
||||
><strong>{{ processedName }}</strong></template
|
||||
>
|
||||
</i18n-t>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</N8nText>
|
||||
</template>
|
||||
<template #content>
|
||||
<div>
|
||||
<div v-if="availableProjects.length">
|
||||
<N8nSelect
|
||||
class="mr-2xs"
|
||||
class="mr-2xs mb-xs"
|
||||
:model-value="projectId"
|
||||
size="small"
|
||||
:filterable="true"
|
||||
:filter-method="setFilter"
|
||||
:placeholder="i18n.baseText('projects.move.resource.modal.selectPlaceholder')"
|
||||
data-test-id="project-move-resource-modal-select"
|
||||
@update:model-value="updateProject"
|
||||
>
|
||||
<template #prefix>
|
||||
<N8nIcon icon="search" />
|
||||
</template>
|
||||
<N8nOption
|
||||
v-for="p in availableProjects"
|
||||
:key="p.id"
|
||||
|
@ -98,15 +173,45 @@ onMounted(() => {
|
|||
:label="p.name"
|
||||
></N8nOption>
|
||||
</N8nSelect>
|
||||
<N8nText>
|
||||
<i18n-t keypath="projects.move.resource.modal.message.sharingNote">
|
||||
<template #note
|
||||
><strong>{{
|
||||
i18n.baseText('projects.move.resource.modal.message.note')
|
||||
}}</strong></template
|
||||
>
|
||||
<template #resourceTypeLabel>{{ props.data.resourceTypeLabel }}</template>
|
||||
</i18n-t>
|
||||
<span v-if="props.data.resource.sharedWithProjects?.length ?? 0 > 0">
|
||||
<br />
|
||||
{{
|
||||
i18n.baseText('projects.move.resource.modal.message.sharingInfo', {
|
||||
adjustToNumber: props.data.resource.sharedWithProjects?.length,
|
||||
interpolate: {
|
||||
numberOfProjects: props.data.resource.sharedWithProjects?.length ?? 0,
|
||||
},
|
||||
})
|
||||
}}</span
|
||||
>
|
||||
</N8nText>
|
||||
</div>
|
||||
<N8nText v-else>{{
|
||||
i18n.baseText('projects.move.resource.modal.message.noProjects', {
|
||||
interpolate: { resourceTypeLabel: props.data.resourceTypeLabel },
|
||||
})
|
||||
}}</N8nText>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div :class="$style.buttons">
|
||||
<N8nButton type="secondary" text class="mr-2xs" @click="closeModal">
|
||||
{{ i18n.baseText('generic.cancel') }}
|
||||
</N8nButton>
|
||||
<N8nButton :disabled="!projectId" type="primary" @click="next">
|
||||
{{ i18n.baseText('generic.next') }}
|
||||
<N8nButton :disabled="!projectId" type="primary" @click="moveResource">
|
||||
{{
|
||||
i18n.baseText('projects.move.resource.modal.button', {
|
||||
interpolate: { resourceTypeLabel: props.data.resourceTypeLabel },
|
||||
})
|
||||
}}
|
||||
</N8nButton>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
@ -0,0 +1,87 @@
|
|||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import ProjectMoveSuccessToastMessage from '@/components/Projects/ProjectMoveSuccessToastMessage.vue';
|
||||
import { ResourceType } from '@/utils/projects.utils';
|
||||
import { VIEWS } from '@/constants';
|
||||
import { ProjectTypes } from '@/types/projects.types';
|
||||
|
||||
const renderComponent = createComponentRenderer(ProjectMoveSuccessToastMessage, {
|
||||
global: {
|
||||
stubs: {
|
||||
'router-link': {
|
||||
template: '<a href="#"><slot /></a>',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
describe('ProjectMoveSuccessToastMessage', () => {
|
||||
it('should show credentials message if the resource is a workflow', async () => {
|
||||
const props = {
|
||||
routeName: VIEWS.PROJECTS_WORKFLOWS,
|
||||
resource: {
|
||||
id: '1',
|
||||
name: 'My Workflow',
|
||||
homeProject: {
|
||||
id: '2',
|
||||
name: 'My Project',
|
||||
},
|
||||
},
|
||||
resourceType: ResourceType.Workflow,
|
||||
resourceTypeLabel: 'Workflow',
|
||||
targetProject: {
|
||||
id: '2',
|
||||
name: 'My Project',
|
||||
},
|
||||
};
|
||||
const { getByText } = renderComponent({ props });
|
||||
expect(getByText(/Please double check any credentials/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show link if the target project type is team project', async () => {
|
||||
const props = {
|
||||
routeName: VIEWS.PROJECTS_WORKFLOWS,
|
||||
resource: {
|
||||
id: '1',
|
||||
name: 'My Workflow',
|
||||
homeProject: {
|
||||
id: '2',
|
||||
name: 'My Project',
|
||||
},
|
||||
},
|
||||
resourceType: ResourceType.Workflow,
|
||||
resourceTypeLabel: 'workflow',
|
||||
targetProject: {
|
||||
id: '2',
|
||||
name: 'Team Project',
|
||||
type: ProjectTypes.Team,
|
||||
},
|
||||
};
|
||||
const { getByRole } = renderComponent({ props });
|
||||
expect(getByRole('link')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show only general if the resource is credential and moved to a personal project', async () => {
|
||||
const props = {
|
||||
routeName: VIEWS.PROJECTS_WORKFLOWS,
|
||||
resource: {
|
||||
id: '1',
|
||||
name: 'Notion API',
|
||||
homeProject: {
|
||||
id: '2',
|
||||
name: 'My Project',
|
||||
},
|
||||
},
|
||||
resourceType: ResourceType.Credential,
|
||||
resourceTypeLabel: 'credential',
|
||||
targetProject: {
|
||||
id: '2',
|
||||
name: 'Personal Project',
|
||||
type: ProjectTypes.Personal,
|
||||
},
|
||||
};
|
||||
const { getByText, queryByText, queryByRole } = renderComponent({ props });
|
||||
expect(getByText(/credential was moved to /)).toBeInTheDocument();
|
||||
expect(queryByText(/Please double check any credentials/)).not.toBeInTheDocument();
|
||||
expect(queryByRole('link')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
|
@ -1,32 +1,57 @@
|
|||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import { truncate } from 'n8n-design-system';
|
||||
import type { ICredentialsResponse, IWorkflowDb } from '@/Interface';
|
||||
import { ResourceType, splitName } from '@/utils/projects.utils';
|
||||
import type { ProjectListItem } from '@/types/projects.types';
|
||||
import { ProjectTypes } from '@/types/projects.types';
|
||||
|
||||
const props = defineProps<{
|
||||
routeName: string;
|
||||
resource: IWorkflowDb | ICredentialsResponse;
|
||||
resourceType: ResourceType;
|
||||
resourceTypeLabel: string;
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
targetProject: ProjectListItem;
|
||||
}>();
|
||||
|
||||
const isWorkflow = computed(() => props.resourceType === ResourceType.Workflow);
|
||||
const isTargetProjectTeam = computed(() => props.targetProject.type === ProjectTypes.Team);
|
||||
const projectName = computed(() => {
|
||||
const { name, email } = splitName(props.targetProject?.name ?? '');
|
||||
return truncate(name ?? email ?? '', 25);
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<i18n-t keypath="projects.move.resource.success.message">
|
||||
<template #resourceTypeLabel>{{ props.resourceTypeLabel }}</template>
|
||||
<template #resourceName>{{ props.resource.name }}</template>
|
||||
<template #targetProjectName>{{ props.projectName }}</template>
|
||||
<template #link>
|
||||
<router-link
|
||||
:to="{
|
||||
name: props.routeName,
|
||||
params: { projectId: props.projectId },
|
||||
}"
|
||||
>
|
||||
<p class="pt-s">
|
||||
<template #resourceName
|
||||
><strong>{{ props.resource.name }}</strong></template
|
||||
>
|
||||
<template #targetProjectName
|
||||
><strong>{{ projectName }}</strong></template
|
||||
>
|
||||
<template v-if="isWorkflow" #workflow>
|
||||
<N8nText tag="p" class="pt-xs">
|
||||
<i18n-t keypath="projects.move.resource.success.message.workflow">
|
||||
<template #targetProjectName
|
||||
><strong>{{ projectName }}</strong></template
|
||||
>
|
||||
</i18n-t>
|
||||
</N8nText>
|
||||
</template>
|
||||
<template v-if="isTargetProjectTeam" #link>
|
||||
<p class="pt-s">
|
||||
<router-link
|
||||
:to="{
|
||||
name: props.routeName,
|
||||
params: { projectId: props.targetProject.id },
|
||||
}"
|
||||
>
|
||||
<i18n-t keypath="projects.move.resource.success.link">
|
||||
<template #targetProjectName>{{ props.projectName }}</template>
|
||||
<template #targetProjectName>{{ projectName }}</template>
|
||||
</i18n-t>
|
||||
</p>
|
||||
</router-link>
|
||||
</router-link>
|
||||
</p>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</template>
|
||||
|
|
|
@ -67,5 +67,9 @@ const processedName = computed(() => {
|
|||
.text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -63,10 +63,11 @@ export default defineComponent({
|
|||
},
|
||||
},
|
||||
async beforeMount() {
|
||||
await this.projectsStore.getAllProjects();
|
||||
await this.projectsStore.getAvailableProjects();
|
||||
this.selectedProject =
|
||||
this.projectsStore.projects.find((project) => project.id === this.modelValue.homeProject) ??
|
||||
null;
|
||||
this.projectsStore.availableProjects.find(
|
||||
(project) => project.id === this.modelValue.homeProject,
|
||||
) ?? null;
|
||||
},
|
||||
methods: {
|
||||
setKeyValue(key: string, value: unknown) {
|
||||
|
@ -126,7 +127,7 @@ export default defineComponent({
|
|||
/>
|
||||
<ProjectSharing
|
||||
v-model="selectedProject"
|
||||
:projects="projectsStore.projects"
|
||||
:projects="projectsStore.availableProjects"
|
||||
:placeholder="$locale.baseText('forms.resourceFiltersDropdown.owner.placeholder')"
|
||||
:empty-options-text="$locale.baseText('projects.sharing.noMatchingProjects')"
|
||||
@update:model-value="setKeyValue('homeProject', ($event as ProjectSharingData).id)"
|
||||
|
|
|
@ -69,7 +69,6 @@ export const PROMPT_MFA_CODE_MODAL_KEY = 'promptMfaCode';
|
|||
export const WORKFLOW_HISTORY_VERSION_RESTORE = 'workflowHistoryVersionRestore';
|
||||
export const SETUP_CREDENTIALS_MODAL_KEY = 'setupCredentials';
|
||||
export const PROJECT_MOVE_RESOURCE_MODAL = 'projectMoveResourceModal';
|
||||
export const PROJECT_MOVE_RESOURCE_CONFIRM_MODAL = 'projectMoveResourceConfirmModal';
|
||||
export const NEW_ASSISTANT_SESSION_MODAL = 'newAssistantSession';
|
||||
export const EXTERNAL_SECRETS_PROVIDER_MODAL_KEY = 'externalSecretsProvider';
|
||||
|
||||
|
|
|
@ -2504,17 +2504,20 @@
|
|||
"projects.create.limit": "{num} project | {num} projects",
|
||||
"projects.create.limitReached": "You have reached the {planName} plan limit of {limit}. Upgrade your plan to unlock more projects. {link}",
|
||||
"projects.create.limitReached.link": "View plans",
|
||||
"projects.move.resource.modal.title": "Choose a project to move this {resourceTypeLabel} to",
|
||||
"projects.move.resource.modal.message": "\"{resourceName}\" is currently in the \"{resourceHomeProjectName}\" project. Which project would you like to move this {resourceTypeLabel} to?",
|
||||
"projects.move.resource.confirm.modal.title": "Please confirm the following",
|
||||
"projects.move.resource.confirm.modal.button.confirm": "Confirm move to new project",
|
||||
"projects.move.workflow.confirm.modal.label": "This workflow may stop working if the credentials used with it do not exist in the project its being moved to",
|
||||
"projects.move.credential.confirm.modal.label": "Any workflows currently using this credential will stop working once this credential has been moved",
|
||||
"projects.move.resource.confirm.modal.label": "Any individual sharing currently associated with this {resourceTypeLabel} will be removed. (currently shared with {numberOfUsers})",
|
||||
"projects.move.resource.confirm.modal.numberOfUsers": "{numberOfUsers} user | {numberOfUsers} users",
|
||||
"projects.move.resource.modal.title": "Choose a project or user to move this {resourceTypeLabel} to",
|
||||
"projects.move.resource.modal.message": "\"{resourceName}\" is currently {inPersonalProject}{inTeamProject}",
|
||||
"projects.move.resource.modal.message.team": "in the \"{resourceHomeProjectName}\" project.",
|
||||
"projects.move.resource.modal.message.personal": "owned by \"{resourceHomeProjectName}\".",
|
||||
"projects.move.resource.modal.message.note": "Note",
|
||||
"projects.move.resource.modal.message.sharingNote": "{note}: Moving will remove any existing sharing for this {resourceTypeLabel}.",
|
||||
"projects.move.resource.modal.message.sharingInfo": "(Currently shared with {numberOfProjects} project) | (Currently shared with {numberOfProjects} projects)",
|
||||
"projects.move.resource.modal.message.noProjects": "Currently there are not any projects or users available for you to move this {resourceTypeLabel} to.",
|
||||
"projects.move.resource.modal.button": "Move {resourceTypeLabel}",
|
||||
"projects.move.resource.modal.selectPlaceholder": "Select project or user...",
|
||||
"projects.move.resource.error.title": "Error moving {resourceName} {resourceTypeLabel}",
|
||||
"projects.move.resource.success.title": "Successfully moved {resourceTypeLabel}",
|
||||
"projects.move.resource.success.message": "{resourceName} {resourceTypeLabel} was moved to {targetProjectName} {link}",
|
||||
"projects.move.resource.success.message": "{resourceName} {resourceTypeLabel} was moved to {targetProjectName}. {workflow} {link}",
|
||||
"projects.move.resource.success.message.workflow": "Please double check any credentials this workflow is using are also shared with {targetProjectName}.",
|
||||
"projects.move.resource.success.link": "View {targetProjectName}",
|
||||
"projects.badge.tooltip.sharedOwned": "This {resourceTypeLabel} is owned by you and shared with one or more projects or users",
|
||||
"projects.badge.tooltip.sharedPersonal": "This {resourceTypeLabel} is owned by {name} and shared with one or more projects or users",
|
||||
|
|
|
@ -19,6 +19,8 @@ import type { IWorkflowDb } from '@/Interface';
|
|||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import { useCredentialsStore } from '@/stores/credentials.store';
|
||||
import { STORES } from '@/constants';
|
||||
import { useUsersStore } from '@/stores/users.store';
|
||||
import { getResourcePermissions } from '@/permissions';
|
||||
|
||||
export const useProjectsStore = defineStore(STORES.PROJECTS, () => {
|
||||
const route = useRoute();
|
||||
|
@ -26,6 +28,7 @@ export const useProjectsStore = defineStore(STORES.PROJECTS, () => {
|
|||
const settingsStore = useSettingsStore();
|
||||
const workflowsStore = useWorkflowsStore();
|
||||
const credentialsStore = useCredentialsStore();
|
||||
const usersStore = useUsersStore();
|
||||
|
||||
const projects = ref<ProjectListItem[]>([]);
|
||||
const myProjects = ref<ProjectListItem[]>([]);
|
||||
|
@ -38,6 +41,13 @@ export const useProjectsStore = defineStore(STORES.PROJECTS, () => {
|
|||
});
|
||||
const projectNavActiveIdState = ref<string | string[] | null>(null);
|
||||
|
||||
const globalProjectPermissions = computed(
|
||||
() => getResourcePermissions(usersStore.currentUser?.globalScopes).project,
|
||||
);
|
||||
const availableProjects = computed(() =>
|
||||
globalProjectPermissions.value.list ? projects.value : myProjects.value,
|
||||
);
|
||||
|
||||
const currentProjectId = computed(
|
||||
() =>
|
||||
(route.params?.projectId as string | undefined) ??
|
||||
|
@ -91,6 +101,14 @@ export const useProjectsStore = defineStore(STORES.PROJECTS, () => {
|
|||
personalProject.value = await projectsApi.getPersonalProject(rootStore.restApiContext);
|
||||
};
|
||||
|
||||
const getAvailableProjects = async () => {
|
||||
if (globalProjectPermissions.value.list) {
|
||||
await getAllProjects();
|
||||
} else {
|
||||
await getMyProjects();
|
||||
}
|
||||
};
|
||||
|
||||
const fetchProject = async (id: string) =>
|
||||
await projectsApi.getProject(rootStore.restApiContext, id);
|
||||
|
||||
|
@ -189,6 +207,7 @@ export const useProjectsStore = defineStore(STORES.PROJECTS, () => {
|
|||
|
||||
return {
|
||||
projects,
|
||||
availableProjects,
|
||||
myProjects,
|
||||
personalProject,
|
||||
currentProject,
|
||||
|
@ -206,6 +225,7 @@ export const useProjectsStore = defineStore(STORES.PROJECTS, () => {
|
|||
getAllProjects,
|
||||
getMyProjects,
|
||||
getPersonalProject,
|
||||
getAvailableProjects,
|
||||
fetchProject,
|
||||
getProject,
|
||||
createProject,
|
||||
|
|
|
@ -35,7 +35,6 @@ import {
|
|||
WORKFLOW_HISTORY_VERSION_RESTORE,
|
||||
SETUP_CREDENTIALS_MODAL_KEY,
|
||||
PROJECT_MOVE_RESOURCE_MODAL,
|
||||
PROJECT_MOVE_RESOURCE_CONFIRM_MODAL,
|
||||
NEW_ASSISTANT_SESSION_MODAL,
|
||||
PROMPT_MFA_CODE_MODAL_KEY,
|
||||
} from '@/constants';
|
||||
|
@ -126,7 +125,6 @@ export const useUIStore = defineStore(STORES.UI, () => {
|
|||
WORKFLOW_HISTORY_VERSION_RESTORE,
|
||||
SETUP_CREDENTIALS_MODAL_KEY,
|
||||
PROJECT_MOVE_RESOURCE_MODAL,
|
||||
PROJECT_MOVE_RESOURCE_CONFIRM_MODAL,
|
||||
NEW_ASSISTANT_SESSION_MODAL,
|
||||
].map((modalKey) => [modalKey, { open: false }]),
|
||||
),
|
||||
|
|
|
@ -51,10 +51,8 @@ describe('ProjectSettings', () => {
|
|||
settingsStore = useSettingsStore();
|
||||
|
||||
vi.spyOn(usersStore, 'fetchUsers').mockImplementation(async () => await Promise.resolve());
|
||||
vi.spyOn(projectsStore, 'getAllProjects').mockImplementation(
|
||||
async () => await Promise.resolve(),
|
||||
);
|
||||
vi.spyOn(projectsStore, 'projects', 'get').mockReturnValue(projects);
|
||||
vi.spyOn(projectsStore, 'getAvailableProjects').mockImplementation(async () => {});
|
||||
vi.spyOn(projectsStore, 'availableProjects', 'get').mockReturnValue(projects);
|
||||
vi.spyOn(settingsStore, 'settings', 'get').mockReturnValue({
|
||||
enterprise: {
|
||||
projects: {
|
||||
|
|
|
@ -60,7 +60,9 @@ const usersList = computed(() =>
|
|||
);
|
||||
|
||||
const projects = computed(() =>
|
||||
projectsStore.projects.filter((project) => project.id !== projectsStore.currentProjectId),
|
||||
projectsStore.availableProjects.filter(
|
||||
(project) => project.id !== projectsStore.currentProjectId,
|
||||
),
|
||||
);
|
||||
const projectRoles = computed(() =>
|
||||
rolesStore.processedProjectRoles.map((role) => ({
|
||||
|
@ -200,7 +202,7 @@ const onSubmit = async () => {
|
|||
};
|
||||
|
||||
const onDelete = async () => {
|
||||
await projectsStore.getAllProjects();
|
||||
await projectsStore.getAvailableProjects();
|
||||
dialogVisible.value = true;
|
||||
};
|
||||
|
||||
|
|
|
@ -61,7 +61,7 @@ describe('WorkflowsView', () => {
|
|||
usersStore = useUsersStore();
|
||||
projectsStore = useProjectsStore();
|
||||
|
||||
vi.spyOn(projectsStore, 'getAllProjects').mockImplementation(async () => {});
|
||||
vi.spyOn(projectsStore, 'getAvailableProjects').mockImplementation(async () => {});
|
||||
|
||||
await settingsStore.getSettings();
|
||||
await usersStore.fetchUsers();
|
||||
|
|
|
@ -258,7 +258,7 @@ const WorkflowsView = defineComponent({
|
|||
});
|
||||
},
|
||||
isValidProjectId(projectId: string) {
|
||||
return this.projectsStore.projects.some((project) => project.id === projectId);
|
||||
return this.projectsStore.availableProjects.some((project) => project.id === projectId);
|
||||
},
|
||||
async setFiltersFromQueryString() {
|
||||
const { tags, status, search, homeProject } = this.$route.query;
|
||||
|
@ -266,7 +266,7 @@ const WorkflowsView = defineComponent({
|
|||
const filtersToApply: { [key: string]: string | string[] | boolean } = {};
|
||||
|
||||
if (homeProject && typeof homeProject === 'string') {
|
||||
await this.projectsStore.getAllProjects();
|
||||
await this.projectsStore.getAvailableProjects();
|
||||
if (this.isValidProjectId(homeProject)) {
|
||||
filtersToApply.homeProject = homeProject;
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue