mirror of
https://github.com/n8n-io/n8n.git
synced 2025-03-05 20:50:17 -08:00
# Conflicts: # packages/editor-ui/src/components/Projects/ProjectMoveResourceModal.test.ts
359 lines
11 KiB
Vue
359 lines
11 KiB
Vue
<script lang="ts" setup>
|
|
import { ref, computed, onMounted, h } from 'vue';
|
|
import type { ICredentialsResponse, IUsedCredential, 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 { VIEWS } from '@/constants';
|
|
import { ResourceType, splitName } from '@/utils/projects.utils';
|
|
import { useTelemetry } from '@/composables/useTelemetry';
|
|
import { ProjectTypes } from '@/types/projects.types';
|
|
import ProjectMoveSuccessToastMessage from '@/components/Projects/ProjectMoveSuccessToastMessage.vue';
|
|
import { useToast } from '@/composables/useToast';
|
|
import { getResourcePermissions } from '@/permissions';
|
|
import { sortByProperty } from '@/utils/sortUtils';
|
|
import { useWorkflowsStore } from '@/stores/workflows.store';
|
|
import { useCredentialsStore } from '@/stores/credentials.store';
|
|
import type { RouteLocationNamedRaw } from 'vue-router';
|
|
|
|
const props = defineProps<{
|
|
modalName: string;
|
|
data: {
|
|
resource: IWorkflowDb | ICredentialsResponse;
|
|
resourceType: ResourceType;
|
|
resourceTypeLabel: string;
|
|
};
|
|
}>();
|
|
|
|
const i18n = useI18n();
|
|
const uiStore = useUIStore();
|
|
const toast = useToast();
|
|
const projectsStore = useProjectsStore();
|
|
const workflowsStore = useWorkflowsStore();
|
|
const credentialsStore = useCredentialsStore();
|
|
const telemetry = useTelemetry();
|
|
|
|
const filter = ref('');
|
|
const projectId = ref<string | null>(null);
|
|
const shareableCredentials = ref<ICredentialsResponse[]>([]);
|
|
const unShareableCredentials = ref<IUsedCredential[]>([]);
|
|
const shareUsedCredentials = ref(false);
|
|
const processedName = computed(
|
|
() => processProjectName(props.data.resource.homeProject?.name ?? '') ?? '',
|
|
);
|
|
const availableProjects = computed(() =>
|
|
sortByProperty(
|
|
'name',
|
|
projectsStore.availableProjects.filter(
|
|
(p) =>
|
|
p.id !== props.data.resource.homeProject?.id &&
|
|
(!p.scopes || getResourcePermissions(p.scopes)[props.data.resourceType].create),
|
|
),
|
|
),
|
|
);
|
|
const filteredProjects = computed(() =>
|
|
availableProjects.value.filter((p) => p.name?.toLowerCase().includes(filter.value.toLowerCase())),
|
|
);
|
|
const selectedProject = computed(() =>
|
|
availableProjects.value.find((p) => p.id === projectId.value),
|
|
);
|
|
const isResourceInTeamProject = computed(() => isHomeProjectTeam(props.data.resource));
|
|
const isResourceWorkflow = computed(() => props.data.resourceType === ResourceType.Workflow);
|
|
|
|
const isHomeProjectTeam = (resource: IWorkflowDb | ICredentialsResponse) =>
|
|
resource.homeProject?.type === ProjectTypes.Team;
|
|
|
|
const processProjectName = (projectName: string) => {
|
|
const { name, email } = splitName(projectName);
|
|
return name ?? email;
|
|
};
|
|
|
|
const updateProject = (value: string) => {
|
|
projectId.value = value;
|
|
};
|
|
|
|
const closeModal = () => {
|
|
uiStore.closeModal(props.modalName);
|
|
};
|
|
|
|
const setFilter = (query: string) => {
|
|
filter.value = query;
|
|
};
|
|
|
|
const moveResource = async () => {
|
|
if (!selectedProject.value) return;
|
|
try {
|
|
await projectsStore.moveResourceToProject(
|
|
props.data.resourceType,
|
|
props.data.resource.id,
|
|
selectedProject.value.id,
|
|
shareUsedCredentials.value ? shareableCredentials.value.map((c) => c.id) : undefined,
|
|
);
|
|
closeModal();
|
|
telemetry.track(`User successfully moved ${props.data.resourceType}`, {
|
|
[`${props.data.resourceType}_id`]: props.data.resource.id,
|
|
project_from_type: projectsStore.currentProject?.type ?? projectsStore.personalProject?.type,
|
|
});
|
|
toast.showToast({
|
|
title: i18n.baseText('projects.move.resource.success.title', {
|
|
interpolate: {
|
|
resourceTypeLabel: props.data.resourceTypeLabel,
|
|
},
|
|
}),
|
|
message: h(ProjectMoveSuccessToastMessage, {
|
|
routeName: isResourceWorkflow.value ? VIEWS.PROJECTS_WORKFLOWS : VIEWS.PROJECTS_CREDENTIALS,
|
|
resource: props.data.resource,
|
|
resourceType: props.data.resourceType,
|
|
resourceTypeLabel: props.data.resourceTypeLabel,
|
|
targetProject: selectedProject.value,
|
|
}),
|
|
type: 'success',
|
|
duration: 8000,
|
|
});
|
|
} catch (error) {
|
|
toast.showError(
|
|
error.message,
|
|
i18n.baseText('projects.move.resource.error.title', {
|
|
interpolate: {
|
|
resourceTypeLabel: props.data.resourceTypeLabel,
|
|
resourceName: props.data.resource.name,
|
|
},
|
|
}),
|
|
);
|
|
}
|
|
};
|
|
|
|
const getCredentialRouterLocation = (
|
|
credential: ICredentialsResponse | IUsedCredential,
|
|
): RouteLocationNamedRaw => {
|
|
const isSharedWithCurrentProject = credential.sharedWithProjects?.find(
|
|
(p) => p.id === projectsStore.currentProjectId,
|
|
);
|
|
const params: {
|
|
projectId?: string;
|
|
credentialId: string;
|
|
} = { credentialId: credential.id };
|
|
|
|
if (isSharedWithCurrentProject ?? credential.homeProject?.id) {
|
|
params.projectId = isSharedWithCurrentProject
|
|
? projectsStore.currentProjectId
|
|
: credential.homeProject?.id;
|
|
}
|
|
|
|
return {
|
|
name: isSharedWithCurrentProject ? VIEWS.PROJECTS_CREDENTIALS : VIEWS.CREDENTIALS,
|
|
params,
|
|
};
|
|
};
|
|
|
|
onMounted(async () => {
|
|
telemetry.track(`User clicked to move a ${props.data.resourceType}`, {
|
|
[`${props.data.resourceType}_id`]: props.data.resource.id,
|
|
project_from_type: projectsStore.currentProject?.type ?? projectsStore.personalProject?.type,
|
|
});
|
|
|
|
if (isResourceWorkflow.value) {
|
|
const [workflow, credentials] = await Promise.all([
|
|
workflowsStore.fetchWorkflow(props.data.resource.id),
|
|
credentialsStore.fetchAllCredentials(),
|
|
]);
|
|
|
|
shareableCredentials.value = (credentials ?? []).filter(
|
|
(credential) =>
|
|
(workflow?.usedCredentials ?? []).find((c) => c.id === credential.id) &&
|
|
getResourcePermissions(credential.scopes).credential.share,
|
|
);
|
|
unShareableCredentials.value = (workflow?.usedCredentials ?? []).filter(
|
|
(credential) =>
|
|
!getResourcePermissions(credentialsStore.getCredentialById(credential.id)?.scopes)
|
|
.credential.share,
|
|
);
|
|
}
|
|
});
|
|
</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 pr-s">
|
|
{{
|
|
i18n.baseText('projects.move.resource.modal.title', {
|
|
interpolate: { resourceTypeLabel: props.data.resourceTypeLabel },
|
|
})
|
|
}}
|
|
</N8nHeading>
|
|
<N8nText>
|
|
<i18n-t keypath="projects.move.resource.modal.message">
|
|
<template #resourceName
|
|
><strong>{{ props.data.resource.name }}</strong></template
|
|
>
|
|
<template v-if="isResourceInTeamProject" #inTeamProject>
|
|
<i18n-t keypath="projects.move.resource.modal.message.team">
|
|
<template #resourceHomeProjectName
|
|
><strong>{{ processedName }}</strong></template
|
|
>
|
|
</i18n-t>
|
|
</template>
|
|
<template v-else #inPersonalProject>
|
|
<i18n-t keypath="projects.move.resource.modal.message.personal">
|
|
<template #resourceHomeProjectName
|
|
><strong>{{ processedName }}</strong></template
|
|
>
|
|
</i18n-t>
|
|
</template>
|
|
</i18n-t>
|
|
</N8nText>
|
|
</template>
|
|
<template #content>
|
|
<div v-if="availableProjects.length">
|
|
<N8nSelect
|
|
class="mr-2xs mb-xs"
|
|
:model-value="projectId"
|
|
:filterable="true"
|
|
:filter-method="setFilter"
|
|
:placeholder="i18n.baseText('projects.move.resource.modal.selectPlaceholder')"
|
|
data-test-id="project-move-resource-modal-select"
|
|
@update:model-value="updateProject"
|
|
>
|
|
<template #prefix>
|
|
<N8nIcon icon="search" />
|
|
</template>
|
|
<N8nOption
|
|
v-for="p in filteredProjects"
|
|
:key="p.id"
|
|
:value="p.id"
|
|
:label="p.name"
|
|
></N8nOption>
|
|
</N8nSelect>
|
|
<N8nText>
|
|
<i18n-t keypath="projects.move.resource.modal.message.sharingNote">
|
|
<template #note
|
|
><strong>{{
|
|
i18n.baseText('projects.move.resource.modal.message.note')
|
|
}}</strong></template
|
|
>
|
|
<template #resourceTypeLabel>{{ props.data.resourceTypeLabel }}</template>
|
|
</i18n-t>
|
|
<span
|
|
v-if="props.data.resource.sharedWithProjects?.length ?? 0 > 0"
|
|
:class="$style.textBlock"
|
|
>
|
|
{{
|
|
i18n.baseText('projects.move.resource.modal.message.sharingInfo', {
|
|
adjustToNumber: props.data.resource.sharedWithProjects?.length,
|
|
interpolate: {
|
|
numberOfProjects: props.data.resource.sharedWithProjects?.length ?? 0,
|
|
},
|
|
})
|
|
}}</span
|
|
>
|
|
<N8nCheckbox
|
|
v-if="shareableCredentials.length"
|
|
v-model="shareUsedCredentials"
|
|
:class="$style.textBlock"
|
|
data-test-id="project-move-resource-modal-checkbox-all"
|
|
>
|
|
<i18n-t keypath="projects.move.resource.modal.message.usedCredentials">
|
|
<template #usedCredentials>
|
|
<N8nTooltip placement="top">
|
|
<span :class="$style.tooltipText">
|
|
{{
|
|
i18n.baseText('projects.move.resource.modal.message.usedCredentials.number', {
|
|
adjustToNumber: shareableCredentials.length,
|
|
interpolate: { number: shareableCredentials.length },
|
|
})
|
|
}}
|
|
</span>
|
|
<template #content>
|
|
<ul :class="$style.credentialsList">
|
|
<li v-for="credential in shareableCredentials" :key="credential.id">
|
|
<router-link target="_blank" :to="getCredentialRouterLocation(credential)">
|
|
{{ credential.name }}
|
|
</router-link>
|
|
</li>
|
|
</ul>
|
|
</template>
|
|
</N8nTooltip>
|
|
</template>
|
|
</i18n-t>
|
|
</N8nCheckbox>
|
|
<span v-if="unShareableCredentials.length" :class="$style.textBlock">
|
|
<i18n-t keypath="projects.move.resource.modal.message.unAccessibleCredentials.note">
|
|
<template #credentials>
|
|
<N8nTooltip placement="top">
|
|
<span :class="$style.tooltipText">{{
|
|
i18n.baseText('projects.move.resource.modal.message.unAccessibleCredentials')
|
|
}}</span>
|
|
<template #content>
|
|
<ul :class="$style.credentialsList">
|
|
<li v-for="credential in unShareableCredentials" :key="credential.id">
|
|
<router-link target="_blank" :to="getCredentialRouterLocation(credential)">
|
|
{{ credential.name }}
|
|
</router-link>
|
|
</li>
|
|
</ul>
|
|
</template>
|
|
</N8nTooltip>
|
|
</template>
|
|
</i18n-t>
|
|
</span>
|
|
</N8nText>
|
|
</div>
|
|
<N8nText v-else>{{
|
|
i18n.baseText('projects.move.resource.modal.message.noProjects', {
|
|
interpolate: { resourceTypeLabel: props.data.resourceTypeLabel },
|
|
})
|
|
}}</N8nText>
|
|
</template>
|
|
<template #footer>
|
|
<div :class="$style.buttons">
|
|
<N8nButton type="secondary" text class="mr-2xs" @click="closeModal">
|
|
{{ i18n.baseText('generic.cancel') }}
|
|
</N8nButton>
|
|
<N8nButton
|
|
:disabled="!projectId"
|
|
type="primary"
|
|
data-test-id="project-move-resource-modal-button"
|
|
@click="moveResource"
|
|
>
|
|
{{
|
|
i18n.baseText('projects.move.resource.modal.button', {
|
|
interpolate: { resourceTypeLabel: props.data.resourceTypeLabel },
|
|
})
|
|
}}
|
|
</N8nButton>
|
|
</div>
|
|
</template>
|
|
</Modal>
|
|
</template>
|
|
|
|
<style lang="scss" module>
|
|
.buttons {
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
}
|
|
|
|
.textBlock {
|
|
display: block;
|
|
margin-top: var(--spacing-s);
|
|
}
|
|
|
|
.tooltipText {
|
|
text-decoration: underline;
|
|
}
|
|
|
|
.credentialsList {
|
|
list-style-type: none;
|
|
padding: 0;
|
|
margin: 0;
|
|
|
|
li {
|
|
padding: 0 0 var(--spacing-3xs);
|
|
|
|
&:last-child {
|
|
padding-bottom: 0;
|
|
}
|
|
}
|
|
}
|
|
</style>
|