feat(editor): Add functionality to create folders (#13473)

This commit is contained in:
Milorad FIlipović
2025-02-28 15:50:50 +01:00
committed by GitHub
parent f381a24145
commit 2cb9d9e29f
21 changed files with 961 additions and 319 deletions

View File

@@ -72,6 +72,8 @@ const save = async (): Promise<void> => {
return;
}
const parentFolderId = router.currentRoute.value.params.folderId as string | undefined;
const currentWorkflowId = props.data.id;
isSaving.value = true;
@@ -102,6 +104,7 @@ const save = async (): Promise<void> => {
resetWebhookUrls: true,
openInNewWindow: true,
resetNodeIds: true,
parentFolderId,
});
if (saved) {

View File

@@ -0,0 +1,89 @@
<script setup lang="ts">
import { useI18n } from '@/composables/useI18n';
import type { FolderPathItem } from '@/Interface';
import { useProjectsStore } from '@/stores/projects.store';
import { ProjectTypes } from '@/types/projects.types';
import type { UserAction } from '@n8n/design-system/types';
import { type PathItem } from '@n8n/design-system/components/N8nBreadcrumbs/Breadcrumbs.vue';
import { computed } from 'vue';
type Props = {
actions: UserAction[];
breadcrumbs: {
visibleItems: FolderPathItem[];
hiddenItems: FolderPathItem[];
};
};
defineProps<Props>();
const emit = defineEmits<{
itemSelected: [item: PathItem];
action: [action: string];
}>();
const i18n = useI18n();
const projectsStore = useProjectsStore();
const currentProject = computed(() => projectsStore.currentProject);
const projectName = computed(() => {
if (currentProject.value?.type === ProjectTypes.Personal) {
return i18n.baseText('projects.menu.personal');
}
return currentProject.value?.name;
});
const onItemSelect = (item: PathItem) => {
emit('itemSelected', item);
};
const onAction = (action: string) => {
emit('action', action);
};
</script>
<template>
<div :class="$style.container">
<n8n-breadcrumbs
v-if="breadcrumbs.visibleItems"
:items="breadcrumbs.visibleItems"
:highlight-last-item="false"
:path-truncated="breadcrumbs.visibleItems[0].parentFolder"
:hidden-items="breadcrumbs.hiddenItems"
data-test-id="folder-card-breadcrumbs"
@item-selected="onItemSelect"
>
<template v-if="currentProject" #prepend>
<div :class="$style['home-project']">
<n8n-link :to="`/projects/${currentProject.id}`">
<N8nText size="large" color="text-base">{{ projectName }}</N8nText>
</n8n-link>
</div>
</template>
</n8n-breadcrumbs>
<n8n-action-toggle
v-if="breadcrumbs.visibleItems"
:actions="actions"
theme="dark"
data-test-id="folder-breadcrumbs-actions"
@action="onAction"
/>
</div>
</template>
<style module lang="scss">
.container {
display: flex;
align-items: center;
}
.home-project {
display: flex;
align-items: center;
&:hover * {
color: var(--color-text-dark);
}
}
</style>

View File

@@ -3,7 +3,7 @@ import userEvent from '@testing-library/user-event';
import FolderCard from './FolderCard.vue';
import { createPinia, setActivePinia } from 'pinia';
import type { FolderResource } from '../layouts/ResourcesListLayout.vue';
import type { UserAction } from '@/Interface';
import type { FolderPathItem, UserAction } from '@/Interface';
vi.mock('vue-router', () => {
const push = vi.fn();
@@ -61,6 +61,11 @@ const PARENT_FOLDER: FolderResource = {
},
} as const satisfies FolderResource;
const DEFAULT_BREADCRUMBS: { visibleItems: FolderPathItem[]; hiddenItems: FolderPathItem[] } = {
visibleItems: [{ id: '1', label: 'Parent 2' }],
hiddenItems: [{ id: '2', label: 'Parent 1', parentFolder: '1' }],
};
const renderComponent = createComponentRenderer(FolderCard, {
props: {
data: DEFAULT_FOLDER,
@@ -68,6 +73,7 @@ const renderComponent = createComponentRenderer(FolderCard, {
{ label: 'Open', value: 'open', disabled: false },
{ label: 'Delete', value: 'delete', disabled: false },
] as const satisfies UserAction[],
breadcrumbs: DEFAULT_BREADCRUMBS,
},
global: {
stubs: {
@@ -145,6 +151,10 @@ describe('FolderCard', () => {
},
parentFolder: PARENT_FOLDER,
},
breadcrumbs: {
visibleItems: [{ id: PARENT_FOLDER.id, label: PARENT_FOLDER.name, parentFolder: '1' }],
hiddenItems: [],
},
},
});
expect(getByTestId('folder-card-icon')).toBeInTheDocument();

View File

@@ -7,11 +7,15 @@ import { useI18n } from '@/composables/useI18n';
import { useRoute, useRouter } from 'vue-router';
import { VIEWS } from '@/constants';
import type { PathItem } from '@n8n/design-system/components/N8nBreadcrumbs/Breadcrumbs.vue';
import type { UserAction } from '@/Interface';
import type { FolderPathItem, UserAction } from '@/Interface';
type Props = {
data: FolderResource;
actions: UserAction[];
breadcrumbs: {
visibleItems: FolderPathItem[];
hiddenItems: FolderPathItem[];
};
};
const props = withDefaults(defineProps<Props>(), {
@@ -27,18 +31,6 @@ const emit = defineEmits<{
folderOpened: [{ folder: FolderResource }];
}>();
const breadCrumbsItems = computed(() => {
if (props.data.parentFolder) {
return [
{
id: props.data.parentFolder.id,
label: props.data.parentFolder.name,
},
];
}
return [];
});
const projectIcon = computed<ProjectIcon>(() => {
const defaultIcon: ProjectIcon = { type: 'icon', value: 'layer-group' };
if (props.data.homeProject?.type === ProjectTypes.Personal) {
@@ -109,7 +101,7 @@ const onBreadcrumbsItemClick = async (item: PathItem) => {
<n8n-text
size="small"
color="text-light"
:class="$style['info-cell']"
:class="[$style['info-cell'], $style['info-cell--workflow-count']]"
data-test-id="folder-card-workflow-count"
>
{{ data.workflowCount }} {{ i18n.baseText('generic.workflows') }}
@@ -117,7 +109,7 @@ const onBreadcrumbsItemClick = async (item: PathItem) => {
<n8n-text
size="small"
color="text-light"
:class="$style['info-cell']"
:class="[$style['info-cell'], $style['info-cell--updated']]"
data-test-id="folder-card-last-updated"
>
{{ i18n.baseText('workerList.item.lastUpdated') }}
@@ -126,7 +118,7 @@ const onBreadcrumbsItemClick = async (item: PathItem) => {
<n8n-text
size="small"
color="text-light"
:class="$style['info-cell']"
:class="[$style['info-cell'], $style['info-cell--created']]"
data-test-id="folder-card-created"
>
{{ i18n.baseText('workflows.item.created') }}
@@ -136,26 +128,29 @@ const onBreadcrumbsItemClick = async (item: PathItem) => {
</template>
<template #append>
<div :class="$style['card-actions']" @click.prevent>
<n8n-breadcrumbs
:items="breadCrumbsItems"
:path-truncated="true"
:show-border="true"
:highlight-last-item="false"
theme="small"
data-test-id="folder-card-breadcrumbs"
@item-selected="onBreadcrumbsItemClick"
>
<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 :class="$style.breadcrumbs">
<n8n-breadcrumbs
:items="breadcrumbs.visibleItems"
:hidden-items="breadcrumbs.hiddenItems"
:path-truncated="breadcrumbs.visibleItems[0]?.parentFolder"
:show-border="true"
:highlight-last-item="false"
theme="small"
data-test-id="folder-card-breadcrumbs"
@item-selected="onBreadcrumbsItemClick"
>
<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>
<n8n-action-toggle
v-if="actions.length"
:actions="actions"
@@ -214,4 +209,23 @@ const onBreadcrumbsItemClick = async (item: PathItem) => {
gap: var(--spacing-3xs);
color: var(--color-text-dark);
}
@include mixins.breakpoint('sm-and-down') {
.card {
flex-wrap: wrap;
:global(.n8n-card-append) {
width: 100%;
margin-top: var(--spacing-3xs);
padding-left: 40px;
}
.card-actions {
width: 100%;
justify-content: space-between;
}
}
.info-cell--created {
display: none;
}
}
</style>

View File

@@ -11,6 +11,7 @@ import { ProjectTypes } from '@/types/projects.types';
import { VIEWS } from '@/constants';
import userEvent from '@testing-library/user-event';
import { waitFor, within } from '@testing-library/vue';
import { useSettingsStore } from '@/stores/settings.store';
const mockPush = vi.fn();
vi.mock('vue-router', async () => {
@@ -43,14 +44,17 @@ const renderComponent = createComponentRenderer(ProjectHeader, {
let route: ReturnType<typeof router.useRoute>;
let projectsStore: ReturnType<typeof mockedStore<typeof useProjectsStore>>;
let settingsStore: ReturnType<typeof mockedStore<typeof useSettingsStore>>;
describe('ProjectHeader', () => {
beforeEach(() => {
createTestingPinia();
route = router.useRoute();
projectsStore = mockedStore(useProjectsStore);
settingsStore = mockedStore(useSettingsStore);
projectsStore.teamProjectsLimit = -1;
settingsStore.settings.folders = { enabled: false };
});
afterEach(() => {

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import type { UserAction } from '@n8n/design-system';
import { N8nButton, N8nTooltip } from '@n8n/design-system';
import { useI18n } from '@/composables/useI18n';
import { type ProjectIcon, ProjectTypes } from '@/types/projects.types';
@@ -10,12 +11,18 @@ import { getResourcePermissions } from '@/permissions';
import { VIEWS } from '@/constants';
import { useSourceControlStore } from '@/stores/sourceControl.store';
import ProjectCreateResource from '@/components/Projects/ProjectCreateResource.vue';
import { useSettingsStore } from '@/stores/settings.store';
const route = useRoute();
const router = useRouter();
const i18n = useI18n();
const projectsStore = useProjectsStore();
const sourceControlStore = useSourceControlStore();
const settingsStore = useSettingsStore();
const emit = defineEmits<{
createFolder: [];
}>();
const headerIcon = computed((): ProjectIcon => {
if (projectsStore.currentProject?.type === ProjectTypes.Personal) {
@@ -49,31 +56,43 @@ const showSettings = computed(
);
const homeProject = computed(() => projectsStore.currentProject ?? projectsStore.personalProject);
const isFoldersFeatureEnabled = computed(() => settingsStore.settings.folders.enabled);
const ACTION_TYPES = {
WORKFLOW: 'workflow',
CREDENTIAL: 'credential',
FOLDER: 'folder',
} as const;
type ActionTypes = (typeof ACTION_TYPES)[keyof typeof ACTION_TYPES];
const createWorkflowButton = computed(() => ({
value: ACTION_TYPES.WORKFLOW,
label: 'Create Workflow',
label: i18n.baseText('projects.header.create.workflow'),
icon: sourceControlStore.preferences.branchReadOnly ? 'lock' : undefined,
size: 'mini' as const,
disabled:
sourceControlStore.preferences.branchReadOnly ||
!getResourcePermissions(homeProject.value?.scopes).workflow.create,
}));
const menu = computed(() => [
{
value: ACTION_TYPES.CREDENTIAL,
label: 'Create credential',
disabled:
sourceControlStore.preferences.branchReadOnly ||
!getResourcePermissions(homeProject.value?.scopes).credential.create,
},
]);
const menu = computed(() => {
const items: UserAction[] = [
{
value: ACTION_TYPES.CREDENTIAL,
label: i18n.baseText('projects.header.create.credential'),
disabled:
sourceControlStore.preferences.branchReadOnly ||
!getResourcePermissions(homeProject.value?.scopes).credential.create,
},
];
if (isFoldersFeatureEnabled.value) {
items.push({
value: ACTION_TYPES.FOLDER,
label: i18n.baseText('projects.header.create.folder'),
disabled: false,
});
}
return items;
});
const actions: Record<ActionTypes, (projectId: string) => void> = {
[ACTION_TYPES.WORKFLOW]: (projectId: string) => {
@@ -81,6 +100,7 @@ const actions: Record<ActionTypes, (projectId: string) => void> = {
name: VIEWS.NEW_WORKFLOW,
query: {
projectId,
parentFolderId: route.params.folderId as string,
},
});
},
@@ -93,6 +113,9 @@ const actions: Record<ActionTypes, (projectId: string) => void> = {
},
});
},
[ACTION_TYPES.FOLDER]: async () => {
emit('createFolder');
},
} as const;
const onSelect = (action: string) => {

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed } from 'vue';
import type { IUser } from '@/Interface';
import type { FolderPathItem, IUser } from '@/Interface';
import {
DUPLICATE_MODAL_KEY,
MODAL_CONFIRM,
@@ -39,6 +39,10 @@ const WORKFLOW_LIST_ITEM_ACTIONS = {
const props = withDefaults(
defineProps<{
data: WorkflowResource;
breadcrumbs: {
visibleItems: FolderPathItem[];
hiddenItems: FolderPathItem[];
};
readOnly?: boolean;
workflowListEventBus?: EventBus;
}>(),
@@ -115,18 +119,6 @@ const formattedCreatedAtDate = computed(() => {
);
});
const breadCrumbsItems = computed(() => {
if (props.data.parentFolder) {
return [
{
id: props.data.parentFolder.id,
label: props.data.parentFolder.name,
},
];
}
return [];
});
const projectIcon = computed<CardProjectIcon>(() => {
const defaultIcon: CardProjectIcon = { type: 'icon', value: 'layer-group' };
if (props.data.homeProject?.type === ProjectTypes.Personal) {
@@ -306,26 +298,28 @@ const emitWorkflowActiveToggle = (value: { id: string; active: boolean }) => {
:resource-type-label="resourceTypeLabel"
:personal-project="projectsStore.personalProject"
/>
<n8n-breadcrumbs
v-else
:items="breadCrumbsItems"
:path-truncated="true"
:show-border="true"
:highlight-last-item="false"
theme="small"
data-test-id="folder-card-breadcrumbs"
>
<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 v-else :class="$style.breadcrumbs">
<n8n-breadcrumbs
:items="breadcrumbs.visibleItems"
:hidden-items="breadcrumbs.hiddenItems"
:path-truncated="breadcrumbs.visibleItems[0]?.parentFolder"
:show-border="true"
:highlight-last-item="false"
theme="small"
data-test-id="folder-card-breadcrumbs"
>
<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>
<WorkflowActivator
class="mr-s"
:workflow-active="data.active"
@@ -405,8 +399,15 @@ const emitWorkflowActiveToggle = (value: { id: string; active: boolean }) => {
padding: 0 var(--spacing-s) var(--spacing-s);
}
.cardBadge {
.cardBadge,
.breadcrumbs {
margin-right: auto;
}
}
@include mixins.breakpoint('xs-only') {
.breadcrumbs > div {
flex-direction: column;
}
}
</style>

View File

@@ -555,22 +555,24 @@ const loadPaginationFromQueryString = async () => {
/>
</n8n-select>
</div>
<ResourceFiltersDropdown
v-if="showFiltersDropdown"
:keys="filterKeys"
:reset="resetFilters"
:model-value="filtersModel"
:shareable="shareable"
:just-icon="true"
@update:model-value="onUpdateFilters"
@update:filters-length="onUpdateFiltersLength"
>
<template #default="resourceFiltersSlotProps">
<slot name="filters" v-bind="resourceFiltersSlotProps" />
</template>
</ResourceFiltersDropdown>
<div :class="$style['sort-and-filter']">
<ResourceFiltersDropdown
v-if="showFiltersDropdown"
:keys="filterKeys"
:reset="resetFilters"
:model-value="filtersModel"
:shareable="shareable"
:just-icon="true"
@update:model-value="onUpdateFilters"
@update:filters-length="onUpdateFiltersLength"
>
<template #default="resourceFiltersSlotProps">
<slot name="filters" v-bind="resourceFiltersSlotProps" />
</template>
</ResourceFiltersDropdown>
<slot name="add-button"></slot>
</div>
</div>
<slot name="add-button"></slot>
</div>
<slot name="callout"></slot>
@@ -680,19 +682,20 @@ const loadPaginationFromQueryString = async () => {
justify-content: end;
width: 100%;
@include mixins.breakpoint('xs-only') {
grid-template-columns: 1fr auto;
grid-auto-flow: row;
.sort-and-filter {
display: flex;
gap: var(--spacing-2xs);
align-items: center;
}
> *:last-child {
grid-column: auto;
}
@include mixins.breakpoint('xs-only') {
grid-auto-flow: row;
grid-auto-columns: unset;
grid-template-columns: 1fr;
}
}
.search {
// max-width: 240px;
@include mixins.breakpoint('sm-and-down') {
max-width: 100%;
}