feat(editor): Add move resources option to workflows and credentials on (#9654)

This commit is contained in:
Csaba Tuncsik 2024-06-11 14:21:16 +02:00 committed by GitHub
parent dda7901398
commit bc35e8c33d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 960 additions and 495 deletions

View file

@ -1,3 +1,8 @@
import { CredentialsModal, WorkflowPage } from '../pages';
const workflowPage = new WorkflowPage();
const credentialsModal = new CredentialsModal();
export const getHomeButton = () => cy.getByTestId('project-home-menu-item'); export const getHomeButton = () => cy.getByTestId('project-home-menu-item');
export const getMenuItems = () => cy.getByTestId('project-menu-item'); export const getMenuItems = () => cy.getByTestId('project-menu-item');
export const getAddProjectButton = () => cy.getByTestId('add-project-menu-item'); export const getAddProjectButton = () => cy.getByTestId('add-project-menu-item');
@ -11,8 +16,42 @@ export const getProjectSettingsCancelButton = () =>
export const getProjectSettingsDeleteButton = () => export const getProjectSettingsDeleteButton = () =>
cy.getByTestId('project-settings-delete-button'); cy.getByTestId('project-settings-delete-button');
export const getProjectMembersSelect = () => cy.getByTestId('project-members-select'); export const getProjectMembersSelect = () => cy.getByTestId('project-members-select');
export const addProjectMember = (email: string) => { export const addProjectMember = (email: string) => {
getProjectMembersSelect().click(); getProjectMembersSelect().click();
getProjectMembersSelect().get('.el-select-dropdown__item').contains(email.toLowerCase()).click(); getProjectMembersSelect().get('.el-select-dropdown__item').contains(email.toLowerCase()).click();
}; };
export const getProjectNameInput = () => cy.get('#projectName');
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) {
getAddProjectButton().should('be.visible').click();
getProjectNameInput()
.should('be.visible')
.should('be.focused')
.should('have.value', 'My project')
.clear()
.type(name);
getProjectSettingsSaveButton().click();
}
export function createWorkflow(fixtureKey: string, name: string) {
workflowPage.getters.workflowImportInput().selectFile(`fixtures/${fixtureKey}`, { force: true });
workflowPage.actions.setWorkflowName(name);
workflowPage.getters.saveButton().should('contain', 'Saved');
workflowPage.actions.zoomToFit();
}
export function createCredential(name: string) {
credentialsModal.getters.newCredentialModal().should('be.visible');
credentialsModal.getters.newCredentialTypeSelect().should('be.visible');
credentialsModal.getters.newCredentialTypeOption('Notion API').click();
credentialsModal.getters.newCredentialTypeButton().click();
credentialsModal.getters.connectionParameter('Internal Integration Secret').type('1234567890');
credentialsModal.actions.setName(name);
credentialsModal.actions.save();
credentialsModal.actions.close();
}

View file

@ -401,5 +401,152 @@ describe('Projects', () => {
.first() .first()
.should('contain.text', 'Notion account personal project'); .should('contain.text', 'Notion account personal project');
}); });
it('should move resources between projects', () => {
cy.signin(INSTANCE_OWNER);
cy.visit(workflowsPage.url);
// Create a workflow and a credential in the Home project
workflowsPage.getters.workflowCards().should('not.have.length');
workflowsPage.getters.newWorkflowButtonCard().click();
projects.createWorkflow('Test_workflow_1.json', 'Workflow in Home project');
projects.getHomeButton().click();
projects.getProjectTabCredentials().should('be.visible').click();
credentialsPage.getters.emptyListCreateCredentialButton().click();
projects.createCredential('Credential in Home project');
// Create a project and add a credential and a workflow to it
projects.createProject('Project 1');
projects.getProjectTabCredentials().click();
credentialsPage.getters.emptyListCreateCredentialButton().click();
projects.createCredential('Credential in Project 1');
projects.getProjectTabWorkflows().click();
workflowsPage.getters.newWorkflowButtonCard().click();
projects.createWorkflow('Test_workflow_1.json', 'Workflow in Project 1');
// Create another project and add a credential and a workflow to it
projects.createProject('Project 2');
projects.getProjectTabCredentials().click();
credentialsPage.getters.emptyListCreateCredentialButton().click();
projects.createCredential('Credential in Project 2');
projects.getProjectTabWorkflows().click();
workflowsPage.getters.newWorkflowButtonCard().click();
projects.createWorkflow('Test_workflow_1.json', 'Workflow in Project 2');
// Move the workflow owned by me from Home to Project 1
projects.getHomeButton().click();
workflowsPage.getters
.workflowCards()
.should('have.length', 3)
.filter(':contains("Owned by me")')
.should('exist');
workflowsPage.getters.workflowCardActions('Workflow in Home project').click();
workflowsPage.getters.workflowMoveButton().click();
projects
.getResourceMoveModal()
.should('be.visible')
.find('button:contains("Next")')
.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')
.click();
workflowsPage.getters
.workflowCards()
.should('have.length', 3)
.filter(':contains("Owned by me")')
.should('not.exist');
// Move the credential from Project 1 to Project 2
projects.getMenuItems().first().click();
workflowsPage.getters.workflowCards().should('have.length', 2);
projects.getProjectTabCredentials().click();
credentialsPage.getters.credentialCards().should('have.length', 1);
credentialsPage.getters.credentialCardActions('Credential in Project 1').click();
credentialsPage.getters.credentialMoveButton().click();
projects
.getResourceMoveModal()
.should('be.visible')
.find('button:contains("Next")')
.should('be.disabled');
projects.getProjectMoveSelect().click();
getVisibleSelect()
.find('li')
.should('have.length', 1)
.first()
.should('contain.text', 'Project 2')
.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')
.click();
credentialsPage.getters.credentialCards().should('not.have.length');
projects.getMenuItems().last().click();
projects.getProjectTabCredentials().click();
credentialsPage.getters.credentialCards().should('have.length', 2);
});
}); });
}); });

View file

@ -18,6 +18,8 @@ export class CredentialsPage extends BasePage {
this.getters.credentialCard(credentialName).findChildByTestId('credential-card-actions'), this.getters.credentialCard(credentialName).findChildByTestId('credential-card-actions'),
credentialDeleteButton: () => credentialDeleteButton: () =>
cy.getByTestId('action-toggle-dropdown').filter(':visible').contains('Delete'), cy.getByTestId('action-toggle-dropdown').filter(':visible').contains('Delete'),
credentialMoveButton: () =>
cy.getByTestId('action-toggle-dropdown').filter(':visible').contains('Move'),
sort: () => cy.getByTestId('resources-list-sort').first(), sort: () => cy.getByTestId('resources-list-sort').first(),
sortOption: (label: string) => sortOption: (label: string) =>
cy.getByTestId('resources-list-sort-item').contains(label).first(), cy.getByTestId('resources-list-sort-item').contains(label).first(),

View file

@ -24,6 +24,8 @@ export class WorkflowsPage extends BasePage {
this.getters.workflowCard(workflowName).findChildByTestId('workflow-card-actions'), this.getters.workflowCard(workflowName).findChildByTestId('workflow-card-actions'),
workflowDeleteButton: () => workflowDeleteButton: () =>
cy.getByTestId('action-toggle-dropdown').filter(':visible').contains('Delete'), cy.getByTestId('action-toggle-dropdown').filter(':visible').contains('Delete'),
workflowMoveButton: () =>
cy.getByTestId('action-toggle-dropdown').filter(':visible').contains('Move'),
workflowFilterButton: () => cy.getByTestId('resources-list-filters-trigger').filter(':visible'), workflowFilterButton: () => cy.getByTestId('resources-list-filters-trigger').filter(':visible'),
workflowTagsDropdown: () => cy.getByTestId('tags-dropdown'), workflowTagsDropdown: () => cy.getByTestId('tags-dropdown'),
workflowTagItem: (tag: string) => cy.getByTestId('tag').contains(tag), workflowTagItem: (tag: string) => cy.getByTestId('tag').contains(tag),

View file

@ -14,3 +14,13 @@ export async function setCredentialSharedWith(
data as unknown as IDataObject, data as unknown as IDataObject,
); );
} }
export async function moveCredentialToProject(
context: IRestApiContext,
id: string,
destinationProjectId: string,
): Promise<void> {
return await makeRestApiRequest(context, 'PUT', `/credentials/${id}/transfer`, {
destinationProjectId,
});
}

View file

@ -14,3 +14,13 @@ export async function setWorkflowSharedWith(
data as unknown as IDataObject, data as unknown as IDataObject,
); );
} }
export async function moveWorkflowToProject(
context: IRestApiContext,
id: string,
destinationProjectId: string,
): Promise<void> {
return await makeRestApiRequest(context, 'PUT', `/workflows/${id}/transfer`, {
destinationProjectId,
});
}

View file

@ -1,3 +1,132 @@
<script setup lang="ts">
import { computed } from 'vue';
import dateformat from 'dateformat';
import type { ICredentialsResponse } from '@/Interface';
import { MODAL_CONFIRM, PROJECT_MOVE_RESOURCE_MODAL } from '@/constants';
import { useMessage } from '@/composables/useMessage';
import CredentialIcon from '@/components/CredentialIcon.vue';
import { getCredentialPermissions } from '@/permissions';
import { useUIStore } from '@/stores/ui.store';
import { useCredentialsStore } from '@/stores/credentials.store';
import TimeAgo from '@/components/TimeAgo.vue';
import type { ProjectSharingData } from '@/types/projects.types';
import { useProjectsStore } from '@/stores/projects.store';
import ProjectCardBadge from '@/components/Projects/ProjectCardBadge.vue';
import { useI18n } from '@/composables/useI18n';
const CREDENTIAL_LIST_ITEM_ACTIONS = {
OPEN: 'open',
DELETE: 'delete',
MOVE: 'move',
};
const props = withDefaults(
defineProps<{
data: ICredentialsResponse;
readOnly: boolean;
}>(),
{
data: () => ({
id: '',
createdAt: '',
updatedAt: '',
type: '',
name: '',
sharedWithProjects: [],
homeProject: {} as ProjectSharingData,
}),
readOnly: false,
},
);
const locale = useI18n();
const message = useMessage();
const uiStore = useUIStore();
const credentialsStore = useCredentialsStore();
const projectsStore = useProjectsStore();
const credentialType = computed(() => credentialsStore.getCredentialTypeByName(props.data.type));
const credentialPermissions = computed(() => getCredentialPermissions(props.data));
const actions = computed(() => {
const items = [
{
label: locale.baseText('credentials.item.open'),
value: CREDENTIAL_LIST_ITEM_ACTIONS.OPEN,
},
];
if (credentialPermissions.value.delete) {
items.push({
label: locale.baseText('credentials.item.delete'),
value: CREDENTIAL_LIST_ITEM_ACTIONS.DELETE,
});
}
if (credentialPermissions.value.move) {
items.push({
label: locale.baseText('credentials.item.move'),
value: CREDENTIAL_LIST_ITEM_ACTIONS.MOVE,
});
}
return items;
});
const formattedCreatedAtDate = computed(() => {
const currentYear = new Date().getFullYear().toString();
return dateformat(
props.data.createdAt,
`d mmmm${props.data.createdAt.startsWith(currentYear) ? '' : ', yyyy'}`,
);
});
function onClick() {
uiStore.openExistingCredential(props.data.id);
}
async function onAction(action: string) {
switch (action) {
case CREDENTIAL_LIST_ITEM_ACTIONS.OPEN:
onClick();
break;
case CREDENTIAL_LIST_ITEM_ACTIONS.DELETE:
await deleteResource();
break;
case CREDENTIAL_LIST_ITEM_ACTIONS.MOVE:
moveResource();
break;
}
}
async function deleteResource() {
const deleteConfirmed = await message.confirm(
locale.baseText('credentialEdit.credentialEdit.confirmMessage.deleteCredential.message', {
interpolate: { savedCredentialName: props.data.name },
}),
locale.baseText('credentialEdit.credentialEdit.confirmMessage.deleteCredential.headline'),
{
confirmButtonText: locale.baseText(
'credentialEdit.credentialEdit.confirmMessage.deleteCredential.confirmButtonText',
),
},
);
if (deleteConfirmed === MODAL_CONFIRM) {
await credentialsStore.deleteCredential({ id: props.data.id });
}
}
function moveResource() {
uiStore.openModalWithData({
name: PROJECT_MOVE_RESOURCE_MODAL,
data: {
resource: props.data,
resourceType: locale.baseText('generic.credential').toLocaleLowerCase(),
},
});
}
</script>
<template> <template>
<n8n-card :class="$style.cardLink" @click="onClick"> <n8n-card :class="$style.cardLink" @click="onClick">
<template #prepend> <template #prepend>
@ -20,151 +149,19 @@
</n8n-text> </n8n-text>
</div> </div>
<template #append> <template #append>
<ProjectCardBadge :resource="data" :personal-project="projectsStore.personalProject" /> <div :class="$style.cardActions" @click.stop>
<div ref="cardActions" :class="$style.cardActions"> <ProjectCardBadge :resource="data" :personal-project="projectsStore.personalProject" />
<n8n-action-toggle :actions="actions" theme="dark" @action="onAction" @click.stop /> <n8n-action-toggle
data-test-id="credential-card-actions"
:actions="actions"
theme="dark"
@action="onAction"
/>
</div> </div>
</template> </template>
</n8n-card> </n8n-card>
</template> </template>
<script lang="ts">
import { defineComponent } from 'vue';
import type { PropType } from 'vue';
import type { ICredentialsResponse, IUser } from '@/Interface';
import type { ICredentialType } from 'n8n-workflow';
import { MODAL_CONFIRM } from '@/constants';
import { useMessage } from '@/composables/useMessage';
import CredentialIcon from '@/components/CredentialIcon.vue';
import type { PermissionsMap } from '@/permissions';
import { getCredentialPermissions } from '@/permissions';
import dateformat from 'dateformat';
import { mapStores } from 'pinia';
import { useUIStore } from '@/stores/ui.store';
import { useUsersStore } from '@/stores/users.store';
import { useCredentialsStore } from '@/stores/credentials.store';
import TimeAgo from '@/components/TimeAgo.vue';
import type { ProjectSharingData } from '@/types/projects.types';
import { useProjectsStore } from '@/stores/projects.store';
import type { CredentialScope } from '@n8n/permissions';
import ProjectCardBadge from '@/components/Projects/ProjectCardBadge.vue';
export const CREDENTIAL_LIST_ITEM_ACTIONS = {
OPEN: 'open',
DELETE: 'delete',
};
export default defineComponent({
components: {
TimeAgo,
CredentialIcon,
ProjectCardBadge,
},
props: {
data: {
type: Object as PropType<ICredentialsResponse>,
required: true,
default: (): ICredentialsResponse => ({
id: '',
createdAt: '',
updatedAt: '',
type: '',
name: '',
sharedWithProjects: [],
homeProject: {} as ProjectSharingData,
}),
},
readonly: {
type: Boolean,
default: false,
},
},
setup() {
return {
...useMessage(),
};
},
computed: {
...mapStores(useCredentialsStore, useUIStore, useUsersStore, useProjectsStore),
currentUser(): IUser | null {
return this.usersStore.currentUser;
},
credentialType(): ICredentialType | undefined {
return this.credentialsStore.getCredentialTypeByName(this.data.type);
},
credentialPermissions(): PermissionsMap<CredentialScope> | null {
return !this.currentUser ? null : getCredentialPermissions(this.data);
},
actions(): Array<{ label: string; value: string }> {
if (!this.credentialPermissions) {
return [];
}
return [
{
label: this.$locale.baseText('credentials.item.open'),
value: CREDENTIAL_LIST_ITEM_ACTIONS.OPEN,
},
].concat(
this.credentialPermissions.delete
? [
{
label: this.$locale.baseText('credentials.item.delete'),
value: CREDENTIAL_LIST_ITEM_ACTIONS.DELETE,
},
]
: [],
);
},
formattedCreatedAtDate(): string {
const currentYear = new Date().getFullYear().toString();
return dateformat(
this.data.createdAt,
`d mmmm${this.data.createdAt.startsWith(currentYear) ? '' : ', yyyy'}`,
);
},
},
methods: {
async onClick(event: Event) {
const cardActionsEl = this.$refs.cardActions as HTMLDivElement | undefined;
const clickTarget = event.target as HTMLElement | null;
if (cardActionsEl === clickTarget || (clickTarget && cardActionsEl?.contains(clickTarget))) {
return;
}
this.uiStore.openExistingCredential(this.data.id);
},
async onAction(action: string) {
if (action === CREDENTIAL_LIST_ITEM_ACTIONS.OPEN) {
await this.onClick(new Event('click'));
} else if (action === CREDENTIAL_LIST_ITEM_ACTIONS.DELETE) {
const deleteConfirmed = await this.confirm(
this.$locale.baseText(
'credentialEdit.credentialEdit.confirmMessage.deleteCredential.message',
{
interpolate: { savedCredentialName: this.data.name },
},
),
this.$locale.baseText(
'credentialEdit.credentialEdit.confirmMessage.deleteCredential.headline',
),
{
confirmButtonText: this.$locale.baseText(
'credentialEdit.credentialEdit.confirmMessage.deleteCredential.confirmButtonText',
),
},
);
if (deleteConfirmed === MODAL_CONFIRM) {
await this.credentialsStore.deleteCredential({ id: this.data.id });
}
}
},
},
});
</script>
<style lang="scss" module> <style lang="scss" module>
.cardLink { .cardLink {
transition: box-shadow 0.3s ease; transition: box-shadow 0.3s ease;

View file

@ -1,3 +1,74 @@
<script setup lang="ts">
import {
ABOUT_MODAL_KEY,
CHAT_EMBED_MODAL_KEY,
CHANGE_PASSWORD_MODAL_KEY,
COMMUNITY_PACKAGE_CONFIRM_MODAL_KEY,
COMMUNITY_PACKAGE_INSTALL_MODAL_KEY,
CONTACT_PROMPT_MODAL_KEY,
CREDENTIAL_EDIT_MODAL_KEY,
CREDENTIAL_SELECT_MODAL_KEY,
DELETE_USER_MODAL_KEY,
DUPLICATE_MODAL_KEY,
INVITE_USER_MODAL_KEY,
ONBOARDING_CALL_SIGNUP_MODAL_KEY,
PERSONALIZATION_MODAL_KEY,
TAGS_MANAGER_MODAL_KEY,
NPS_SURVEY_MODAL_KEY,
VERSIONS_MODAL_KEY,
WORKFLOW_ACTIVE_MODAL_KEY,
WORKFLOW_LM_CHAT_MODAL_KEY,
WORKFLOW_SETTINGS_MODAL_KEY,
WORKFLOW_SHARE_MODAL_KEY,
IMPORT_CURL_MODAL_KEY,
LOG_STREAM_MODAL_KEY,
SOURCE_CONTROL_PUSH_MODAL_KEY,
SOURCE_CONTROL_PULL_MODAL_KEY,
EXTERNAL_SECRETS_PROVIDER_MODAL_KEY,
DEBUG_PAYWALL_MODAL_KEY,
MFA_SETUP_MODAL_KEY,
WORKFLOW_HISTORY_VERSION_RESTORE,
SETUP_CREDENTIALS_MODAL_KEY,
GENERATE_CURL_MODAL_KEY,
PROJECT_MOVE_RESOURCE_MODAL,
PROJECT_MOVE_RESOURCE_CONFIRM_MODAL,
} from '@/constants';
import AboutModal from '@/components/AboutModal.vue';
import ChatEmbedModal from '@/components/ChatEmbedModal.vue';
import CommunityPackageManageConfirmModal from '@/components/CommunityPackageManageConfirmModal.vue';
import CommunityPackageInstallModal from '@/components/CommunityPackageInstallModal.vue';
import ChangePasswordModal from '@/components/ChangePasswordModal.vue';
import ContactPromptModal from '@/components/ContactPromptModal.vue';
import CredentialEdit from '@/components/CredentialEdit/CredentialEdit.vue';
import InviteUsersModal from '@/components/InviteUsersModal.vue';
import CredentialsSelectModal from '@/components/CredentialsSelectModal.vue';
import DuplicateWorkflowDialog from '@/components/DuplicateWorkflowDialog.vue';
import ModalRoot from '@/components/ModalRoot.vue';
import OnboardingCallSignupModal from '@/components/OnboardingCallSignupModal.vue';
import PersonalizationModal from '@/components/PersonalizationModal.vue';
import TagsManager from '@/components/TagsManager/TagsManager.vue';
import UpdatesPanel from '@/components/UpdatesPanel.vue';
import NpsSurvey from '@/components/NpsSurvey.vue';
import WorkflowLMChat from '@/components/WorkflowLMChat.vue';
import WorkflowSettings from '@/components/WorkflowSettings.vue';
import DeleteUserModal from '@/components/DeleteUserModal.vue';
import ActivationModal from '@/components/ActivationModal.vue';
import ImportCurlModal from '@/components/ImportCurlModal.vue';
import GenerateCurlModal from '@/components/GenerateCurlModal.vue';
import MfaSetupModal from '@/components/MfaSetupModal.vue';
import WorkflowShareModal from '@/components/WorkflowShareModal.ee.vue';
import EventDestinationSettingsModal from '@/components/SettingsLogStreaming/EventDestinationSettingsModal.ee.vue';
import SourceControlPushModal from '@/components/SourceControlPushModal.ee.vue';
import SourceControlPullModal from '@/components/SourceControlPullModal.ee.vue';
import ExternalSecretsProviderModal from '@/components/ExternalSecretsProviderModal.ee.vue';
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';
</script>
<template> <template>
<div> <div>
<ModalRoot :name="CONTACT_PROMPT_MODAL_KEY"> <ModalRoot :name="CONTACT_PROMPT_MODAL_KEY">
@ -167,142 +238,23 @@
/> />
</template> </template>
</ModalRoot> </ModalRoot>
<ModalRoot :name="PROJECT_MOVE_RESOURCE_MODAL">
<template #default="{ modalName, data }">
<ProjectMoveResourceModal
data-test-id="project-move-resource-modal"
:modal-name="modalName"
:data="data"
/>
</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>
</div> </div>
</template> </template>
<script lang="ts">
import { defineComponent } from 'vue';
import {
ABOUT_MODAL_KEY,
CHAT_EMBED_MODAL_KEY,
CHANGE_PASSWORD_MODAL_KEY,
COMMUNITY_PACKAGE_CONFIRM_MODAL_KEY,
COMMUNITY_PACKAGE_INSTALL_MODAL_KEY,
CONTACT_PROMPT_MODAL_KEY,
CREDENTIAL_EDIT_MODAL_KEY,
CREDENTIAL_SELECT_MODAL_KEY,
DELETE_USER_MODAL_KEY,
DUPLICATE_MODAL_KEY,
INVITE_USER_MODAL_KEY,
ONBOARDING_CALL_SIGNUP_MODAL_KEY,
PERSONALIZATION_MODAL_KEY,
TAGS_MANAGER_MODAL_KEY,
NPS_SURVEY_MODAL_KEY,
VERSIONS_MODAL_KEY,
WORKFLOW_ACTIVE_MODAL_KEY,
WORKFLOW_LM_CHAT_MODAL_KEY,
WORKFLOW_SETTINGS_MODAL_KEY,
WORKFLOW_SHARE_MODAL_KEY,
IMPORT_CURL_MODAL_KEY,
LOG_STREAM_MODAL_KEY,
SOURCE_CONTROL_PUSH_MODAL_KEY,
SOURCE_CONTROL_PULL_MODAL_KEY,
EXTERNAL_SECRETS_PROVIDER_MODAL_KEY,
DEBUG_PAYWALL_MODAL_KEY,
MFA_SETUP_MODAL_KEY,
WORKFLOW_HISTORY_VERSION_RESTORE,
SETUP_CREDENTIALS_MODAL_KEY,
GENERATE_CURL_MODAL_KEY,
} from '@/constants';
import AboutModal from './AboutModal.vue';
import ChatEmbedModal from './ChatEmbedModal.vue';
import CommunityPackageManageConfirmModal from './CommunityPackageManageConfirmModal.vue';
import CommunityPackageInstallModal from './CommunityPackageInstallModal.vue';
import ChangePasswordModal from './ChangePasswordModal.vue';
import ContactPromptModal from './ContactPromptModal.vue';
import CredentialEdit from './CredentialEdit/CredentialEdit.vue';
import InviteUsersModal from './InviteUsersModal.vue';
import CredentialsSelectModal from './CredentialsSelectModal.vue';
import DuplicateWorkflowDialog from './DuplicateWorkflowDialog.vue';
import ModalRoot from './ModalRoot.vue';
import OnboardingCallSignupModal from './OnboardingCallSignupModal.vue';
import PersonalizationModal from './PersonalizationModal.vue';
import TagsManager from './TagsManager/TagsManager.vue';
import UpdatesPanel from './UpdatesPanel.vue';
import NpsSurvey from './NpsSurvey.vue';
import WorkflowLMChat from './WorkflowLMChat.vue';
import WorkflowSettings from './WorkflowSettings.vue';
import DeleteUserModal from './DeleteUserModal.vue';
import ActivationModal from './ActivationModal.vue';
import ImportCurlModal from './ImportCurlModal.vue';
import GenerateCurlModal from './GenerateCurlModal.vue';
import MfaSetupModal from './MfaSetupModal.vue';
import WorkflowShareModal from './WorkflowShareModal.ee.vue';
import EventDestinationSettingsModal from '@/components/SettingsLogStreaming/EventDestinationSettingsModal.ee.vue';
import SourceControlPushModal from '@/components/SourceControlPushModal.ee.vue';
import SourceControlPullModal from '@/components/SourceControlPullModal.ee.vue';
import ExternalSecretsProviderModal from '@/components/ExternalSecretsProviderModal.ee.vue';
import DebugPaywallModal from '@/components/DebugPaywallModal.vue';
import WorkflowHistoryVersionRestoreModal from '@/components/WorkflowHistory/WorkflowHistoryVersionRestoreModal.vue';
import SetupWorkflowCredentialsModal from '@/components/SetupWorkflowCredentialsModal/SetupWorkflowCredentialsModal.vue';
export default defineComponent({
name: 'Modals',
components: {
AboutModal,
ActivationModal,
ChatEmbedModal,
CommunityPackageInstallModal,
CommunityPackageManageConfirmModal,
ContactPromptModal,
ChangePasswordModal,
CredentialEdit,
CredentialsSelectModal,
DeleteUserModal,
DuplicateWorkflowDialog,
InviteUsersModal,
ModalRoot,
OnboardingCallSignupModal,
PersonalizationModal,
TagsManager,
UpdatesPanel,
NpsSurvey,
WorkflowLMChat,
WorkflowSettings,
WorkflowShareModal,
ImportCurlModal,
GenerateCurlModal,
EventDestinationSettingsModal,
SourceControlPushModal,
SourceControlPullModal,
ExternalSecretsProviderModal,
DebugPaywallModal,
MfaSetupModal,
WorkflowHistoryVersionRestoreModal,
SetupWorkflowCredentialsModal,
},
data: () => ({
CHAT_EMBED_MODAL_KEY,
COMMUNITY_PACKAGE_CONFIRM_MODAL_KEY,
COMMUNITY_PACKAGE_INSTALL_MODAL_KEY,
CONTACT_PROMPT_MODAL_KEY,
CREDENTIAL_EDIT_MODAL_KEY,
CREDENTIAL_SELECT_MODAL_KEY,
ABOUT_MODAL_KEY,
CHANGE_PASSWORD_MODAL_KEY,
DELETE_USER_MODAL_KEY,
DUPLICATE_MODAL_KEY,
ONBOARDING_CALL_SIGNUP_MODAL_KEY,
PERSONALIZATION_MODAL_KEY,
INVITE_USER_MODAL_KEY,
TAGS_MANAGER_MODAL_KEY,
VERSIONS_MODAL_KEY,
WORKFLOW_LM_CHAT_MODAL_KEY,
WORKFLOW_SETTINGS_MODAL_KEY,
WORKFLOW_SHARE_MODAL_KEY,
NPS_SURVEY_MODAL_KEY,
WORKFLOW_ACTIVE_MODAL_KEY,
IMPORT_CURL_MODAL_KEY,
GENERATE_CURL_MODAL_KEY,
LOG_STREAM_MODAL_KEY,
SOURCE_CONTROL_PUSH_MODAL_KEY,
SOURCE_CONTROL_PULL_MODAL_KEY,
EXTERNAL_SECRETS_PROVIDER_MODAL_KEY,
DEBUG_PAYWALL_MODAL_KEY,
MFA_SETUP_MODAL_KEY,
WORKFLOW_HISTORY_VERSION_RESTORE,
SETUP_CREDENTIALS_MODAL_KEY,
}),
});
</script>

View file

@ -0,0 +1,102 @@
<script lang="ts" setup>
import { ref, computed } 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';
const props = defineProps<{
modalName: string;
data: {
resource: IWorkflowDb | ICredentialsResponse;
resourceType: 'workflow' | 'credential';
projectId: string;
};
}>();
const i18n = useI18n();
const toast = useToast();
const uiStore = useUIStore();
const projectsStore = useProjectsStore();
const checks = ref([false, false]);
const allChecked = computed(() => checks.value.every(Boolean));
const moveResourceLabel = computed(() =>
props.data.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();
} catch (error) {
toast.showError(
error.message,
i18n.baseText('projects.move.resource.error.title', {
interpolate: {
resourceType: props.data.resourceType,
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 #resourceType>{{ props.data.resourceType }}</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>

View file

@ -0,0 +1,108 @@
<script lang="ts" setup>
import { ref, computed } 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 { splitName } from '@/utils/projects.utils';
const props = defineProps<{
modalName: string;
data: {
resource: IWorkflowDb | ICredentialsResponse;
resourceType: 'workflow' | 'credential';
};
}>();
const i18n = useI18n();
const uiStore = useUIStore();
const projectsStore = useProjectsStore();
const projectId = ref<string | null>(null);
const processedName = computed(() => {
const { firstName, lastName, email } = splitName(props.data.resource.homeProject?.name ?? '');
return !firstName ? email : `${firstName}${lastName ? ' ' + lastName : ''}`;
});
const availableProjects = computed(() => {
return projectsStore.teamProjects.filter((p) => p.id !== props.data.resource.homeProject?.id);
});
const updateProject = (value: string) => {
projectId.value = value;
};
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,
projectId: projectId.value,
},
});
};
</script>
<template>
<Modal width="500px" :name="props.modalName" data-test-id="project-move-resource-modal">
<template #header>
<N8nHeading tag="h2" size="xlarge" class="mb-m">
{{
i18n.baseText('projects.move.resource.modal.title', {
interpolate: { resourceType: props.data.resourceType },
})
}}
</N8nHeading>
<N8nText>
<i18n-t keypath="projects.move.resource.modal.message">
<template #resourceName
><strong>{{ props.data.resource.name }}</strong></template
>
<template #resourceHomeProjectName>{{ processedName }}</template>
<template #resourceType>{{ props.data.resourceType }}</template>
</i18n-t>
</N8nText>
</template>
<template #content>
<div>
<N8nSelect
class="mr-2xs"
:model-value="projectId"
size="small"
data-test-id="project-move-resource-modal-select"
@update:model-value="updateProject"
>
<N8nOption
v-for="p in availableProjects"
:key="p.id"
:value="p.id"
:label="p.name"
></N8nOption>
</N8nSelect>
</div>
</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>
</div>
</template>
</Modal>
</template>
<style lang="scss" module>
.buttons {
display: flex;
justify-content: flex-end;
}
</style>

View file

@ -255,9 +255,9 @@ onBeforeMount(async () => {
</div> </div>
<form @submit.prevent="onSubmit"> <form @submit.prevent="onSubmit">
<fieldset> <fieldset>
<label for="name">{{ locale.baseText('projects.settings.name') }}</label> <label for="projectName">{{ locale.baseText('projects.settings.name') }}</label>
<N8nInput <N8nInput
id="name" id="projectName"
ref="nameInput" ref="nameInput"
v-model="formData.name" v-model="formData.name"
type="text" type="text"

View file

@ -10,20 +10,23 @@ import { useSettingsStore } from '@/stores/settings.store';
import { useUsersStore } from '@/stores/users.store'; import { useUsersStore } from '@/stores/users.store';
import { useWorkflowsStore } from '@/stores/workflows.store'; import { useWorkflowsStore } from '@/stores/workflows.store';
import type { IWorkflowDb } from '@/Interface'; import type { IWorkflowDb } from '@/Interface';
import { useRouter } from 'vue-router';
const $router = { vi.mock('vue-router', () => {
push: vi.fn(), const push = vi.fn();
resolve: vi.fn().mockImplementation(() => ({ href: '' })), const resolve = vi.fn().mockReturnValue({ href: '' });
}; return {
useRouter: () => ({
const renderComponent = createComponentRenderer(WorkflowCard, { push,
global: { resolve,
mocks: { }),
$router, useRoute: () => ({}),
}, RouterLink: vi.fn(),
}, };
}); });
const renderComponent = createComponentRenderer(WorkflowCard);
const createWorkflow = (overrides = {}): IWorkflowDb => ({ const createWorkflow = (overrides = {}): IWorkflowDb => ({
id: '1', id: '1',
name: 'My Workflow', name: 'My Workflow',
@ -43,6 +46,7 @@ describe('WorkflowCard', () => {
let settingsStore: ReturnType<typeof useSettingsStore>; let settingsStore: ReturnType<typeof useSettingsStore>;
let usersStore: ReturnType<typeof useUsersStore>; let usersStore: ReturnType<typeof useUsersStore>;
let workflowsStore: ReturnType<typeof useWorkflowsStore>; let workflowsStore: ReturnType<typeof useWorkflowsStore>;
let router: ReturnType<typeof useRouter>;
beforeEach(async () => { beforeEach(async () => {
pinia = createPinia(); pinia = createPinia();
@ -51,6 +55,7 @@ describe('WorkflowCard', () => {
settingsStore = useSettingsStore(); settingsStore = useSettingsStore();
usersStore = useUsersStore(); usersStore = useUsersStore();
workflowsStore = useWorkflowsStore(); workflowsStore = useWorkflowsStore();
router = useRouter();
windowOpenSpy = vi.spyOn(window, 'open'); windowOpenSpy = vi.spyOn(window, 'open');
}); });
@ -67,7 +72,7 @@ describe('WorkflowCard', () => {
await userEvent.click(cardTitle); await userEvent.click(cardTitle);
await waitFor(() => { await waitFor(() => {
expect($router.push).toHaveBeenCalledWith({ expect(router.push).toHaveBeenCalledWith({
name: VIEWS.WORKFLOW, name: VIEWS.WORKFLOW,
params: { name: data.id }, params: { name: data.id },
}); });
@ -79,7 +84,7 @@ describe('WorkflowCard', () => {
await user.keyboard('[ControlLeft>]'); await user.keyboard('[ControlLeft>]');
await user.click(cardTitle); await user.click(cardTitle);
await waitFor(() => { await waitFor(() => {
expect($router.push).toHaveBeenCalledTimes(1); expect(router.push).toHaveBeenCalledTimes(1);
}); });
expect(windowOpenSpy).toHaveBeenCalled(); expect(windowOpenSpy).toHaveBeenCalled();
}); });
@ -98,7 +103,7 @@ describe('WorkflowCard', () => {
await userEvent.click(cardActions); await userEvent.click(cardActions);
await waitFor(() => { await waitFor(() => {
expect($router.push).not.toHaveBeenCalled(); expect(router.push).not.toHaveBeenCalled();
}); });
const actions = document.querySelector(`#${controllingId}`); const actions = document.querySelector(`#${controllingId}`);
@ -107,7 +112,7 @@ describe('WorkflowCard', () => {
}); });
await userEvent.click(actions!.querySelectorAll('li')[0]); await userEvent.click(actions!.querySelectorAll('li')[0]);
await waitFor(() => { await waitFor(() => {
expect($router.push).toHaveBeenCalledWith({ expect(router.push).toHaveBeenCalledWith({
name: VIEWS.WORKFLOW, name: VIEWS.WORKFLOW,
params: { name: data.id }, params: { name: data.id },
}); });

View file

@ -1,3 +1,233 @@
<script setup lang="ts">
import { computed } from 'vue';
import type { IWorkflowDb, IUser } from '@/Interface';
import {
DUPLICATE_MODAL_KEY,
MODAL_CONFIRM,
PROJECT_MOVE_RESOURCE_MODAL,
VIEWS,
WORKFLOW_SHARE_MODAL_KEY,
} from '@/constants';
import { useMessage } from '@/composables/useMessage';
import { useToast } from '@/composables/useToast';
import { getWorkflowPermissions } from '@/permissions';
import dateformat from 'dateformat';
import WorkflowActivator from '@/components/WorkflowActivator.vue';
import { useUIStore } from '@/stores/ui.store';
import { useSettingsStore } from '@/stores/settings.store';
import { useUsersStore } from '@/stores/users.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import TimeAgo from '@/components/TimeAgo.vue';
import type { ProjectSharingData } from '@/types/projects.types';
import { useProjectsStore } from '@/stores/projects.store';
import ProjectCardBadge from '@/components/Projects/ProjectCardBadge.vue';
import { useI18n } from '@/composables/useI18n';
import { useRouter } from 'vue-router';
import { useTelemetry } from '@/composables/useTelemetry';
const WORKFLOW_LIST_ITEM_ACTIONS = {
OPEN: 'open',
SHARE: 'share',
DUPLICATE: 'duplicate',
DELETE: 'delete',
MOVE: 'move',
};
const props = withDefaults(
defineProps<{
data: IWorkflowDb;
readOnly: boolean;
}>(),
{
data: () => ({
id: '',
createdAt: '',
updatedAt: '',
active: false,
connections: {},
nodes: [],
name: '',
sharedWithProjects: [],
homeProject: {} as ProjectSharingData,
versionId: '',
}),
readOnly: false,
},
);
const emit = defineEmits<{
(event: 'expand:tags'): void;
(event: 'click:tag', tagId: string, e: PointerEvent): void;
}>();
const toast = useToast();
const message = useMessage();
const locale = useI18n();
const router = useRouter();
const telemetry = useTelemetry();
const settingsStore = useSettingsStore();
const uiStore = useUIStore();
const usersStore = useUsersStore();
const workflowsStore = useWorkflowsStore();
const projectsStore = useProjectsStore();
const currentUser = computed(() => usersStore.currentUser ?? ({} as IUser));
const workflowPermissions = computed(() => getWorkflowPermissions(props.data));
const actions = computed(() => {
const items = [
{
label: locale.baseText('workflows.item.open'),
value: WORKFLOW_LIST_ITEM_ACTIONS.OPEN,
},
{
label: locale.baseText('workflows.item.share'),
value: WORKFLOW_LIST_ITEM_ACTIONS.SHARE,
},
];
if (!props.readOnly) {
items.push({
label: locale.baseText('workflows.item.duplicate'),
value: WORKFLOW_LIST_ITEM_ACTIONS.DUPLICATE,
});
}
if (workflowPermissions.value.move) {
items.push({
label: locale.baseText('workflows.item.move'),
value: WORKFLOW_LIST_ITEM_ACTIONS.MOVE,
});
}
if (workflowPermissions.value.delete && !props.readOnly) {
items.push({
label: locale.baseText('workflows.item.delete'),
value: WORKFLOW_LIST_ITEM_ACTIONS.DELETE,
});
}
return items;
});
const formattedCreatedAtDate = computed(() => {
const currentYear = new Date().getFullYear().toString();
return dateformat(
props.data.createdAt,
`d mmmm${String(props.data.createdAt).startsWith(currentYear) ? '' : ', yyyy'}`,
);
});
async function onClick(event?: KeyboardEvent | PointerEvent) {
if (event?.ctrlKey || event?.metaKey) {
const route = router.resolve({
name: VIEWS.WORKFLOW,
params: { name: props.data.id },
});
window.open(route.href, '_blank');
return;
}
await router.push({
name: VIEWS.WORKFLOW,
params: { name: props.data.id },
});
}
function onClickTag(tagId: string, event: PointerEvent) {
event.stopPropagation();
emit('click:tag', tagId, event);
}
function onExpandTags() {
emit('expand:tags');
}
async function onAction(action: string) {
switch (action) {
case WORKFLOW_LIST_ITEM_ACTIONS.OPEN:
await onClick();
break;
case WORKFLOW_LIST_ITEM_ACTIONS.DUPLICATE:
uiStore.openModalWithData({
name: DUPLICATE_MODAL_KEY,
data: {
id: props.data.id,
name: props.data.name,
tags: (props.data.tags ?? []).map((tag) =>
typeof tag !== 'string' && 'id' in tag ? tag.id : tag,
),
},
});
break;
case WORKFLOW_LIST_ITEM_ACTIONS.SHARE:
uiStore.openModalWithData({
name: WORKFLOW_SHARE_MODAL_KEY,
data: { id: props.data.id },
});
telemetry.track('User opened sharing modal', {
workflow_id: props.data.id,
user_id_sharer: currentUser.value.id,
sub_view: 'Workflows listing',
});
break;
case WORKFLOW_LIST_ITEM_ACTIONS.DELETE:
await deleteWorkflow();
break;
case WORKFLOW_LIST_ITEM_ACTIONS.MOVE:
moveResource();
break;
}
}
async function deleteWorkflow() {
const deleteConfirmed = await message.confirm(
locale.baseText('mainSidebar.confirmMessage.workflowDelete.message', {
interpolate: { workflowName: props.data.name },
}),
locale.baseText('mainSidebar.confirmMessage.workflowDelete.headline'),
{
type: 'warning',
confirmButtonText: locale.baseText(
'mainSidebar.confirmMessage.workflowDelete.confirmButtonText',
),
cancelButtonText: locale.baseText(
'mainSidebar.confirmMessage.workflowDelete.cancelButtonText',
),
},
);
if (deleteConfirmed !== MODAL_CONFIRM) {
return;
}
try {
await workflowsStore.deleteWorkflow(props.data.id);
} catch (error) {
toast.showError(error, locale.baseText('generic.deleteWorkflowError'));
return;
}
// Reset tab title since workflow is deleted.
toast.showMessage({
title: locale.baseText('mainSidebar.showMessage.handleSelect1.title'),
type: 'success',
});
}
function moveResource() {
uiStore.openModalWithData({
name: PROJECT_MOVE_RESOURCE_MODAL,
data: {
resource: props.data,
resourceType: locale.baseText('generic.workflow').toLocaleLowerCase(),
},
});
}
</script>
<template> <template>
<n8n-card :class="$style.cardLink" @click="onClick"> <n8n-card :class="$style.cardLink" @click="onClick">
<template #header> <template #header>
@ -50,203 +280,6 @@
</n8n-card> </n8n-card>
</template> </template>
<script lang="ts">
import { defineComponent } from 'vue';
import type { PropType } from 'vue';
import type { IWorkflowDb, IUser } from '@/Interface';
import { DUPLICATE_MODAL_KEY, MODAL_CONFIRM, VIEWS, WORKFLOW_SHARE_MODAL_KEY } from '@/constants';
import { useMessage } from '@/composables/useMessage';
import { useToast } from '@/composables/useToast';
import type { PermissionsMap } from '@/permissions';
import type { WorkflowScope } from '@n8n/permissions';
import { getWorkflowPermissions } from '@/permissions';
import dateformat from 'dateformat';
import WorkflowActivator from '@/components/WorkflowActivator.vue';
import { mapStores } from 'pinia';
import { useUIStore } from '@/stores/ui.store';
import { useSettingsStore } from '@/stores/settings.store';
import { useUsersStore } from '@/stores/users.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import TimeAgo from '@/components/TimeAgo.vue';
import type { ProjectSharingData } from '@/types/projects.types';
import { useProjectsStore } from '@/stores/projects.store';
import ProjectCardBadge from '@/components/Projects/ProjectCardBadge.vue';
export const WORKFLOW_LIST_ITEM_ACTIONS = {
OPEN: 'open',
SHARE: 'share',
DUPLICATE: 'duplicate',
DELETE: 'delete',
};
export default defineComponent({
components: {
TimeAgo,
WorkflowActivator,
ProjectCardBadge,
},
props: {
data: {
type: Object as PropType<IWorkflowDb>,
required: true,
default: (): IWorkflowDb => ({
id: '',
createdAt: '',
updatedAt: '',
active: false,
connections: {},
nodes: [],
name: '',
sharedWithProjects: [],
homeProject: {} as ProjectSharingData,
versionId: '',
}),
},
readOnly: {
type: Boolean,
default: false,
},
},
setup() {
return {
...useToast(),
...useMessage(),
};
},
computed: {
...mapStores(useSettingsStore, useUIStore, useUsersStore, useWorkflowsStore, useProjectsStore),
currentUser(): IUser {
return this.usersStore.currentUser || ({} as IUser);
},
workflowPermissions(): PermissionsMap<WorkflowScope> {
return getWorkflowPermissions(this.data);
},
actions(): Array<{ label: string; value: string }> {
const actions = [
{
label: this.$locale.baseText('workflows.item.open'),
value: WORKFLOW_LIST_ITEM_ACTIONS.OPEN,
},
{
label: this.$locale.baseText('workflows.item.share'),
value: WORKFLOW_LIST_ITEM_ACTIONS.SHARE,
},
];
if (!this.readOnly) {
actions.push({
label: this.$locale.baseText('workflows.item.duplicate'),
value: WORKFLOW_LIST_ITEM_ACTIONS.DUPLICATE,
});
}
if (this.workflowPermissions.delete && !this.readOnly) {
actions.push({
label: this.$locale.baseText('workflows.item.delete'),
value: WORKFLOW_LIST_ITEM_ACTIONS.DELETE,
});
}
return actions;
},
formattedCreatedAtDate(): string {
const currentYear = new Date().getFullYear().toString();
return dateformat(
this.data.createdAt,
`d mmmm${String(this.data.createdAt).startsWith(currentYear) ? '' : ', yyyy'}`,
);
},
},
methods: {
async onClick(event?: KeyboardEvent | PointerEvent) {
if (event?.ctrlKey || event?.metaKey) {
const route = this.$router.resolve({
name: VIEWS.WORKFLOW,
params: { name: this.data.id },
});
window.open(route.href, '_blank');
return;
}
await this.$router.push({
name: VIEWS.WORKFLOW,
params: { name: this.data.id },
});
},
onClickTag(tagId: string, event: PointerEvent) {
event.stopPropagation();
this.$emit('click:tag', tagId, event);
},
onExpandTags() {
this.$emit('expand:tags');
},
async onAction(action: string) {
if (action === WORKFLOW_LIST_ITEM_ACTIONS.OPEN) {
await this.onClick();
} else if (action === WORKFLOW_LIST_ITEM_ACTIONS.DUPLICATE) {
this.uiStore.openModalWithData({
name: DUPLICATE_MODAL_KEY,
data: {
id: this.data.id,
name: this.data.name,
tags: (this.data.tags ?? []).map((tag) =>
typeof tag !== 'string' && 'id' in tag ? tag.id : tag,
),
},
});
} else if (action === WORKFLOW_LIST_ITEM_ACTIONS.SHARE) {
this.uiStore.openModalWithData({
name: WORKFLOW_SHARE_MODAL_KEY,
data: { id: this.data.id },
});
this.$telemetry.track('User opened sharing modal', {
workflow_id: this.data.id,
user_id_sharer: this.currentUser.id,
sub_view: 'Workflows listing',
});
} else if (action === WORKFLOW_LIST_ITEM_ACTIONS.DELETE) {
const deleteConfirmed = await this.confirm(
this.$locale.baseText('mainSidebar.confirmMessage.workflowDelete.message', {
interpolate: { workflowName: this.data.name },
}),
this.$locale.baseText('mainSidebar.confirmMessage.workflowDelete.headline'),
{
type: 'warning',
confirmButtonText: this.$locale.baseText(
'mainSidebar.confirmMessage.workflowDelete.confirmButtonText',
),
cancelButtonText: this.$locale.baseText(
'mainSidebar.confirmMessage.workflowDelete.cancelButtonText',
),
},
);
if (deleteConfirmed !== MODAL_CONFIRM) {
return;
}
try {
await this.workflowsStore.deleteWorkflow(this.data.id);
} catch (error) {
this.showError(error, this.$locale.baseText('generic.deleteWorkflowError'));
return;
}
// Reset tab title since workflow is deleted.
this.showMessage({
title: this.$locale.baseText('mainSidebar.showMessage.handleSelect1.title'),
type: 'success',
});
}
},
},
});
</script>
<style lang="scss" module> <style lang="scss" module>
.cardLink { .cardLink {
transition: box-shadow 0.3s ease; transition: box-shadow 0.3s ease;

View file

@ -159,6 +159,7 @@ import { useRoute } from 'vue-router';
// eslint-disable-next-line unused-imports/no-unused-imports, @typescript-eslint/no-unused-vars // eslint-disable-next-line unused-imports/no-unused-imports, @typescript-eslint/no-unused-vars
import type { BaseTextKey } from '@/plugins/i18n'; import type { BaseTextKey } from '@/plugins/i18n';
import type { Scope } from '@n8n/permissions';
export type IResource = { export type IResource = {
id: string; id: string;
@ -167,6 +168,7 @@ export type IResource = {
updatedAt?: string; updatedAt?: string;
createdAt?: string; createdAt?: string;
homeProject?: ProjectSharingData; homeProject?: ProjectSharingData;
scopes?: Scope[];
}; };
interface IFilters { interface IFilters {

View file

@ -65,6 +65,8 @@ export const DEBUG_PAYWALL_MODAL_KEY = 'debugPaywall';
export const MFA_SETUP_MODAL_KEY = 'mfaSetup'; export const MFA_SETUP_MODAL_KEY = 'mfaSetup';
export const WORKFLOW_HISTORY_VERSION_RESTORE = 'workflowHistoryVersionRestore'; export const WORKFLOW_HISTORY_VERSION_RESTORE = 'workflowHistoryVersionRestore';
export const SETUP_CREDENTIALS_MODAL_KEY = 'setupCredentials'; export const SETUP_CREDENTIALS_MODAL_KEY = 'setupCredentials';
export const PROJECT_MOVE_RESOURCE_MODAL = 'projectMoveResourceModal';
export const PROJECT_MOVE_RESOURCE_CONFIRM_MODAL = 'projectMoveResourceConfirmModal';
export const EXTERNAL_SECRETS_PROVIDER_MODAL_KEY = 'externalSecretsProvider'; export const EXTERNAL_SECRETS_PROVIDER_MODAL_KEY = 'externalSecretsProvider';

View file

@ -78,6 +78,7 @@ describe('permissions', () => {
'credential:delete', 'credential:delete',
'credential:list', 'credential:list',
'credential:share', 'credential:share',
'credential:move',
], ],
} as ICredentialsResponse), } as ICredentialsResponse),
).toEqual({ ).toEqual({
@ -87,6 +88,7 @@ describe('permissions', () => {
delete: true, delete: true,
list: true, list: true,
share: true, share: true,
move: true,
}); });
}); });
@ -101,6 +103,7 @@ describe('permissions', () => {
'workflow:list', 'workflow:list',
'workflow:share', 'workflow:share',
'workflow:execute', 'workflow:execute',
'workflow:move',
], ],
} as IWorkflowDb), } as IWorkflowDb),
).toEqual({ ).toEqual({
@ -111,6 +114,7 @@ describe('permissions', () => {
list: true, list: true,
share: true, share: true,
execute: true, execute: true,
move: true,
}); });
}); });
}); });

View file

@ -13,13 +13,13 @@ export type PermissionsMap<T> = {
[K in ExtractAfterColon<T>]: boolean; [K in ExtractAfterColon<T>]: boolean;
}; };
const mapScopesToPermissions = (scopes: Scope[], scopeSet: Set<Scope>): PermissionsMap<Scope> => const mapScopesToPermissions = <T extends Scope>(scopes: T[], scopeSet: Set<T>) =>
scopes.reduce( scopes.reduce(
(permissions: PermissionsMap<Scope>, scope: Scope) => ({ (permissions, scope) => ({
...permissions, ...permissions,
[scope.split(':')[1]]: scopeSet.has(scope), [scope.split(':')[1]]: scopeSet.has(scope),
}), }),
{} as PermissionsMap<Scope>, {} as PermissionsMap<T>,
); );
export const getCredentialPermissions = ( export const getCredentialPermissions = (
@ -33,6 +33,7 @@ export const getCredentialPermissions = (
'credential:delete', 'credential:delete',
'credential:list', 'credential:list',
'credential:share', 'credential:share',
'credential:move',
], ],
new Set(credential?.scopes ?? []), new Set(credential?.scopes ?? []),
); );
@ -47,6 +48,7 @@ export const getWorkflowPermissions = (workflow: IWorkflowDb): PermissionsMap<Wo
'workflow:list', 'workflow:list',
'workflow:share', 'workflow:share',
'workflow:execute', 'workflow:execute',
'workflow:move',
], ],
new Set(workflow?.scopes ?? []), new Set(workflow?.scopes ?? []),
); );

View file

@ -61,6 +61,7 @@
"generic.unsavedWork.confirmMessage.cancelButtonText": "Leave without saving", "generic.unsavedWork.confirmMessage.cancelButtonText": "Leave without saving",
"generic.upgrade": "Upgrade", "generic.upgrade": "Upgrade",
"generic.upgradeNow": "Upgrade now", "generic.upgradeNow": "Upgrade now",
"generic.credential": "Credential",
"generic.workflow": "Workflow", "generic.workflow": "Workflow",
"generic.workflowSaved": "Workflow changes saved", "generic.workflowSaved": "Workflow changes saved",
"generic.editor": "Editor", "generic.editor": "Editor",
@ -69,6 +70,7 @@
"generic.and": "and", "generic.and": "and",
"generic.ownedByMe": "Owned by me", "generic.ownedByMe": "Owned by me",
"generic.moreInfo": "More info", "generic.moreInfo": "More info",
"generic.next": "Next",
"about.aboutN8n": "About n8n", "about.aboutN8n": "About n8n",
"about.close": "Close", "about.close": "Close",
"about.license": "License", "about.license": "License",
@ -579,6 +581,7 @@
"credentials.empty.button": "Add first credential", "credentials.empty.button": "Add first credential",
"credentials.item.open": "Open", "credentials.item.open": "Open",
"credentials.item.delete": "Delete", "credentials.item.delete": "Delete",
"credentials.item.move": "Move",
"credentials.item.updated": "Last updated", "credentials.item.updated": "Last updated",
"credentials.item.created": "Created", "credentials.item.created": "Created",
"credentials.item.owner": "Owner", "credentials.item.owner": "Owner",
@ -2178,6 +2181,7 @@
"workflows.item.share": "Share...", "workflows.item.share": "Share...",
"workflows.item.duplicate": "Duplicate", "workflows.item.duplicate": "Duplicate",
"workflows.item.delete": "Delete", "workflows.item.delete": "Delete",
"workflows.item.move": "Move",
"workflows.item.updated": "Last updated", "workflows.item.updated": "Last updated",
"workflows.item.created": "Created", "workflows.item.created": "Created",
"workflows.search.placeholder": "Search workflows...", "workflows.search.placeholder": "Search workflows...",
@ -2494,6 +2498,15 @@
"projects.create.limit": "{num} project | {num} projects", "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": "You have reached the {planName} plan limit of {limit}. Upgrade your plan to unlock more projects. {link}",
"projects.create.limitReached.link": "View plans", "projects.create.limitReached.link": "View plans",
"projects.move.resource.modal.title": "Choose a project to move this {resourceType} to",
"projects.move.resource.modal.message": "\"{resourceName}\" is currently in the \"{resourceHomeProjectName}\" project. Which project would you like to move this {resourceType} 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 {resourceType} will be removed. (currently shared with {numberOfUsers})",
"projects.move.resource.confirm.modal.numberOfUsers": "{numberOfUsers} user | {numberOfUsers} users",
"projects.move.resource.error.title": "Error moving {resourceName} {resourceType}",
"mfa.setup.invalidAuthenticatorCode": "{code} is not a valid number", "mfa.setup.invalidAuthenticatorCode": "{code} is not a valid number",
"mfa.setup.invalidCode": "Two-factor code failed. Please try again.", "mfa.setup.invalidCode": "Two-factor code failed. Please try again.",
"mfa.code.modal.title": "Two-factor authentication", "mfa.code.modal.title": "Two-factor authentication",

View file

@ -3,6 +3,8 @@ import { ref, watch, computed } from 'vue';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import { useRootStore } from '@/stores/n8nRoot.store'; import { useRootStore } from '@/stores/n8nRoot.store';
import * as projectsApi from '@/api/projects.api'; import * as projectsApi from '@/api/projects.api';
import * as workflowsEEApi from '@/api/workflows.ee';
import * as credentialsEEApi from '@/api/credentials.ee';
import type { import type {
Project, Project,
ProjectCreateRequest, ProjectCreateRequest,
@ -14,11 +16,15 @@ import { ProjectTypes } from '@/types/projects.types';
import { useSettingsStore } from '@/stores/settings.store'; import { useSettingsStore } from '@/stores/settings.store';
import { hasPermission } from '@/utils/rbac/permissions'; import { hasPermission } from '@/utils/rbac/permissions';
import type { IWorkflowDb } from '@/Interface'; import type { IWorkflowDb } from '@/Interface';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { useCredentialsStore } from '@/stores/credentials.store';
export const useProjectsStore = defineStore('projects', () => { export const useProjectsStore = defineStore('projects', () => {
const route = useRoute(); const route = useRoute();
const rootStore = useRootStore(); const rootStore = useRootStore();
const settingsStore = useSettingsStore(); const settingsStore = useSettingsStore();
const workflowsStore = useWorkflowsStore();
const credentialsStore = useCredentialsStore();
const projects = ref<ProjectListItem[]>([]); const projects = ref<ProjectListItem[]>([]);
const myProjects = ref<ProjectListItem[]>([]); const myProjects = ref<ProjectListItem[]>([]);
@ -135,6 +141,28 @@ export const useProjectsStore = defineStore('projects', () => {
} }
}; };
const moveResourceToProject = async (
resourceType: 'workflow' | 'credential',
resourceId: string,
projectId: string,
) => {
if (resourceType === 'workflow') {
await workflowsEEApi.moveWorkflowToProject(
rootStore.getRestApiContext,
resourceId,
projectId,
);
await workflowsStore.fetchAllWorkflows(currentProjectId.value);
} else {
await credentialsEEApi.moveCredentialToProject(
rootStore.getRestApiContext,
resourceId,
projectId,
);
await credentialsStore.fetchAllCredentials(currentProjectId.value);
}
};
watch( watch(
route, route,
async (newRoute) => { async (newRoute) => {
@ -188,5 +216,6 @@ export const useProjectsStore = defineStore('projects', () => {
deleteProject, deleteProject,
getProjectsCount, getProjectsCount,
setProjectNavActiveIdByWorkflowHomeProject, setProjectNavActiveIdByWorkflowHomeProject,
moveResourceToProject,
}; };
}); });

View file

@ -39,6 +39,8 @@ import {
WORKFLOW_HISTORY_VERSION_RESTORE, WORKFLOW_HISTORY_VERSION_RESTORE,
SETUP_CREDENTIALS_MODAL_KEY, SETUP_CREDENTIALS_MODAL_KEY,
GENERATE_CURL_MODAL_KEY, GENERATE_CURL_MODAL_KEY,
PROJECT_MOVE_RESOURCE_MODAL,
PROJECT_MOVE_RESOURCE_CONFIRM_MODAL,
} from '@/constants'; } from '@/constants';
import type { import type {
CloudUpdateLinkSourceType, CloudUpdateLinkSourceType,
@ -119,6 +121,8 @@ export const useUIStore = defineStore(STORES.UI, {
DEBUG_PAYWALL_MODAL_KEY, DEBUG_PAYWALL_MODAL_KEY,
WORKFLOW_HISTORY_VERSION_RESTORE, WORKFLOW_HISTORY_VERSION_RESTORE,
SETUP_CREDENTIALS_MODAL_KEY, SETUP_CREDENTIALS_MODAL_KEY,
PROJECT_MOVE_RESOURCE_MODAL,
PROJECT_MOVE_RESOURCE_CONFIRM_MODAL,
].map((modalKey) => [modalKey, { open: false }]), ].map((modalKey) => [modalKey, { open: false }]),
), ),
[DELETE_USER_MODAL_KEY]: { [DELETE_USER_MODAL_KEY]: {

View file

@ -114,6 +114,8 @@ export default defineComponent({
value: '', value: '',
updatedAt: credential.updatedAt, updatedAt: credential.updatedAt,
createdAt: credential.createdAt, createdAt: credential.createdAt,
homeProject: credential.homeProject,
scopes: credential.scopes,
})); }));
}, },
allCredentialTypes(): ICredentialType[] { allCredentialTypes(): ICredentialType[] {