fix(editor): Address folders feature feedback (no-changelog) (#13859)

This commit is contained in:
Milorad FIlipović
2025-03-13 17:29:15 +01:00
committed by GitHub
parent b4672b8deb
commit 31493a0cac
17 changed files with 189 additions and 298 deletions

View File

@@ -13,15 +13,11 @@ import {
getFolderCard, getFolderCard,
getFolderCardActionItem, getFolderCardActionItem,
getFolderCardActionToggle, getFolderCardActionToggle,
getFolderCardBreadCrumbsEllipsis,
getFolderCardCurrentBreadcrumb,
getFolderCardHomeProjectBreadcrumb,
getFolderCards, getFolderCards,
getHomeProjectBreadcrumb, getHomeProjectBreadcrumb,
getListBreadcrumbs, getListBreadcrumbs,
getMainBreadcrumbsEllipsis, getMainBreadcrumbsEllipsis,
getMainBreadcrumbsEllipsisMenuItems, getMainBreadcrumbsEllipsisMenuItems,
getOpenHiddenItemsTooltip,
getOverviewMenuItem, getOverviewMenuItem,
getPersonalProjectMenuItem, getPersonalProjectMenuItem,
getVisibleListBreadcrumbs, getVisibleListBreadcrumbs,
@@ -62,7 +58,7 @@ describe('Folders', () => {
createFolderFromListHeaderButton('My Folder 2'); createFolderFromListHeaderButton('My Folder 2');
getFolderCards().should('have.length.greaterThan', 0); getFolderCards().should('have.length.greaterThan', 0);
// Clicking on the success toast should navigate to the folder // Clicking on the success toast should navigate to the folder
successToast().find('a').contains('My Folder 2').click(); successToast().contains('My Folder 2').find('a').contains('Open folder').click();
getCurrentBreadcrumb().should('contain.text', 'My Folder 2'); getCurrentBreadcrumb().should('contain.text', 'My Folder 2');
}); });
@@ -113,29 +109,21 @@ describe('Folders', () => {
createFolderFromProjectHeader('Multi-level Test'); createFolderFromProjectHeader('Multi-level Test');
createFolderInsideFolder('Child Folder', 'Multi-level Test'); createFolderInsideFolder('Child Folder', 'Multi-level Test');
// One level deep: // One level deep:
// - Both main breadcrumbs & card breadcrumbs should only show home project and current folder // - Breadcrumbs should only show home project and current folder
getHomeProjectBreadcrumb().should('exist'); getHomeProjectBreadcrumb().should('exist');
getCurrentBreadcrumb().should('contain.text', 'Multi-level Test'); getCurrentBreadcrumb().should('contain.text', 'Multi-level Test');
getFolderCard('Child Folder').should('exist'); getFolderCard('Child Folder').should('exist');
getFolderCardHomeProjectBreadcrumb('Child Folder').should('exist');
getFolderCardCurrentBreadcrumb('Child Folder').should('contain.text', 'Multi-level Test');
// No hidden items at this level
getFolderCardBreadCrumbsEllipsis('Child Folder').should('not.exist');
createFolderInsideFolder('Child Folder 2', 'Child Folder'); createFolderInsideFolder('Child Folder 2', 'Child Folder');
// Two levels deep: // Two levels deep:
// - Main breadcrumbs should also show parent folder, without hidden ellipsis // - Breadcrumbs should also show parent folder, without hidden ellipsis
// - Card breadcrumbs should show home project, parent folder, with hidden ellipsis
getHomeProjectBreadcrumb().should('exist'); getHomeProjectBreadcrumb().should('exist');
getCurrentBreadcrumb().should('contain.text', 'Child Folder'); getCurrentBreadcrumb().should('contain.text', 'Child Folder');
getVisibleListBreadcrumbs().should('have.length', 1); getVisibleListBreadcrumbs().should('have.length', 1);
getMainBreadcrumbsEllipsis().should('not.exist'); getMainBreadcrumbsEllipsis().should('not.exist');
getFolderCardCurrentBreadcrumb('Child Folder 2').should('contain.text', 'Child Folder');
getFolderCardBreadCrumbsEllipsis('Child Folder 2').should('exist');
// Three levels deep: // Three levels deep:
// - Main breadcrumbs should show parents up to the grandparent folder, with one hidden element // - Breadcrumbs should show parents up to the grandparent folder, with one hidden element
// - Card breadcrumbs should now show two hidden elements
createFolderInsideFolder('Child Folder 3', 'Child Folder 2'); createFolderInsideFolder('Child Folder 3', 'Child Folder 2');
getVisibleListBreadcrumbs().should('have.length', 1); getVisibleListBreadcrumbs().should('have.length', 1);
getMainBreadcrumbsEllipsis().should('exist'); getMainBreadcrumbsEllipsis().should('exist');
@@ -143,12 +131,6 @@ describe('Folders', () => {
getMainBreadcrumbsEllipsis().click(); getMainBreadcrumbsEllipsis().click();
getMainBreadcrumbsEllipsisMenuItems().first().should('contain.text', 'Multi-level Test'); getMainBreadcrumbsEllipsisMenuItems().first().should('contain.text', 'Multi-level Test');
getMainBreadcrumbsEllipsis().click(); getMainBreadcrumbsEllipsis().click();
// Card breadcrumbs should show two hidden elements
getFolderCardBreadCrumbsEllipsis('Child Folder 3').should('exist');
// Clicking on the ellipsis should show hidden element in card breadcrumbs
getFolderCardBreadCrumbsEllipsis('Child Folder 3').click();
getOpenHiddenItemsTooltip().should('be.visible');
getOpenHiddenItemsTooltip().should('contain.text', 'Multi-level Test / Child Folder');
}); });
// Make sure breadcrumbs and folder card show correct info when landing straight on a folder page // Make sure breadcrumbs and folder card show correct info when landing straight on a folder page
@@ -171,13 +153,6 @@ describe('Folders', () => {
getMainBreadcrumbsEllipsisMenuItems().first().should('contain.text', 'Landing Test'); getMainBreadcrumbsEllipsisMenuItems().first().should('contain.text', 'Landing Test');
// Should load child folder card // Should load child folder card
getFolderCard('Child Folder 3').should('exist'); getFolderCard('Child Folder 3').should('exist');
// Card breadcrumbs should show home project and parent, with two hidden elements
getFolderCardHomeProjectBreadcrumb('Child Folder 3').should('exist');
getFolderCardCurrentBreadcrumb('Child Folder 3').should('contain.text', 'Child Folder 2');
getFolderCardBreadCrumbsEllipsis('Child Folder 3').should('exist');
getFolderCardBreadCrumbsEllipsis('Child Folder 3').click();
getOpenHiddenItemsTooltip().should('be.visible');
getOpenHiddenItemsTooltip().should('contain.text', 'Landing Test / Child Folder');
}); });
it('should show folders only in projects', () => { it('should show folders only in projects', () => {
@@ -239,9 +214,7 @@ describe('Folders', () => {
getPersonalProjectMenuItem().find('li').should('have.class', 'is-active'); getPersonalProjectMenuItem().find('li').should('have.class', 'is-active');
}); });
// TODO: Once we have a backend endpoint that returns sub-folder count, enable this it('should warn before deleting non-empty folder from card dropdown', () => {
// eslint-disable-next-line n8n-local-rules/no-skipped-tests
it.skip('should warn before deleting non-empty folder from card dropdown', () => {
goToPersonalProject(); goToPersonalProject();
createFolderFromProjectHeader('I also have family'); createFolderFromProjectHeader('I also have family');
createFolderInsideFolder('Child 1', 'I also have family'); createFolderInsideFolder('Child 1', 'I also have family');

View File

@@ -39,7 +39,7 @@ export class WorkflowsPage extends BasePage {
workflowDeleteButton: () => workflowDeleteButton: () =>
cy.getByTestId('action-toggle-dropdown').filter(':visible').contains('Delete'), cy.getByTestId('action-toggle-dropdown').filter(':visible').contains('Delete'),
workflowMoveButton: () => workflowMoveButton: () =>
cy.getByTestId('action-toggle-dropdown').filter(':visible').contains('Move'), cy.getByTestId('action-toggle-dropdown').filter(':visible').contains('Change owner'),
workflowFilterButton: () => cy.getByTestId('resources-list-filters-trigger').filter(':visible'), workflowFilterButton: () => cy.getByTestId('resources-list-filters-trigger').filter(':visible'),
workflowTagsDropdown: () => cy.getByTestId('tags-dropdown'), workflowTagsDropdown: () => cy.getByTestId('tags-dropdown'),
workflowTagItem: (tag: string) => cy.getByTestId('tag').contains(tag), workflowTagItem: (tag: string) => cy.getByTestId('tag').contains(tag),

View File

@@ -20,6 +20,7 @@ interface ActionToggleProps {
loading?: boolean; loading?: boolean;
loadingRowCount?: number; loadingRowCount?: number;
disabled?: boolean; disabled?: boolean;
popperClass?: string;
} }
defineOptions({ name: 'N8nActionToggle' }); defineOptions({ name: 'N8nActionToggle' });
@@ -33,6 +34,7 @@ withDefaults(defineProps<ActionToggleProps>(), {
loading: false, loading: false,
loadingRowCount: 3, loadingRowCount: 3,
disabled: false, disabled: false,
popperClass: '',
}); });
const actionToggleRef = ref<InstanceType<typeof ElDropdown> | null>(null); const actionToggleRef = ref<InstanceType<typeof ElDropdown> | null>(null);
@@ -62,6 +64,7 @@ defineExpose({
:placement="placement" :placement="placement"
:size="size" :size="size"
:disabled="disabled" :disabled="disabled"
:popper-class="popperClass"
trigger="click" trigger="click"
@command="onCommand" @command="onCommand"
@visible-change="onVisibleChange" @visible-change="onVisibleChange"

View File

@@ -153,6 +153,7 @@ const handleTooltipClose = () => {
:loading-row-count="loadingSkeletonRows" :loading-row-count="loadingSkeletonRows"
:disabled="dropdownDisabled" :disabled="dropdownDisabled"
:class="$style['action-toggle']" :class="$style['action-toggle']"
:popper-class="$style['hidden-items-menu-popper']"
theme="dark" theme="dark"
placement="bottom" placement="bottom"
size="small" size="small"
@@ -199,6 +200,7 @@ const handleTooltipClose = () => {
[$style.item]: true, [$style.item]: true,
[$style.current]: props.highlightLastItem && index === items.length - 1, [$style.current]: props.highlightLastItem && index === items.length - 1,
}" }"
:title="item.label"
:data-test-id=" :data-test-id="
index === items.length - 1 ? 'breadcrumbs-item-current' : 'breadcrumbs-item' index === items.length - 1 ? 'breadcrumbs-item-current' : 'breadcrumbs-item'
" "
@@ -238,6 +240,13 @@ const handleTooltipClose = () => {
align-items: center; align-items: center;
} }
.item * {
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.item.current span { .item.current span {
color: var(--color-text-dark); color: var(--color-text-dark);
} }
@@ -272,6 +281,21 @@ const handleTooltipClose = () => {
color: var(--color-text-base); color: var(--color-text-base);
} }
.hidden-items-menu-popper {
& > div ul {
max-height: 250px;
overflow: auto;
}
li {
max-width: var(--spacing-5xl);
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
.tooltip-loading { .tooltip-loading {
min-width: var(--spacing-3xl); min-width: var(--spacing-3xl);
width: 100%; width: 100%;
@@ -319,6 +343,10 @@ const handleTooltipClose = () => {
gap: var(--spacing-5xs); gap: var(--spacing-5xs);
} }
.item {
max-width: var(--spacing-3xl);
}
.item, .item,
.item * { .item * {
color: var(--color-text-base); color: var(--color-text-base);
@@ -331,7 +359,7 @@ const handleTooltipClose = () => {
} }
.separator { .separator {
font-size: var(--font-size-m); font-size: var(--font-size-s);
color: var(--color-text-base); color: var(--color-text-base);
} }
} }
@@ -345,7 +373,11 @@ const handleTooltipClose = () => {
.item, .item,
.item * { .item * {
color: var(--color-text-base); color: var(--color-text-base);
font-size: var(--font-size-m); font-size: var(--font-size-s);
}
.item {
max-width: var(--spacing-5xl);
} }
.item a:hover * { .item a:hover * {

View File

@@ -6,13 +6,13 @@ exports[`Breadcrumbs > does not highlight last item for "highlightLastItem = fal
<!--v-if--> <!--v-if-->
<!--v-if--> <!--v-if-->
<!--v-if--> <!--v-if-->
<li class="item" data-test-id="breadcrumbs-item"><a href="/folder1" target="_blank" class="n8n-link"><span class="text"><span class="n8n-text size-medium regular">Folder 1</span></span></a></li> <li class="item" title="Folder 1" data-test-id="breadcrumbs-item"><a href="/folder1" target="_blank" class="n8n-link"><span class="text"><span class="n8n-text size-medium regular">Folder 1</span></span></a></li>
<li class="separator">/</li> <li class="separator">/</li>
<li class="item" data-test-id="breadcrumbs-item"><a href="/folder2" target="_blank" class="n8n-link"><span class="text"><span class="n8n-text size-medium regular">Folder 2</span></span></a></li> <li class="item" title="Folder 2" data-test-id="breadcrumbs-item"><a href="/folder2" target="_blank" class="n8n-link"><span class="text"><span class="n8n-text size-medium regular">Folder 2</span></span></a></li>
<li class="separator">/</li> <li class="separator">/</li>
<li class="item" data-test-id="breadcrumbs-item"><a href="/folder3" target="_blank" class="n8n-link"><span class="text"><span class="n8n-text size-medium regular">Folder 3</span></span></a></li> <li class="item" title="Folder 3" data-test-id="breadcrumbs-item"><a href="/folder3" target="_blank" class="n8n-link"><span class="text"><span class="n8n-text size-medium regular">Folder 3</span></span></a></li>
<li class="separator">/</li> <li class="separator">/</li>
<li class="item" data-test-id="breadcrumbs-item-current"><span class="n8n-text size-medium regular">Current</span></li> <li class="item" title="Current" data-test-id="breadcrumbs-item-current"><span class="n8n-text size-medium regular">Current</span></li>
<!--v-if--> <!--v-if-->
</ul> </ul>
</div>" </div>"
@@ -24,13 +24,13 @@ exports[`Breadcrumbs > renders custom separator correctly 1`] = `
<!--v-if--> <!--v-if-->
<!--v-if--> <!--v-if-->
<!--v-if--> <!--v-if-->
<li class="item" data-test-id="breadcrumbs-item"><a href="/folder1" target="_blank" class="n8n-link"><span class="text"><span class="n8n-text size-medium regular">Folder 1</span></span></a></li> <li class="item" title="Folder 1" data-test-id="breadcrumbs-item"><a href="/folder1" target="_blank" class="n8n-link"><span class="text"><span class="n8n-text size-medium regular">Folder 1</span></span></a></li>
<li class="separator">➮</li> <li class="separator">➮</li>
<li class="item" data-test-id="breadcrumbs-item"><a href="/folder2" target="_blank" class="n8n-link"><span class="text"><span class="n8n-text size-medium regular">Folder 2</span></span></a></li> <li class="item" title="Folder 2" data-test-id="breadcrumbs-item"><a href="/folder2" target="_blank" class="n8n-link"><span class="text"><span class="n8n-text size-medium regular">Folder 2</span></span></a></li>
<li class="separator">➮</li> <li class="separator">➮</li>
<li class="item" data-test-id="breadcrumbs-item"><a href="/folder3" target="_blank" class="n8n-link"><span class="text"><span class="n8n-text size-medium regular">Folder 3</span></span></a></li> <li class="item" title="Folder 3" data-test-id="breadcrumbs-item"><a href="/folder3" target="_blank" class="n8n-link"><span class="text"><span class="n8n-text size-medium regular">Folder 3</span></span></a></li>
<li class="separator">➮</li> <li class="separator">➮</li>
<li class="item current" data-test-id="breadcrumbs-item-current"><span class="n8n-text size-medium regular">Current</span></li> <li class="item current" title="Current" data-test-id="breadcrumbs-item-current"><span class="n8n-text size-medium regular">Current</span></li>
<!--v-if--> <!--v-if-->
</ul> </ul>
</div>" </div>"
@@ -42,13 +42,13 @@ exports[`Breadcrumbs > renders default version correctly 1`] = `
<!--v-if--> <!--v-if-->
<!--v-if--> <!--v-if-->
<!--v-if--> <!--v-if-->
<li class="item" data-test-id="breadcrumbs-item"><a href="/folder1" target="_blank" class="n8n-link"><span class="text"><span class="n8n-text size-medium regular">Folder 1</span></span></a></li> <li class="item" title="Folder 1" data-test-id="breadcrumbs-item"><a href="/folder1" target="_blank" class="n8n-link"><span class="text"><span class="n8n-text size-medium regular">Folder 1</span></span></a></li>
<li class="separator">/</li> <li class="separator">/</li>
<li class="item" data-test-id="breadcrumbs-item"><a href="/folder2" target="_blank" class="n8n-link"><span class="text"><span class="n8n-text size-medium regular">Folder 2</span></span></a></li> <li class="item" title="Folder 2" data-test-id="breadcrumbs-item"><a href="/folder2" target="_blank" class="n8n-link"><span class="text"><span class="n8n-text size-medium regular">Folder 2</span></span></a></li>
<li class="separator">/</li> <li class="separator">/</li>
<li class="item" data-test-id="breadcrumbs-item"><a href="/folder3" target="_blank" class="n8n-link"><span class="text"><span class="n8n-text size-medium regular">Folder 3</span></span></a></li> <li class="item" title="Folder 3" data-test-id="breadcrumbs-item"><a href="/folder3" target="_blank" class="n8n-link"><span class="text"><span class="n8n-text size-medium regular">Folder 3</span></span></a></li>
<li class="separator">/</li> <li class="separator">/</li>
<li class="item current" data-test-id="breadcrumbs-item-current"><span class="n8n-text size-medium regular">Current</span></li> <li class="item current" title="Current" data-test-id="breadcrumbs-item-current"><span class="n8n-text size-medium regular">Current</span></li>
<!--v-if--> <!--v-if-->
</ul> </ul>
</div>" </div>"
@@ -61,13 +61,13 @@ exports[`Breadcrumbs > renders slots correctly 1`] = `
<li class="separator">/</li> <li class="separator">/</li>
<!--v-if--> <!--v-if-->
<!--v-if--> <!--v-if-->
<li class="item" data-test-id="breadcrumbs-item"><a href="/folder1" target="_blank" class="n8n-link"><span class="text"><span class="n8n-text size-medium regular">Folder 1</span></span></a></li> <li class="item" title="Folder 1" data-test-id="breadcrumbs-item"><a href="/folder1" target="_blank" class="n8n-link"><span class="text"><span class="n8n-text size-medium regular">Folder 1</span></span></a></li>
<li class="separator">/</li> <li class="separator">/</li>
<li class="item" data-test-id="breadcrumbs-item"><a href="/folder2" target="_blank" class="n8n-link"><span class="text"><span class="n8n-text size-medium regular">Folder 2</span></span></a></li> <li class="item" title="Folder 2" data-test-id="breadcrumbs-item"><a href="/folder2" target="_blank" class="n8n-link"><span class="text"><span class="n8n-text size-medium regular">Folder 2</span></span></a></li>
<li class="separator">/</li> <li class="separator">/</li>
<li class="item" data-test-id="breadcrumbs-item"><a href="/folder3" target="_blank" class="n8n-link"><span class="text"><span class="n8n-text size-medium regular">Folder 3</span></span></a></li> <li class="item" title="Folder 3" data-test-id="breadcrumbs-item"><a href="/folder3" target="_blank" class="n8n-link"><span class="text"><span class="n8n-text size-medium regular">Folder 3</span></span></a></li>
<li class="separator">/</li> <li class="separator">/</li>
<li class="item current" data-test-id="breadcrumbs-item-current"><span class="n8n-text size-medium regular">Current</span></li> <li class="item current" title="Current" data-test-id="breadcrumbs-item-current"><span class="n8n-text size-medium regular">Current</span></li>
<!--v-if--> <!--v-if-->
</ul> </ul>
<div>[POST] Custom content</div> <div>[POST] Custom content</div>
@@ -80,13 +80,13 @@ exports[`Breadcrumbs > renders small version correctly 1`] = `
<!--v-if--> <!--v-if-->
<!--v-if--> <!--v-if-->
<!--v-if--> <!--v-if-->
<li class="item" data-test-id="breadcrumbs-item"><a href="/folder1" target="_blank" class="n8n-link"><span class="text"><span class="n8n-text size-medium regular">Folder 1</span></span></a></li> <li class="item" title="Folder 1" data-test-id="breadcrumbs-item"><a href="/folder1" target="_blank" class="n8n-link"><span class="text"><span class="n8n-text size-medium regular">Folder 1</span></span></a></li>
<li class="separator">/</li> <li class="separator">/</li>
<li class="item" data-test-id="breadcrumbs-item"><a href="/folder2" target="_blank" class="n8n-link"><span class="text"><span class="n8n-text size-medium regular">Folder 2</span></span></a></li> <li class="item" title="Folder 2" data-test-id="breadcrumbs-item"><a href="/folder2" target="_blank" class="n8n-link"><span class="text"><span class="n8n-text size-medium regular">Folder 2</span></span></a></li>
<li class="separator">/</li> <li class="separator">/</li>
<li class="item" data-test-id="breadcrumbs-item"><a href="/folder3" target="_blank" class="n8n-link"><span class="text"><span class="n8n-text size-medium regular">Folder 3</span></span></a></li> <li class="item" title="Folder 3" data-test-id="breadcrumbs-item"><a href="/folder3" target="_blank" class="n8n-link"><span class="text"><span class="n8n-text size-medium regular">Folder 3</span></span></a></li>
<li class="separator">/</li> <li class="separator">/</li>
<li class="item current" data-test-id="breadcrumbs-item-current"><span class="n8n-text size-medium regular">Current</span></li> <li class="item current" title="Current" data-test-id="breadcrumbs-item-current"><span class="n8n-text size-medium regular">Current</span></li>
<!--v-if--> <!--v-if-->
</ul> </ul>
</div>" </div>"

View File

@@ -1,6 +1,6 @@
import type { TextColor } from '@n8n/design-system/types/text'; import type { TextColor } from '@n8n/design-system/types/text';
const ICON_SIZE = ['xsmall', 'small', 'medium', 'large'] as const; const ICON_SIZE = ['xsmall', 'small', 'medium', 'large', 'xlarge'] as const;
export type IconSize = (typeof ICON_SIZE)[number]; export type IconSize = (typeof ICON_SIZE)[number];
export type IconColor = TextColor; export type IconColor = TextColor;

View File

@@ -80,28 +80,6 @@ const enabled = computed(() => {
return false; return false;
}); });
const folderContentWarningMessage = computed(() => {
const folderCount = props.data.content.subFolderCount ?? 0;
const workflowCount = props.data.content.workflowCount ?? 0;
let folderText = '';
let workflowText = '';
if (folderCount > 0) {
folderText = i18n.baseText('folder.count', { interpolate: { count: folderCount } });
}
if (workflowCount > 0) {
workflowText = i18n.baseText('workflow.count', { interpolate: { count: workflowCount } });
}
if (folderCount > 0 && workflowCount > 0) {
folderText += ` ${i18n.baseText('folder.and.workflow.separator')} `;
}
return i18n.baseText('folder.delete.modal.confirmation', {
interpolate: {
folders: folderText,
workflows: workflowText,
},
});
});
async function onSubmit() { async function onSubmit() {
if (!enabled.value) { if (!enabled.value) {
return; return;
@@ -161,7 +139,9 @@ onMounted(async () => {
</div> </div>
<div v-else :class="$style.content"> <div v-else :class="$style.content">
<div> <div>
<n8n-text color="text-base">{{ folderContentWarningMessage }}</n8n-text> <n8n-text color="text-base">{{
i18n.baseText('folder.delete.modal.confirmation')
}}</n8n-text>
</div> </div>
<el-radio <el-radio
v-model="operation" v-model="operation"

View File

@@ -57,7 +57,7 @@ const onAction = (action: string) => {
<template v-if="currentProject" #prepend> <template v-if="currentProject" #prepend>
<div :class="$style['home-project']" data-test-id="home-project"> <div :class="$style['home-project']" data-test-id="home-project">
<n8n-link :to="`/projects/${currentProject.id}`"> <n8n-link :to="`/projects/${currentProject.id}`">
<N8nText size="large" color="text-base">{{ projectName }}</N8nText> <N8nText size="medium" color="text-base">{{ projectName }}</N8nText>
</n8n-link> </n8n-link>
</div> </div>
</template> </template>

View File

@@ -44,25 +44,6 @@ const DEFAULT_FOLDER: FolderResource = {
}, },
} as const satisfies FolderResource; } as const satisfies FolderResource;
const PARENT_FOLDER: FolderResource = {
id: '2',
name: 'Folder 2',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
resourceType: 'folder',
readOnly: false,
workflowCount: 0,
subFolderCount: 0,
homeProject: {
id: '1',
name: 'Project 1',
icon: null,
type: 'team',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
} as const satisfies FolderResource;
const DEFAULT_BREADCRUMBS: { visibleItems: FolderPathItem[]; hiddenItems: FolderPathItem[] } = { const DEFAULT_BREADCRUMBS: { visibleItems: FolderPathItem[]; hiddenItems: FolderPathItem[] } = {
visibleItems: [{ id: '1', label: 'Parent 2' }], visibleItems: [{ id: '1', label: 'Parent 2' }],
hiddenItems: [{ id: '2', label: 'Parent 1', parentFolder: '1' }], hiddenItems: [{ id: '2', label: 'Parent 1', parentFolder: '1' }],
@@ -122,67 +103,6 @@ describe('FolderCard', () => {
expect(queryByTestId('folder-card-folder-count')).not.toBeInTheDocument(); expect(queryByTestId('folder-card-folder-count')).not.toBeInTheDocument();
}); });
it('should render breadcrumbs with personal folder', () => {
const { getByTestId } = renderComponent();
expect(getByTestId('folder-card-icon')).toBeInTheDocument();
expect(getByTestId('folder-card-breadcrumbs')).toHaveTextContent('Personal');
});
it('should render breadcrumbs with team project', () => {
const { getByTestId } = renderComponent({
props: {
data: {
...DEFAULT_FOLDER,
homeProject: {
id: '1',
name: 'Project 1',
icon: null,
type: 'team',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
},
},
});
expect(getByTestId('folder-card-icon')).toBeInTheDocument();
if (!DEFAULT_FOLDER.homeProject?.name) {
throw new Error('homeProject should be defined for this test');
}
expect(getByTestId('folder-card-breadcrumbs')).toHaveTextContent(
DEFAULT_FOLDER.homeProject.name,
);
});
it('should render breadcrumbs with home project and parent folder', () => {
const { getByTestId } = renderComponent({
props: {
data: {
...DEFAULT_FOLDER,
homeProject: {
id: '1',
name: 'Project 1',
icon: null,
type: 'team',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
parentFolder: PARENT_FOLDER,
},
breadcrumbs: {
visibleItems: [{ id: PARENT_FOLDER.id, label: PARENT_FOLDER.name, parentFolder: '1' }],
hiddenItems: [],
},
},
});
expect(getByTestId('folder-card-icon')).toBeInTheDocument();
if (!DEFAULT_FOLDER.homeProject?.name) {
throw new Error('homeProject should be defined for this test');
}
expect(getByTestId('folder-card-breadcrumbs')).toHaveTextContent(
`${DEFAULT_FOLDER.homeProject.name}/.../${PARENT_FOLDER.name}`,
);
});
it('should not render action dropdown if no actions are provided', () => { it('should not render action dropdown if no actions are provided', () => {
const { queryByTestId } = renderComponent({ const { queryByTestId } = renderComponent({
props: { props: {

View File

@@ -6,20 +6,17 @@ import { type ProjectIcon, ProjectTypes } from '@/types/projects.types';
import { useI18n } from '@/composables/useI18n'; import { useI18n } from '@/composables/useI18n';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import { VIEWS } from '@/constants'; 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 = { type Props = {
data: FolderResource; data: FolderResource;
actions: UserAction[]; actions: UserAction[];
breadcrumbs: { readOnly?: boolean;
visibleItems: FolderPathItem[];
hiddenItems: FolderPathItem[];
};
}; };
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
actions: () => [], actions: () => [],
readOnly: true,
}); });
const i18n = useI18n(); const i18n = useI18n();
@@ -71,12 +68,6 @@ const onAction = async (action: string) => {
} }
emit('action', { action, folderId: props.data.id }); emit('action', { action, folderId: props.data.id });
}; };
const onBreadcrumbsItemClick = async (item: PathItem) => {
if (item.href) {
await router.push(item.href);
}
};
</script> </script>
<template> <template>
@@ -88,13 +79,18 @@ const onBreadcrumbsItemClick = async (item: PathItem) => {
data-test-id="folder-card-icon" data-test-id="folder-card-icon"
:class="$style['folder-icon']" :class="$style['folder-icon']"
icon="folder" icon="folder"
size="large" size="xlarge"
/> />
</template> </template>
<template #header> <template #header>
<n8n-heading tag="h2" bold size="small" data-test-id="folder-card-name"> <div :class="$style['card-header']">
{{ data.name }} <n8n-heading tag="h2" bold size="small" data-test-id="folder-card-name">
</n8n-heading> {{ data.name }}
</n8n-heading>
<N8nBadge v-if="readOnly" class="ml-3xs" theme="tertiary" bold>
{{ i18n.baseText('workflows.item.readonly') }}
</N8nBadge>
</div>
</template> </template>
<template #footer> <template #footer>
<div :class="$style['card-footer']"> <div :class="$style['card-footer']">
@@ -140,28 +136,15 @@ const onBreadcrumbsItemClick = async (item: PathItem) => {
</template> </template>
<template #append> <template #append>
<div :class="$style['card-actions']" @click.prevent> <div :class="$style['card-actions']" @click.prevent>
<div :class="$style.breadcrumbs"> <div v-if="data.homeProject" :class="$style['project-pill']">
<n8n-breadcrumbs <div :class="$style['home-project']" data-test-id="folder-card-home-project">
:items="breadcrumbs.visibleItems" <n8n-link :to="`/projects/${data.homeProject.id}`">
:hidden-items="breadcrumbs.hiddenItems" <ProjectIcon :icon="projectIcon" :border-less="true" size="mini" />
:path-truncated="breadcrumbs.visibleItems[0]?.parentFolder" <n8n-text size="small" :compact="true" :bold="true" color="text-base">
:show-border="true" {{ projectName }}
:highlight-last-item="false" </n8n-text>
theme="small" </n8n-link>
data-test-id="folder-card-breadcrumbs" </div>
@item-selected="onBreadcrumbsItemClick"
>
<template v-if="data.homeProject" #prepend>
<div :class="$style['home-project']" data-test-id="folder-card-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> </div>
<n8n-action-toggle <n8n-action-toggle
v-if="actions.length" v-if="actions.length"
@@ -186,17 +169,24 @@ const onBreadcrumbsItemClick = async (item: PathItem) => {
box-shadow: 0 2px 8px rgba(#441c17, 0.1); box-shadow: 0 2px 8px rgba(#441c17, 0.1);
} }
} }
.folder-icon { .folder-icon {
width: var(--spacing-xl); width: var(--spacing-xl);
height: var(--spacing-xl); height: var(--spacing-xl);
flex-shrink: 0; flex-shrink: 0;
background-color: var(--color-background-dark); color: var(--color-text-base);
color: var(--color-background-light-base);
border-radius: 50%;
align-content: center; align-content: center;
text-align: center; text-align: center;
} }
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
padding-right: var(--spacing-xs);
margin-bottom: var(--spacing-5xs);
}
.card-footer { .card-footer {
display: flex; display: flex;
} }
@@ -215,6 +205,14 @@ const onBreadcrumbsItemClick = async (item: PathItem) => {
gap: var(--spacing-xs); gap: var(--spacing-xs);
} }
.project-pill {
display: flex;
align-items: center;
padding: var(--spacing-4xs) var(--spacing-2xs);
border: var(--border-base);
border-radius: var(--border-radius-base);
}
.home-project span { .home-project span {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@@ -57,7 +57,7 @@ const showSettings = computed(
const homeProject = computed(() => projectsStore.currentProject ?? projectsStore.personalProject); const homeProject = computed(() => projectsStore.currentProject ?? projectsStore.personalProject);
const isFoldersFeatureEnabled = computed(() => settingsStore.settings.folders.enabled); const isFoldersFeatureEnabled = computed(() => settingsStore.settings.folders.enabled);
const isOverviewPage = computed(() => route.name === VIEWS.WORKFLOWS); const isProjectPage = computed(() => route.name === VIEWS.PROJECTS_WORKFLOWS);
const ACTION_TYPES = { const ACTION_TYPES = {
WORKFLOW: 'workflow', WORKFLOW: 'workflow',
@@ -85,11 +85,13 @@ const menu = computed(() => {
!getResourcePermissions(homeProject.value?.scopes).credential.create, !getResourcePermissions(homeProject.value?.scopes).credential.create,
}, },
]; ];
if (isFoldersFeatureEnabled.value && !isOverviewPage.value) { if (isFoldersFeatureEnabled.value && isProjectPage.value) {
items.push({ items.push({
value: ACTION_TYPES.FOLDER, value: ACTION_TYPES.FOLDER,
label: i18n.baseText('projects.header.create.folder'), label: i18n.baseText('projects.header.create.folder'),
disabled: false, disabled:
sourceControlStore.preferences.branchReadOnly ||
!getResourcePermissions(homeProject.value?.scopes).folder.create,
}); });
} }
return items; return items;

View File

@@ -164,7 +164,7 @@ describe('WorkflowCard', () => {
if (!actions) { if (!actions) {
throw new Error('Actions menu not found'); throw new Error('Actions menu not found');
} }
expect(actions).toHaveTextContent('Move'); expect(actions).toHaveTextContent('Change owner');
}); });
it('should show Read only mode', async () => { it('should show Read only mode', async () => {

View File

@@ -1,6 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed } from 'vue';
import type { FolderPathItem, IUser } from '@/Interface';
import { import {
DUPLICATE_MODAL_KEY, DUPLICATE_MODAL_KEY,
MODAL_CONFIRM, MODAL_CONFIRM,
@@ -26,7 +25,7 @@ import { useTelemetry } from '@/composables/useTelemetry';
import { ResourceType } from '@/utils/projects.utils'; import { ResourceType } from '@/utils/projects.utils';
import type { EventBus } from '@n8n/utils/event-bus'; import type { EventBus } from '@n8n/utils/event-bus';
import type { WorkflowResource } from './layouts/ResourcesListLayout.vue'; import type { WorkflowResource } from './layouts/ResourcesListLayout.vue';
import { type ProjectIcon as CardProjectIcon, ProjectTypes } from '@/types/projects.types'; import type { IUser } from 'n8n-workflow';
const WORKFLOW_LIST_ITEM_ACTIONS = { const WORKFLOW_LIST_ITEM_ACTIONS = {
OPEN: 'open', OPEN: 'open',
@@ -39,10 +38,6 @@ const WORKFLOW_LIST_ITEM_ACTIONS = {
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
data: WorkflowResource; data: WorkflowResource;
breadcrumbs: {
visibleItems: FolderPathItem[];
hiddenItems: FolderPathItem[];
};
readOnly?: boolean; readOnly?: boolean;
workflowListEventBus?: EventBus; workflowListEventBus?: EventBus;
}>(), }>(),
@@ -64,7 +59,6 @@ const message = useMessage();
const locale = useI18n(); const locale = useI18n();
const router = useRouter(); const router = useRouter();
const telemetry = useTelemetry(); const telemetry = useTelemetry();
const i18n = useI18n();
const settingsStore = useSettingsStore(); const settingsStore = useSettingsStore();
const uiStore = useUIStore(); const uiStore = useUIStore();
@@ -96,7 +90,7 @@ const actions = computed(() => {
if (workflowPermissions.value.move && projectsStore.isTeamProjectFeatureEnabled) { if (workflowPermissions.value.move && projectsStore.isTeamProjectFeatureEnabled) {
items.push({ items.push({
label: locale.baseText('workflows.item.move'), label: locale.baseText('workflows.item.changeOwner'),
value: WORKFLOW_LIST_ITEM_ACTIONS.MOVE, value: WORKFLOW_LIST_ITEM_ACTIONS.MOVE,
}); });
} }
@@ -119,23 +113,6 @@ const formattedCreatedAtDate = computed(() => {
); );
}); });
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 i18n.baseText('projects.menu.personal');
}
return props.data.homeProject?.name;
});
async function onClick(event?: KeyboardEvent | PointerEvent) { async function onClick(event?: KeyboardEvent | PointerEvent) {
if (event?.ctrlKey || event?.metaKey) { if (event?.ctrlKey || event?.metaKey) {
const route = router.resolve({ const route = router.resolve({
@@ -291,35 +268,12 @@ const emitWorkflowActiveToggle = (value: { id: string; active: boolean }) => {
<template #append> <template #append>
<div :class="$style.cardActions" @click.stop> <div :class="$style.cardActions" @click.stop>
<ProjectCardBadge <ProjectCardBadge
v-if="!data.parentFolder"
:class="$style.cardBadge" :class="$style.cardBadge"
:resource="data" :resource="data"
:resource-type="ResourceType.Workflow" :resource-type="ResourceType.Workflow"
:resource-type-label="resourceTypeLabel" :resource-type-label="resourceTypeLabel"
:personal-project="projectsStore.personalProject" :personal-project="projectsStore.personalProject"
/> />
<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 <WorkflowActivator
class="mr-s" class="mr-s"
:workflow-active="data.active" :workflow-active="data.active"
@@ -383,7 +337,7 @@ const emitWorkflowActiveToggle = (value: { id: string; active: boolean }) => {
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--spacing-3xs); gap: var(--spacing-3xs);
color: var(--color-text-dark); color: var(--color-text-base);
} }
@include mixins.breakpoint('sm-and-down') { @include mixins.breakpoint('sm-and-down') {

View File

@@ -96,6 +96,7 @@ const props = withDefaults(
resourcesRefreshing?: boolean; resourcesRefreshing?: boolean;
// Set to true if sorting and filtering is done outside of the component // Set to true if sorting and filtering is done outside of the component
dontPerformSortingAndFiltering?: boolean; dontPerformSortingAndFiltering?: boolean;
hasEmptyState?: boolean;
}>(), }>(),
{ {
displayName: (resource: Resource) => resource.name || '', displayName: (resource: Resource) => resource.name || '',
@@ -114,6 +115,7 @@ const props = withDefaults(
totalItems: 0, totalItems: 0,
dontPerformSortingAndFiltering: false, dontPerformSortingAndFiltering: false,
resourcesRefreshing: false, resourcesRefreshing: false,
hasEmptyState: true,
}, },
); );
@@ -163,7 +165,7 @@ const filtersModel = computed({
const showEmptyState = computed(() => { const showEmptyState = computed(() => {
return ( return (
route.params.folderId === undefined && props.hasEmptyState &&
props.resources.length === 0 && props.resources.length === 0 &&
// Don't show empty state if resources are refreshing or if filters are being set // Don't show empty state if resources are refreshing or if filters are being set
!hasFilters.value && !hasFilters.value &&
@@ -700,6 +702,15 @@ const loadPaginationFromQueryString = async () => {
} }
} }
.search {
max-width: 196px;
justify-self: end;
input {
height: 42px;
}
}
.search { .search {
@include mixins.breakpoint('sm-and-down') { @include mixins.breakpoint('sm-and-down') {
max-width: 100%; max-width: 100%;

View File

@@ -76,7 +76,7 @@ export function useToast() {
function showToast(config: { function showToast(config: {
title: string; title: string;
message: NotificationOptions['message']; message: NotificationOptions['message'];
onClick?: () => void; onClick?: (event?: MouseEvent) => void;
onClose?: () => void; onClose?: () => void;
duration?: number; duration?: number;
customClass?: string; customClass?: string;

View File

@@ -901,14 +901,13 @@
"folders.add.here.message": "Create a new folder here", "folders.add.here.message": "Create a new folder here",
"folders.add.to.parent.message": "Create folder in \"{parent}\"", "folders.add.to.parent.message": "Create folder in \"{parent}\"",
"folders.add.success.title": "Folder created", "folders.add.success.title": "Folder created",
"folders.add.success.message": "<a href=\"{link}\">Open {name}</a> now", "folders.add.success.message": "Created new folder \"{folderName}\"<br><a href=\"{link}\">Open folder</a>",
"folders.invalidName.message": "Please provide a valid folder name", "folders.invalidName.message": "Please provide a valid folder name",
"folders.delete.confirm.title": "Delete \"{folderName}\"", "folders.delete.confirm.title": "Delete \"{folderName}\"",
"folders.delete.typeToConfirm": "delete {folderName}", "folders.delete.typeToConfirm": "delete {folderName}",
"folders.delete.confirm.message": "Are to sure you want to delete this folder?", "folders.delete.confirm.message": "Are to sure you want to delete this folder?",
"folders.delete.success.message": "Folder deleted", "folders.delete.success.message": "Folder deleted",
"folders.delete.confirmActionAfterDelete": "What should we do with the data in this folder?", "folder.delete.modal.confirmation": "What should we do with the folders and workflows within this folder?",
"folder.delete.modal.confirmation": "What should we do with {folders} {workflows} in this folder?",
"folder.count": "the {count} folder | the {count} folders", "folder.count": "the {count} folder | the {count} folders",
"workflow.count": "the {count} workflow | the {count} workflows", "workflow.count": "the {count} workflow | the {count} workflows",
"folder.and.workflow.separator": "and", "folder.and.workflow.separator": "and",
@@ -917,7 +916,7 @@
"folders.delete.confirmation.message": "Type \"delete {folderName}\" to confirm", "folders.delete.confirmation.message": "Type \"delete {folderName}\" to confirm",
"folders.transfer.confirm.message": "Data transferred to \"{folderName}\"", "folders.transfer.confirm.message": "Data transferred to \"{folderName}\"",
"folders.transfer.action": "Transfer workflows and subfolders to another folder", "folders.transfer.action": "Transfer workflows and subfolders to another folder",
"folders.transfer.selectFolder": "Folder to to transfer to", "folders.transfer.selectFolder": "Folder to transfer to",
"folders.transfer.select.placeholder": "Select folder", "folders.transfer.select.placeholder": "Select folder",
"folders.rename.message": "Rename \"{folderName}\"", "folders.rename.message": "Rename \"{folderName}\"",
"folders.rename.error.title": "Problem renaming folder", "folders.rename.error.title": "Problem renaming folder",
@@ -2375,6 +2374,7 @@
"workflows.item.duplicate": "Duplicate", "workflows.item.duplicate": "Duplicate",
"workflows.item.delete": "Delete", "workflows.item.delete": "Delete",
"workflows.item.move": "Move", "workflows.item.move": "Move",
"workflows.item.changeOwner": "Change owner",
"workflows.item.updated": "Last updated", "workflows.item.updated": "Last updated",
"workflows.item.created": "Created", "workflows.item.created": "Created",
"workflows.item.readonly": "Read only", "workflows.item.readonly": "Read only",

View File

@@ -125,7 +125,9 @@ const currentFolderId = ref<string | null>(null);
* or on each folder card, and then they are applied to the clicked folder * or on each folder card, and then they are applied to the clicked folder
* 'onlyAvailableOn' is used to specify where the action should be available, if not specified it will be available on both * 'onlyAvailableOn' is used to specify where the action should be available, if not specified it will be available on both
*/ */
const folderActions = ref<Array<UserAction & { onlyAvailableOn?: 'mainBreadcrumbs' | 'card' }>>([ const folderActions = computed<
Array<UserAction & { onlyAvailableOn?: 'mainBreadcrumbs' | 'card' }>
>(() => [
{ {
label: i18n.baseText('generic.open'), label: i18n.baseText('generic.open'),
value: FOLDER_LIST_ITEM_ACTIONS.OPEN, value: FOLDER_LIST_ITEM_ACTIONS.OPEN,
@@ -135,39 +137,42 @@ const folderActions = ref<Array<UserAction & { onlyAvailableOn?: 'mainBreadcrumb
{ {
label: i18n.baseText('folders.actions.create'), label: i18n.baseText('folders.actions.create'),
value: FOLDER_LIST_ITEM_ACTIONS.CREATE, value: FOLDER_LIST_ITEM_ACTIONS.CREATE,
disabled: false, disabled: readOnlyEnv.value || !hasPermissionToCreateFolders.value,
}, },
{ {
label: i18n.baseText('folders.actions.create.workflow'), label: i18n.baseText('folders.actions.create.workflow'),
value: FOLDER_LIST_ITEM_ACTIONS.CREATE_WORKFLOW, value: FOLDER_LIST_ITEM_ACTIONS.CREATE_WORKFLOW,
disabled: false, disabled: readOnlyEnv.value || !hasPermissionToCreateWorkflows.value,
}, },
{ {
label: i18n.baseText('generic.rename'), label: i18n.baseText('generic.rename'),
value: FOLDER_LIST_ITEM_ACTIONS.RENAME, value: FOLDER_LIST_ITEM_ACTIONS.RENAME,
disabled: false, disabled: readOnlyEnv.value || !hasPermissionToUpdateFolders.value,
}, },
{ {
label: i18n.baseText('folders.actions.moveToFolder'), label: i18n.baseText('folders.actions.moveToFolder'),
value: FOLDER_LIST_ITEM_ACTIONS.MOVE, value: FOLDER_LIST_ITEM_ACTIONS.MOVE,
disabled: true, disabled: readOnlyEnv.value || !hasPermissionToUpdateFolders.value,
}, },
{ {
label: i18n.baseText('generic.delete'), label: i18n.baseText('generic.delete'),
value: FOLDER_LIST_ITEM_ACTIONS.DELETE, value: FOLDER_LIST_ITEM_ACTIONS.DELETE,
disabled: false, disabled: readOnlyEnv.value || !hasPermissionToDeleteFolders.value,
}, },
]); ]);
const folderCardActions = computed(() => const folderCardActions = computed(() =>
folderActions.value.filter( folderActions.value.filter(
(action) => !action.onlyAvailableOn || action.onlyAvailableOn === 'card', (action) => !action.onlyAvailableOn || action.onlyAvailableOn === 'card',
), ),
); );
const mainBreadcrumbsActions = computed(() => const mainBreadcrumbsActions = computed(() =>
folderActions.value.filter( folderActions.value.filter(
(action) => !action.onlyAvailableOn || action.onlyAvailableOn === 'mainBreadcrumbs', (action) => !action.onlyAvailableOn || action.onlyAvailableOn === 'mainBreadcrumbs',
), ),
); );
const readOnlyEnv = computed(() => sourceControlStore.preferences.branchReadOnly); const readOnlyEnv = computed(() => sourceControlStore.preferences.branchReadOnly);
const foldersEnabled = computed(() => settingsStore.settings.folders.enabled); const foldersEnabled = computed(() => settingsStore.settings.folders.enabled);
const isOverviewPage = computed(() => route.name === VIEWS.WORKFLOWS); const isOverviewPage = computed(() => route.name === VIEWS.WORKFLOWS);
@@ -181,6 +186,26 @@ const currentFolder = computed(() => {
return currentFolderId.value ? foldersStore.breadcrumbsCache[currentFolderId.value] : null; return currentFolderId.value ? foldersStore.breadcrumbsCache[currentFolderId.value] : null;
}); });
const hasPermissionToCreateFolders = computed(() => {
if (!currentProject.value) return false;
return getResourcePermissions(currentProject.value.scopes).folder.create === true;
});
const hasPermissionToUpdateFolders = computed(() => {
if (!currentProject.value) return false;
return getResourcePermissions(currentProject.value.scopes).folder.update === true;
});
const hasPermissionToDeleteFolders = computed(() => {
if (!currentProject.value) return false;
return getResourcePermissions(currentProject.value.scopes).folder.delete === true;
});
const hasPermissionToCreateWorkflows = computed(() => {
if (!currentProject.value) return false;
return getResourcePermissions(currentProject.value.scopes).workflow.create === true;
});
const currentProject = computed(() => projectsStore.currentProject); const currentProject = computed(() => projectsStore.currentProject);
const projectName = computed(() => { const projectName = computed(() => {
@@ -385,8 +410,8 @@ const fetchWorkflows = async () => {
{ {
name: filters.value.search || undefined, name: filters.value.search || undefined,
active: activeFilter, active: activeFilter,
tags, tags: tags.length ? tags : undefined,
parentFolderId: parentFolder ?? '0', // 0 is the root folder in the API parentFolderId: parentFolder ?? (isOverviewPage.value ? undefined : '0'), // Sending 0 will only show one level of folders
}, },
fetchFolders, fetchFolders,
); );
@@ -440,14 +465,14 @@ const onSortUpdated = async (sort: string) => {
const onFiltersUpdated = async () => { const onFiltersUpdated = async () => {
currentPage.value = 1; currentPage.value = 1;
saveFiltersOnQueryString(); saveFiltersOnQueryString();
await fetchWorkflows(); await callDebounced(fetchWorkflows, { debounceTime: 100, trailing: true });
}; };
const onSearchUpdated = async (search: string) => { const onSearchUpdated = async (search: string) => {
currentPage.value = 1; currentPage.value = 1;
saveFiltersOnQueryString(); saveFiltersOnQueryString();
if (search) { if (search) {
await callDebounced(fetchWorkflows, { debounceTime: 500, trailing: true }); await callDebounced(fetchWorkflows, { debounceTime: 100, trailing: true });
} else { } else {
// No need to debounce when clearing search // No need to debounce when clearing search
await fetchWorkflows(); await fetchWorkflows();
@@ -456,12 +481,12 @@ const onSearchUpdated = async (search: string) => {
const setCurrentPage = async (page: number) => { const setCurrentPage = async (page: number) => {
currentPage.value = page; currentPage.value = page;
await fetchWorkflows(); await callDebounced(fetchWorkflows, { debounceTime: 100, trailing: true });
}; };
const setPageSize = async (size: number) => { const setPageSize = async (size: number) => {
pageSize.value = size; pageSize.value = size;
await fetchWorkflows(); await callDebounced(fetchWorkflows, { debounceTime: 100, trailing: true });
}; };
const onClickTag = async (tagId: string) => { const onClickTag = async (tagId: string) => {
@@ -735,25 +760,6 @@ const mainBreadcrumbs = computed(() => {
}; };
}); });
/**
* Card breadcrumbs items that show on workflow and folder cards
* These show path to the current folder with up to one parent visible
*/
const cardBreadcrumbs = computed(() => {
const visibleItems = visibleBreadcrumbsItems.value;
const hiddenItems = hiddenBreadcrumbsItems.value;
if (visibleItems.length > 1) {
return {
visibleItems: [visibleItems[visibleItems.length - 1]],
hiddenItems: [...hiddenItems, ...visibleItems.slice(0, visibleItems.length - 1)],
};
}
return {
visibleItems,
hiddenItems,
};
});
const onBreadcrumbItemClick = (item: PathItem) => { const onBreadcrumbItemClick = (item: PathItem) => {
if (item.href) { if (item.href) {
loading.value = true; loading.value = true;
@@ -839,14 +845,18 @@ const onFolderCardAction = async (payload: { action: string; folderId: string })
// Reusable action handlers // Reusable action handlers
// Both action handlers ultimately call these methods once folder to apply action to is determined // Both action handlers ultimately call these methods once folder to apply action to is determined
const createFolder = async (parent: { id: string; name: string; type: 'project' | 'folder' }) => { const createFolder = async (parent: { id: string; name: string; type: 'project' | 'folder' }) => {
// Rules for folder name:
// - Invalid characters: \/:*?"<>|
// - Invalid name: empty or only dots
const validFolderNameRegex = /^(?!\.+$)(?!\s+$)[^\\/:*?"<>|]{1,100}$/;
const promptResponsePromise = message.prompt( const promptResponsePromise = message.prompt(
i18n.baseText('folders.add.to.parent.message', { interpolate: { parent: parent.name } }), i18n.baseText('folders.add.to.parent.message', { interpolate: { parent: parent.name } }),
{ {
confirmButtonText: i18n.baseText('generic.create'), confirmButtonText: i18n.baseText('generic.create'),
cancelButtonText: i18n.baseText('generic.cancel'), cancelButtonText: i18n.baseText('generic.cancel'),
inputErrorMessage: i18n.baseText('folders.invalidName.message'), inputErrorMessage: i18n.baseText('folders.invalidName.message'),
inputValue: '', inputPattern: validFolderNameRegex,
inputPattern: /^[a-zA-Z0-9-_ ]{1,100}$/,
customClass: 'add-folder-modal', customClass: 'add-folder-modal',
}, },
); );
@@ -864,14 +874,20 @@ const createFolder = async (parent: { id: string; name: string; type: 'project'
if (newFolder.parentFolder) { if (newFolder.parentFolder) {
newFolderURL = `/projects/${route.params.projectId}/folders/${newFolder.id}/workflows`; newFolderURL = `/projects/${route.params.projectId}/folders/${newFolder.id}/workflows`;
} }
toast.showMessage({ toast.showToast({
title: i18n.baseText('folders.add.success.title'), title: i18n.baseText('folders.add.success.title'),
message: i18n.baseText('folders.add.success.message', { message: i18n.baseText('folders.add.success.message', {
interpolate: { interpolate: {
link: newFolderURL, link: newFolderURL,
name: newFolder.name, folderName: newFolder.name,
}, },
}), }),
onClick: (event: MouseEvent | undefined) => {
if (event?.target instanceof HTMLAnchorElement) {
event.preventDefault();
void router.push(newFolderURL);
}
},
type: 'success', type: 'success',
}); });
// If we are on an empty list, just add the new folder to the list // If we are on an empty list, just add the new folder to the list
@@ -978,6 +994,7 @@ const deleteFolder = async (folderId: string, workflowCount: number, subFolderCo
:custom-page-size="DEFAULT_WORKFLOW_PAGE_SIZE" :custom-page-size="DEFAULT_WORKFLOW_PAGE_SIZE"
:total-items="workflowsStore.totalWorkflowCount" :total-items="workflowsStore.totalWorkflowCount"
:dont-perform-sorting-and-filtering="true" :dont-perform-sorting-and-filtering="true"
:has-empty-state="foldersStore.totalWorkflowCount === 0 && !currentFolderId"
@click:add="addWorkflow" @click:add="addWorkflow"
@update:search="onSearchUpdated" @update:search="onSearchUpdated"
@update:current-page="setCurrentPage" @update:current-page="setCurrentPage"
@@ -989,7 +1006,7 @@ const deleteFolder = async (folderId: string, workflowCount: number, subFolderCo
<ProjectHeader @create-folder="createFolderInCurrent" /> <ProjectHeader @create-folder="createFolderInCurrent" />
</template> </template>
<template v-if="showFolders" #add-button> <template v-if="showFolders" #add-button>
<N8nTooltip placement="top"> <N8nTooltip placement="top" :disabled="readOnlyEnv || !hasPermissionToCreateFolders">
<template #content> <template #content>
{{ {{
currentParentName currentParentName
@@ -1005,6 +1022,7 @@ const deleteFolder = async (folderId: string, workflowCount: number, subFolderCo
type="tertiary" type="tertiary"
data-test-id="add-folder-button" data-test-id="add-folder-button"
:class="$style['add-folder-button']" :class="$style['add-folder-button']"
:disabled="readOnlyEnv || !hasPermissionToCreateFolders"
@click="createFolderInCurrent" @click="createFolderInCurrent"
/> />
</N8nTooltip> </N8nTooltip>
@@ -1060,7 +1078,7 @@ const deleteFolder = async (folderId: string, workflowCount: number, subFolderCo
v-if="(data as FolderResource | WorkflowResource).resourceType === 'folder'" v-if="(data as FolderResource | WorkflowResource).resourceType === 'folder'"
:data="data as FolderResource" :data="data as FolderResource"
:actions="folderCardActions" :actions="folderCardActions"
:breadcrumbs="cardBreadcrumbs" :read-only="readOnlyEnv || (!hasPermissionToDeleteFolders && !hasPermissionToCreateFolders)"
class="mb-2xs" class="mb-2xs"
@action="onFolderCardAction" @action="onFolderCardAction"
/> />
@@ -1069,7 +1087,6 @@ const deleteFolder = async (folderId: string, workflowCount: number, subFolderCo
data-test-id="resources-list-item" data-test-id="resources-list-item"
class="mb-2xs" class="mb-2xs"
:data="data as WorkflowResource" :data="data as WorkflowResource"
:breadcrumbs="cardBreadcrumbs"
:workflow-list-event-bus="workflowListEventBus" :workflow-list-event-bus="workflowListEventBus"
:read-only="readOnlyEnv" :read-only="readOnlyEnv"
@click:tag="onClickTag" @click:tag="onClickTag"
@@ -1219,6 +1236,7 @@ const deleteFolder = async (folderId: string, workflowCount: number, subFolderCo
.breadcrumbs-container { .breadcrumbs-container {
display: flex; display: flex;
align-items: center; align-items: center;
align-self: flex-end;
} }
.breadcrumbs-loading { .breadcrumbs-loading {