fix(editor): Addressing internal testing feedback for folders (no-changelog) (#13997)

This commit is contained in:
Milorad FIlipović
2025-03-20 15:48:10 +01:00
committed by GitHub
parent 305ea0fb32
commit 1f56a24bbd
35 changed files with 1277 additions and 145 deletions

View File

@@ -15,6 +15,7 @@ const props = defineProps<{
modalName: string;
data?: {
closeCallback?: () => void;
customHeading?: string;
};
}>();
@@ -89,7 +90,7 @@ const confirm = async () => {
<N8nBadge>{{ i18n.baseText('communityPlusModal.badge') }}</N8nBadge>
</p>
<N8nText tag="h1" align="center" size="xlarge" class="mb-m">{{
i18n.baseText('communityPlusModal.title')
data?.customHeading ?? i18n.baseText('communityPlusModal.title')
}}</N8nText>
<N8nText tag="p">{{ i18n.baseText('communityPlusModal.description') }}</N8nText>
<ul :class="$style.features">
@@ -114,6 +115,13 @@ const confirm = async () => {
{{ i18n.baseText('communityPlusModal.features.third.description') }}
</N8nText>
</li>
<li>
<i> 📁</i>
<N8nText>
<strong>{{ i18n.baseText('communityPlusModal.features.fourth.title') }}</strong>
{{ i18n.baseText('communityPlusModal.features.fourth.description') }}
</N8nText>
</li>
</ul>
<N8nFormInput
id="email"

View File

@@ -6,8 +6,8 @@ import { createEventBus, type EventBus } from '@n8n/utils/event-bus';
import { useI18n } from '@/composables/useI18n';
import { useFoldersStore } from '@/stores/folders.store';
import { useRoute } from 'vue-router';
import type { FolderListItem } from '@/Interface';
import { useProjectsStore } from '@/stores/projects.store';
import { ProjectTypes } from '@/types/projects.types';
const props = defineProps<{
modalName: string;
@@ -32,12 +32,7 @@ const projectsStore = useProjectsStore();
const loading = ref(false);
const operation = ref('');
const deleteConfirmText = ref('');
const selectedFolder = ref<{ id: string; name: string } | null>(null);
const projectFolders = ref<FolderListItem[]>([]);
const currentFolder = computed(() => {
return projectFolders.value.find((folder) => folder.id === props.activeId);
});
const selectedFolder = ref<{ id: string; name: string; type: 'folder' | 'project' } | null>(null);
const folderToDelete = computed(() => {
if (!props.activeId) return null;
@@ -72,6 +67,14 @@ const enabled = computed(() => {
return false;
});
const currentProjectName = computed(() => {
const currentProject = projectsStore.currentProject;
if (currentProject?.type === ProjectTypes.Personal) {
return i18n.baseText('projects.menu.personal');
}
return currentProject?.name;
});
const folderContentWarningMessage = computed(() => {
const folderCount = props.data.content.subFolderCount ?? 0;
const workflowCount = props.data.content.workflowCount ?? 0;
@@ -102,11 +105,10 @@ async function onSubmit() {
try {
loading.value = true;
await foldersStore.deleteFolder(
route.params.projectId as string,
props.activeId,
selectedFolder.value?.id ?? undefined,
);
const newParentId =
selectedFolder.value?.type === 'project' ? '0' : (selectedFolder.value?.id ?? undefined);
await foldersStore.deleteFolder(route.params.projectId as string, props.activeId, newParentId);
let message = '';
if (selectedFolder.value) {
@@ -132,7 +134,7 @@ async function onSubmit() {
}
}
const onFolderSelected = (payload: { id: string; name: string }) => {
const onFolderSelected = (payload: { id: string; name: string; type: 'folder' | 'project' }) => {
selectedFolder.value = payload;
};
</script>
@@ -142,7 +144,7 @@ const onFolderSelected = (payload: { id: string; name: string }) => {
:name="modalName"
:title="title"
:center="true"
width="520"
width="600"
:event-bus="modalBus"
@enter="onSubmit"
>
@@ -163,7 +165,14 @@ const onFolderSelected = (payload: { id: string; name: string }) => {
label="transfer"
@update:model-value="operation = 'transfer'"
>
<n8n-text color="text-dark">{{ i18n.baseText('folders.transfer.action') }}</n8n-text>
<n8n-text v-if="currentProjectName">{{
i18n.baseText('folders.transfer.action', {
interpolate: { projectName: currentProjectName },
})
}}</n8n-text>
<n8n-text v-else color="text-dark">{{
i18n.baseText('folders.transfer.action.noProject')
}}</n8n-text>
</el-radio>
<div v-if="operation === 'transfer'" :class="$style.optionInput">
<n8n-text color="text-dark">{{
@@ -173,8 +182,8 @@ const onFolderSelected = (payload: { id: string; name: string }) => {
v-if="projectsStore.currentProject"
:current-folder-id="props.activeId"
:current-project-id="projectsStore.currentProject?.id"
:parent-folder-id="currentFolder?.parentFolder?.id"
@folder:selected="onFolderSelected"
:parent-folder-id="folderToDelete?.parentFolder"
@location:selected="onFolderSelected"
/>
</div>
<el-radio

View File

@@ -65,6 +65,7 @@ const onAction = (action: string) => {
<n8n-action-toggle
v-if="breadcrumbs.visibleItems"
:actions="actions"
:class="$style['action-toggle']"
theme="dark"
data-test-id="folder-breadcrumbs-actions"
@action="onAction"
@@ -78,6 +79,12 @@ const onAction = (action: string) => {
align-items: center;
}
.action-toggle {
span[role='button'] {
color: var(--color-text-base);
}
}
.home-project {
display: flex;
align-items: center;

View File

@@ -1,9 +1,11 @@
<script setup lang="ts">
import { useI18n } from '@/composables/useI18n';
import type { FolderListItem } from '@/Interface';
import type { ChangeLocationSearchResult } from '@/Interface';
import { useFoldersStore } from '@/stores/folders.store';
import { useProjectsStore } from '@/stores/projects.store';
import { type ProjectIcon as ItemProjectIcon, ProjectTypes } from '@/types/projects.types';
import { N8nSelect } from '@n8n/design-system';
import { ref } from 'vue';
import { computed, ref } from 'vue';
/**
* This component is used to select a folder to move a resource (folder or workflow) to.
@@ -24,21 +26,43 @@ const props = withDefaults(defineProps<Props>(), {
});
const emit = defineEmits<{
'folder:selected': [value: { id: string; name: string }];
'location:selected': [value: { id: string; name: string; type: 'folder' | 'project' }];
}>();
const i18n = useI18n();
const foldersStore = useFoldersStore();
const projectsStore = useProjectsStore();
const moveFolderDropdown = ref<InstanceType<typeof N8nSelect>>();
const selectedFolderId = ref<string | null>(null);
const availableFolders = ref<FolderListItem[]>([]);
const availableLocations = ref<ChangeLocationSearchResult[]>([]);
const loading = ref(false);
const fetchAvailableFolders = async (query?: string) => {
const currentProject = computed(() => {
return projectsStore.currentProject;
});
const projectName = computed(() => {
if (currentProject.value?.type === ProjectTypes.Personal) {
return i18n.baseText('projects.menu.personal');
}
return currentProject.value?.name;
});
const projectIcon = computed<ItemProjectIcon>(() => {
const defaultIcon: ItemProjectIcon = { type: 'icon', value: 'layer-group' };
if (currentProject.value?.type === ProjectTypes.Personal) {
return { type: 'icon', value: 'user' };
} else if (currentProject.value?.type === ProjectTypes.Team) {
return currentProject.value.icon ?? defaultIcon;
}
return defaultIcon;
});
const fetchAvailableLocations = async (query?: string) => {
if (!query) {
availableFolders.value = [];
availableLocations.value = [];
return;
}
loading.value = true;
@@ -48,19 +72,41 @@ const fetchAvailableFolders = async (query?: string) => {
{ name: query ?? undefined },
);
if (!props.parentFolderId) {
availableFolders.value = folders;
availableLocations.value = folders;
} else {
availableFolders.value = folders.filter((folder) => folder.id !== props.parentFolderId);
availableLocations.value = folders.filter((folder) => folder.id !== props.parentFolderId);
}
// Finally add project root if project name contains query (only if folder is not already in root)
if (
projectName.value &&
projectName.value.toLowerCase().includes(query.toLowerCase()) &&
props.parentFolderId !== ''
) {
availableLocations.value.unshift({
id: props.currentProjectId,
name: i18n.baseText('folders.move.project.root.name', {
interpolate: { projectName: projectName.value },
}),
resource: 'project',
createdAt: '',
updatedAt: '',
workflowCount: 0,
subFolderCount: 0,
});
}
loading.value = false;
};
const onFolderSelected = (folderId: string) => {
const selectedFolder = availableFolders.value.find((folder) => folder.id === folderId);
const selectedFolder = availableLocations.value.find((folder) => folder.id === folderId);
if (!selectedFolder) {
return;
}
emit('folder:selected', { id: folderId, name: selectedFolder.name });
emit('location:selected', {
id: folderId,
name: selectedFolder.name,
type: selectedFolder.resource,
});
};
</script>
@@ -71,22 +117,35 @@ const onFolderSelected = (folderId: string) => {
v-model="selectedFolderId"
:filterable="true"
:remote="true"
:remote-method="fetchAvailableFolders"
:remote-method="fetchAvailableLocations"
:loading="loading"
:placeholder="i18n.baseText('folders.move.modal.select.placeholder')"
:no-data-text="i18n.baseText('folders.move.modal.no.data.label')"
option-label="name"
option-value="id"
@update:model-value="onFolderSelected"
>
<template #prefix>
<N8nIcon icon="search" />
</template>
<N8nOption
v-for="folder in availableFolders"
:key="folder.id"
:value="folder.id"
:label="folder.name"
v-for="location in availableLocations"
:key="location.id"
:value="location.id"
:label="location.name"
data-test-id="move-to-folder-option"
>
<div :class="$style['folder-select-item']">
<n8n-icon :class="$style['folder-icon']" icon="folder" />
<span :class="$style['folder-name']"> {{ folder.name }}</span>
<ProjectIcon
v-if="location.resource === 'project' && currentProject"
:class="$style['folder-icon']"
:icon="projectIcon"
:border-less="true"
size="mini"
color="text-dark"
/>
<n8n-icon v-else :class="$style['folder-icon']" icon="folder" />
<span :class="$style['folder-name']"> {{ location.name }}</span>
</div>
</N8nOption>
</N8nSelect>

View File

@@ -48,7 +48,7 @@ const currentFolder = computed(() => {
};
});
const onFolderSelected = (payload: { id: string; name: string }) => {
const onFolderSelected = (payload: { id: string; name: string; type: string }) => {
selectedFolder.value = payload;
};
@@ -81,7 +81,7 @@ const onSubmit = () => {
:current-project-id="projectsStore.currentProject?.id"
:parent-folder-id="props.data.resource.parentFolderId"
:exclude-only-parent="props.data.resourceType === 'workflow'"
@folder:selected="onFolderSelected"
@location:selected="onFolderSelected"
/>
<p
v-if="props.data.resourceType === 'folder'"

View File

@@ -55,6 +55,7 @@ import { useTelemetry } from '@/composables/useTelemetry';
import type { BaseTextKey } from '@/plugins/i18n';
import { useNpsSurveyStore } from '@/stores/npsSurvey.store';
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
import { ProjectTypes } from '@/types/projects.types';
const props = defineProps<{
readOnly?: boolean;
@@ -200,6 +201,26 @@ const workflowTagIds = computed(() => {
return (props.tags ?? []).map((tag) => (typeof tag === 'string' ? tag : tag.id));
});
const currentFolder = computed(() => {
if (props.id === PLACEHOLDER_EMPTY_WORKFLOW_ID) {
return undefined;
}
const workflow = workflowsStore.getWorkflowById(props.id);
if (!workflow) {
return undefined;
}
return workflow.parentFolder;
});
const currentProjectName = computed(() => {
if (projectsStore.currentProject?.type === ProjectTypes.Personal) {
return locale.baseText('projects.menu.personal');
}
return projectsStore.currentProject?.name;
});
watch(
() => props.id,
() => {
@@ -533,16 +554,22 @@ function showCreateWorkflowSuccessToast(id?: string) {
let toastTitle = locale.baseText('workflows.create.personal.toast.title');
let toastText = locale.baseText('workflows.create.personal.toast.text');
if (
projectsStore.currentProject &&
projectsStore.currentProject.id !== projectsStore.personalProject?.id
) {
toastTitle = locale.baseText('workflows.create.project.toast.title', {
interpolate: { projectName: projectsStore.currentProject.name ?? '' },
});
if (projectsStore.currentProject) {
if (currentFolder.value) {
toastTitle = locale.baseText('workflows.create.folder.toast.title', {
interpolate: {
projectName: currentProjectName.value ?? '',
folderName: currentFolder.value.name ?? '',
},
});
} else if (projectsStore.currentProject.id !== projectsStore.personalProject?.id) {
toastTitle = locale.baseText('workflows.create.project.toast.title', {
interpolate: { projectName: currentProjectName.value ?? '' },
});
}
toastText = locale.baseText('workflows.create.project.toast.text', {
interpolate: { projectName: projectsStore.currentProject.name ?? '' },
interpolate: { projectName: currentProjectName.value ?? '' },
});
}

View File

@@ -567,6 +567,7 @@ const closeDialog = () => {
name: COMMUNITY_PLUS_ENROLLMENT_MODAL,
data: {
closeCallback,
customHeading: undefined,
},
});
} else {

View File

@@ -56,6 +56,7 @@ const showSettings = computed(
);
const homeProject = computed(() => projectsStore.currentProject ?? projectsStore.personalProject);
const showFolders = computed(() => {
return settingsStore.isFoldersFeatureEnabled && route.name !== VIEWS.WORKFLOWS;
});
@@ -189,7 +190,7 @@ const onSelect = (action: string) => {
}
.actions {
padding: var(--spacing-2xs) 0 var(--spacing-l);
padding: var(--spacing-2xs) 0 var(--spacing-xs);
}
@include mixins.breakpoint('xs-only') {

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed } from 'vue';
import { computed, ref } from 'vue';
import {
DUPLICATE_MODAL_KEY,
MODAL_CONFIRM,
@@ -26,6 +26,9 @@ import { ResourceType } from '@/utils/projects.utils';
import type { EventBus } from '@n8n/utils/event-bus';
import type { WorkflowResource } from './layouts/ResourcesListLayout.vue';
import type { IUser } from 'n8n-workflow';
import { type ProjectIcon as CardProjectIcon, ProjectTypes } from '@/types/projects.types';
import type { PathItem } from '@n8n/design-system/components/N8nBreadcrumbs/Breadcrumbs.vue';
import { useFoldersStore } from '@/stores/folders.store';
const WORKFLOW_LIST_ITEM_ACTIONS = {
OPEN: 'open',
@@ -68,15 +71,56 @@ const uiStore = useUIStore();
const usersStore = useUsersStore();
const workflowsStore = useWorkflowsStore();
const projectsStore = useProjectsStore();
const foldersStore = useFoldersStore();
const hiddenBreadcrumbsItemsAsync = ref<Promise<PathItem[]>>(new Promise(() => {}));
const resourceTypeLabel = computed(() => locale.baseText('generic.workflow').toLowerCase());
const currentUser = computed(() => usersStore.currentUser ?? ({} as IUser));
const workflowPermissions = computed(() => getResourcePermissions(props.data.scopes).workflow);
const isOverviewPage = computed(() => route.name === VIEWS.WORKFLOWS);
const showFolders = computed(() => {
return settingsStore.isFoldersFeatureEnabled && route.name !== VIEWS.WORKFLOWS;
});
const projectIcon = computed<CardProjectIcon>(() => {
const defaultIcon: CardProjectIcon = { type: 'icon', value: 'layer-group' };
if (props.data.homeProject?.type === ProjectTypes.Personal) {
return { type: 'icon', value: 'user' };
} else if (props.data.homeProject?.type === ProjectTypes.Team) {
return props.data.homeProject.icon ?? defaultIcon;
}
return defaultIcon;
});
const projectName = computed(() => {
if (props.data.homeProject?.type === ProjectTypes.Personal) {
return locale.baseText('projects.menu.personal');
}
return props.data.homeProject?.name;
});
const cardBreadcrumbs = computed<PathItem[]>(() => {
if (props.data.parentFolder) {
return [
{
id: props.data.parentFolder.id,
name: props.data.parentFolder.name,
label: props.data.parentFolder.name,
href: router.resolve({
name: VIEWS.PROJECTS_FOLDERS,
params: {
projectId: props.data.homeProject?.id,
folderId: props.data.parentFolder.id,
},
}).href,
},
];
}
return [];
});
const actions = computed(() => {
const items = [
{
@@ -236,6 +280,17 @@ async function deleteWorkflow() {
emit('workflow:deleted');
}
const fetchHiddenBreadCrumbsItems = async () => {
if (!props.data.homeProject?.id || !projectName.value || !props.data.parentFolder) {
hiddenBreadcrumbsItemsAsync.value = Promise.resolve([]);
} else {
hiddenBreadcrumbsItemsAsync.value = foldersStore.getHiddenBreadcrumbsItems(
{ id: props.data.homeProject.id, name: projectName.value },
props.data.parentFolder.id,
);
}
};
function moveResource() {
uiStore.openModalWithData({
name: PROJECT_MOVE_RESOURCE_MODAL,
@@ -251,6 +306,12 @@ function moveResource() {
const emitWorkflowActiveToggle = (value: { id: string; active: boolean }) => {
emit('workflow:active-toggle', value);
};
const onBreadcrumbItemClick = async (item: PathItem) => {
if (item.href) {
await router.push(item.href);
}
};
</script>
<template>
@@ -289,7 +350,33 @@ const emitWorkflowActiveToggle = (value: { id: string; active: boolean }) => {
</div>
<template #append>
<div :class="$style.cardActions" @click.stop>
<div v-if="isOverviewPage" :class="$style.breadcrumbs">
<n8n-breadcrumbs
:items="cardBreadcrumbs"
:hidden-items="hiddenBreadcrumbsItemsAsync"
:path-truncated="true"
:show-border="true"
:highlight-last-item="false"
hidden-items-trigger="hover"
theme="small"
data-test-id="workflow-card-breadcrumbs"
@tooltip-opened="fetchHiddenBreadCrumbsItems"
@item-selected="onBreadcrumbItemClick"
>
<template v-if="data.homeProject" #prepend>
<div :class="$style['home-project']">
<n8n-link :to="`/projects/${data.homeProject.id}`">
<ProjectIcon :icon="projectIcon" :border-less="true" size="mini" />
<n8n-text size="small" :compact="true" :bold="true" color="text-base">{{
projectName
}}</n8n-text>
</n8n-link>
</div>
</template>
</n8n-breadcrumbs>
</div>
<ProjectCardBadge
v-else
:class="$style.cardBadge"
:resource="data"
:resource-type="ResourceType.Workflow"

View File

@@ -113,6 +113,7 @@ onBeforeMount(async () => {
<n8n-button
icon="filter"
type="tertiary"
size="small"
:active="hasFilters"
:class="{
[$style['filter-button']]: true,
@@ -165,7 +166,8 @@ onBeforeMount(async () => {
<style lang="scss" module>
.filter-button {
height: 40px;
height: 30px;
width: 30px;
align-items: center;
&.no-label {

View File

@@ -543,6 +543,7 @@ const loadPaginationFromQueryString = async () => {
:model-value="filtersModel.search"
:class="$style.search"
:placeholder="i18n.baseText(`${resourceKey}.search.placeholder` as BaseTextKey)"
size="small"
clearable
data-test-id="resources-list-search"
@update:model-value="onSearch"
@@ -552,7 +553,7 @@ const loadPaginationFromQueryString = async () => {
</template>
</n8n-input>
<div :class="$style['sort-and-filter']">
<n8n-select v-model="sortBy" data-test-id="resources-list-sort">
<n8n-select v-model="sortBy" size="small" data-test-id="resources-list-sort">
<n8n-option
v-for="sortOption in sortOptions"
:key="sortOption"
@@ -660,7 +661,12 @@ const loadPaginationFromQueryString = async () => {
</n8n-datatable>
</div>
<n8n-text v-else color="text-base" size="medium" data-test-id="resources-list-empty">
<n8n-text
v-else-if="hasAppliedFilters() || filtersModel.search !== ''"
color="text-base"
size="medium"
data-test-id="resources-list-empty"
>
{{ i18n.baseText(`${resourceKey}.noResults` as BaseTextKey) }}
</n8n-text>
@@ -684,14 +690,14 @@ const loadPaginationFromQueryString = async () => {
display: grid;
grid-auto-flow: column;
grid-auto-columns: 1fr max-content max-content max-content;
gap: var(--spacing-2xs);
gap: var(--spacing-4xs);
align-items: center;
justify-content: end;
width: 100%;
.sort-and-filter {
display: flex;
gap: var(--spacing-2xs);
gap: var(--spacing-4xs);
align-items: center;
}
@@ -707,7 +713,7 @@ const loadPaginationFromQueryString = async () => {
justify-self: end;
input {
height: 42px;
height: 30px;
}
}