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
22 changed files with 960 additions and 495 deletions

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>
<n8n-card :class="$style.cardLink" @click="onClick">
<template #prepend>
@@ -20,151 +149,19 @@
</n8n-text>
</div>
<template #append>
<ProjectCardBadge :resource="data" :personal-project="projectsStore.personalProject" />
<div ref="cardActions" :class="$style.cardActions">
<n8n-action-toggle :actions="actions" theme="dark" @action="onAction" @click.stop />
<div :class="$style.cardActions" @click.stop>
<ProjectCardBadge :resource="data" :personal-project="projectsStore.personalProject" />
<n8n-action-toggle
data-test-id="credential-card-actions"
:actions="actions"
theme="dark"
@action="onAction"
/>
</div>
</template>
</n8n-card>
</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>
.cardLink {
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>
<div>
<ModalRoot :name="CONTACT_PROMPT_MODAL_KEY">
@@ -167,142 +238,23 @@
/>
</template>
</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>
</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>
<form @submit.prevent="onSubmit">
<fieldset>
<label for="name">{{ locale.baseText('projects.settings.name') }}</label>
<label for="projectName">{{ locale.baseText('projects.settings.name') }}</label>
<N8nInput
id="name"
id="projectName"
ref="nameInput"
v-model="formData.name"
type="text"

View File

@@ -10,20 +10,23 @@ import { useSettingsStore } from '@/stores/settings.store';
import { useUsersStore } from '@/stores/users.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import type { IWorkflowDb } from '@/Interface';
import { useRouter } from 'vue-router';
const $router = {
push: vi.fn(),
resolve: vi.fn().mockImplementation(() => ({ href: '' })),
};
const renderComponent = createComponentRenderer(WorkflowCard, {
global: {
mocks: {
$router,
},
},
vi.mock('vue-router', () => {
const push = vi.fn();
const resolve = vi.fn().mockReturnValue({ href: '' });
return {
useRouter: () => ({
push,
resolve,
}),
useRoute: () => ({}),
RouterLink: vi.fn(),
};
});
const renderComponent = createComponentRenderer(WorkflowCard);
const createWorkflow = (overrides = {}): IWorkflowDb => ({
id: '1',
name: 'My Workflow',
@@ -43,6 +46,7 @@ describe('WorkflowCard', () => {
let settingsStore: ReturnType<typeof useSettingsStore>;
let usersStore: ReturnType<typeof useUsersStore>;
let workflowsStore: ReturnType<typeof useWorkflowsStore>;
let router: ReturnType<typeof useRouter>;
beforeEach(async () => {
pinia = createPinia();
@@ -51,6 +55,7 @@ describe('WorkflowCard', () => {
settingsStore = useSettingsStore();
usersStore = useUsersStore();
workflowsStore = useWorkflowsStore();
router = useRouter();
windowOpenSpy = vi.spyOn(window, 'open');
});
@@ -67,7 +72,7 @@ describe('WorkflowCard', () => {
await userEvent.click(cardTitle);
await waitFor(() => {
expect($router.push).toHaveBeenCalledWith({
expect(router.push).toHaveBeenCalledWith({
name: VIEWS.WORKFLOW,
params: { name: data.id },
});
@@ -79,7 +84,7 @@ describe('WorkflowCard', () => {
await user.keyboard('[ControlLeft>]');
await user.click(cardTitle);
await waitFor(() => {
expect($router.push).toHaveBeenCalledTimes(1);
expect(router.push).toHaveBeenCalledTimes(1);
});
expect(windowOpenSpy).toHaveBeenCalled();
});
@@ -98,7 +103,7 @@ describe('WorkflowCard', () => {
await userEvent.click(cardActions);
await waitFor(() => {
expect($router.push).not.toHaveBeenCalled();
expect(router.push).not.toHaveBeenCalled();
});
const actions = document.querySelector(`#${controllingId}`);
@@ -107,7 +112,7 @@ describe('WorkflowCard', () => {
});
await userEvent.click(actions!.querySelectorAll('li')[0]);
await waitFor(() => {
expect($router.push).toHaveBeenCalledWith({
expect(router.push).toHaveBeenCalledWith({
name: VIEWS.WORKFLOW,
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>
<n8n-card :class="$style.cardLink" @click="onClick">
<template #header>
@@ -50,203 +280,6 @@
</n8n-card>
</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>
.cardLink {
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
import type { BaseTextKey } from '@/plugins/i18n';
import type { Scope } from '@n8n/permissions';
export type IResource = {
id: string;
@@ -167,6 +168,7 @@ export type IResource = {
updatedAt?: string;
createdAt?: string;
homeProject?: ProjectSharingData;
scopes?: Scope[];
};
interface IFilters {