feat(editor): Combine 'Move to Folder' and 'Change owner' modals (#15756)

This new modal also allows transferring entire folders to other projects and users.
This commit is contained in:
Jaakko Husso
2025-05-28 23:41:07 +03:00
committed by GitHub
parent ba70cab9d5
commit e860dd6d2e
27 changed files with 1989 additions and 292 deletions

View File

@@ -1,8 +1,7 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { useI18n } from '@/composables/useI18n';
import { ResourceType } from '@/utils/projects.utils';
import { splitName } from '@/utils/projects.utils';
import { ResourceType, splitName } from '@/utils/projects.utils';
import type { Project, ProjectIcon as BadgeIcon } from '@/types/projects.types';
import { ProjectTypes } from '@/types/projects.types';
import type {
@@ -36,6 +35,10 @@ const props = withDefaults(defineProps<Props>(), {
const i18n = useI18n();
const isShared = computed(() => {
return 'sharedWithProjects' in props.resource && props.resource.sharedWithProjects?.length;
});
const projectState = computed(() => {
if (
(props.resource.homeProject &&
@@ -43,17 +46,17 @@ const projectState = computed(() => {
props.resource.homeProject.id === props.personalProject.id) ||
!props.resource.homeProject
) {
if (props.resource.sharedWithProjects?.length) {
if (isShared.value) {
return ProjectState.SharedOwned;
}
return ProjectState.Owned;
} else if (props.resource.homeProject?.type !== ProjectTypes.Team) {
if (props.resource.sharedWithProjects?.length) {
if (isShared.value) {
return ProjectState.SharedPersonal;
}
return ProjectState.Personal;
} else if (props.resource.homeProject?.type === ProjectTypes.Team) {
if (props.resource.sharedWithProjects?.length) {
if (isShared.value) {
return ProjectState.SharedTeam;
}
return ProjectState.Team;
@@ -61,8 +64,8 @@ const projectState = computed(() => {
return ProjectState.Unknown;
});
const numberOfMembersInHomeTeamProject = computed(
() => props.resource.sharedWithProjects?.length ?? 0,
const numberOfMembersInHomeTeamProject = computed(() =>
'sharedWithProjects' in props.resource ? (props.resource.sharedWithProjects?.length ?? 0) : 0,
);
const badgeText = computed(() => {

View File

@@ -235,6 +235,7 @@ describe('ProjectMoveResourceModal', () => {
'workflow',
movedWorkflow.id,
destinationProject.id,
undefined,
['1', '2'],
);
});

View File

@@ -7,7 +7,12 @@ 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 {
splitName,
getTruncatedProjectName,
ResourceType,
MAX_NAME_LENGTH,
} from '@/utils/projects.utils';
import { useTelemetry } from '@/composables/useTelemetry';
import { ProjectTypes } from '@/types/projects.types';
import ProjectMoveSuccessToastMessage from '@/components/Projects/ProjectMoveSuccessToastMessage.vue';
@@ -88,10 +93,9 @@ const selectedProject = computed(() =>
const isResourceInTeamProject = computed(() => isHomeProjectTeam(props.data.resource));
const isResourceWorkflow = computed(() => props.data.resourceType === ResourceType.Workflow);
const targetProjectName = computed(() => {
const { name, email } = splitName(selectedProject.value?.name ?? '');
return truncate(name ?? email ?? '', 25);
return getTruncatedProjectName(selectedProject.value?.name);
});
const resourceName = computed(() => truncate(props.data.resource.name, 25));
const resourceName = computed(() => truncate(props.data.resource.name, MAX_NAME_LENGTH));
const isHomeProjectTeam = (resource: IWorkflowDb | ICredentialsResponse) =>
resource.homeProject?.type === ProjectTypes.Team;
@@ -120,6 +124,7 @@ const moveResource = async () => {
props.data.resourceType,
props.data.resource.id,
selectedProject.value.id,
undefined,
shareUsedCredentials.value ? shareableCredentials.value.map((c) => c.id) : undefined,
);
closeModal();

View File

@@ -1,7 +1,6 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { truncate } from '@n8n/utils/string/truncate';
import { ResourceType, splitName } from '@/utils/projects.utils';
import { ResourceType, getTruncatedProjectName } from '@/utils/projects.utils';
import type { ProjectListItem } from '@/types/projects.types';
import { ProjectTypes } from '@/types/projects.types';
import { useI18n } from '@/composables/useI18n';
@@ -19,8 +18,7 @@ const i18n = useI18n();
const isWorkflow = computed(() => props.resourceType === ResourceType.Workflow);
const isTargetProjectTeam = computed(() => props.targetProject.type === ProjectTypes.Team);
const targetProjectName = computed(() => {
const { name, email } = splitName(props.targetProject?.name ?? '');
return truncate(name ?? email ?? '', 25);
return getTruncatedProjectName(props.targetProject?.name);
});
</script>
<template>

View File

@@ -2,7 +2,12 @@
import type { AllRolesMap } from '@n8n/permissions';
import { computed, ref, watch } from 'vue';
import { useI18n } from '@/composables/useI18n';
import type { ProjectListItem, ProjectSharingData } from '@/types/projects.types';
import {
ProjectTypes,
type ProjectIcon as ProjectIconItem,
type ProjectListItem,
type ProjectSharingData,
} from '@/types/projects.types';
import ProjectSharingInfo from '@/components/Projects/ProjectSharingInfo.vue';
import { sortByProperty } from '@n8n/utils/sort/sortByProperty';
@@ -46,6 +51,19 @@ const filteredProjects = computed(() =>
),
);
const projectIcon = computed<ProjectIconItem>(() => {
const defaultIcon: ProjectIconItem = { type: 'icon', value: 'layer-group' };
const project = props.projects.find((p) => p.id === selectedProject.value);
if (project?.type === ProjectTypes.Personal) {
return { type: 'icon', value: 'user' };
} else if (project?.type === ProjectTypes.Team) {
return project.icon ?? defaultIcon;
}
return defaultIcon;
});
const setFilter = (query: string) => {
filter.value = query;
};
@@ -109,7 +127,10 @@ watch(
@update:model-value="onProjectSelected"
>
<template #prefix>
<n8n-icon icon="search" />
<N8nIcon v-if="projectIcon.type === 'icon'" :icon="projectIcon.value" color="text-dark" />
<N8nText v-else-if="projectIcon.type === 'emoji'" color="text-light" :class="$style.emoji">
{{ projectIcon.value }}
</N8nText>
</template>
<N8nOption
v-for="project in filteredProjects"
@@ -187,4 +208,8 @@ watch(
.projectRoleSelect {
width: auto;
}
.emoji {
font-size: var(--font-size-s);
}
</style>