mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 18:12:04 +00:00
fix(editor): Address folders feature feedback (no-changelog) (#13859)
This commit is contained in:
committed by
GitHub
parent
b4672b8deb
commit
31493a0cac
@@ -80,28 +80,6 @@ const enabled = computed(() => {
|
||||
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() {
|
||||
if (!enabled.value) {
|
||||
return;
|
||||
@@ -161,7 +139,9 @@ onMounted(async () => {
|
||||
</div>
|
||||
<div v-else :class="$style.content">
|
||||
<div>
|
||||
<n8n-text color="text-base">{{ folderContentWarningMessage }}</n8n-text>
|
||||
<n8n-text color="text-base">{{
|
||||
i18n.baseText('folder.delete.modal.confirmation')
|
||||
}}</n8n-text>
|
||||
</div>
|
||||
<el-radio
|
||||
v-model="operation"
|
||||
|
||||
@@ -57,7 +57,7 @@ const onAction = (action: string) => {
|
||||
<template v-if="currentProject" #prepend>
|
||||
<div :class="$style['home-project']" data-test-id="home-project">
|
||||
<n8n-link :to="`/projects/${currentProject.id}`">
|
||||
<N8nText size="large" color="text-base">{{ projectName }}</N8nText>
|
||||
<N8nText size="medium" color="text-base">{{ projectName }}</N8nText>
|
||||
</n8n-link>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -44,25 +44,6 @@ const DEFAULT_FOLDER: 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[] } = {
|
||||
visibleItems: [{ id: '1', label: 'Parent 2' }],
|
||||
hiddenItems: [{ id: '2', label: 'Parent 1', parentFolder: '1' }],
|
||||
@@ -122,67 +103,6 @@ describe('FolderCard', () => {
|
||||
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', () => {
|
||||
const { queryByTestId } = renderComponent({
|
||||
props: {
|
||||
|
||||
@@ -6,20 +6,17 @@ import { type ProjectIcon, ProjectTypes } from '@/types/projects.types';
|
||||
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 { FolderPathItem, UserAction } from '@/Interface';
|
||||
import type { UserAction } from '@/Interface';
|
||||
|
||||
type Props = {
|
||||
data: FolderResource;
|
||||
actions: UserAction[];
|
||||
breadcrumbs: {
|
||||
visibleItems: FolderPathItem[];
|
||||
hiddenItems: FolderPathItem[];
|
||||
};
|
||||
readOnly?: boolean;
|
||||
};
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
actions: () => [],
|
||||
readOnly: true,
|
||||
});
|
||||
|
||||
const i18n = useI18n();
|
||||
@@ -71,12 +68,6 @@ const onAction = async (action: string) => {
|
||||
}
|
||||
emit('action', { action, folderId: props.data.id });
|
||||
};
|
||||
|
||||
const onBreadcrumbsItemClick = async (item: PathItem) => {
|
||||
if (item.href) {
|
||||
await router.push(item.href);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -88,13 +79,18 @@ const onBreadcrumbsItemClick = async (item: PathItem) => {
|
||||
data-test-id="folder-card-icon"
|
||||
:class="$style['folder-icon']"
|
||||
icon="folder"
|
||||
size="large"
|
||||
size="xlarge"
|
||||
/>
|
||||
</template>
|
||||
<template #header>
|
||||
<n8n-heading tag="h2" bold size="small" data-test-id="folder-card-name">
|
||||
{{ data.name }}
|
||||
</n8n-heading>
|
||||
<div :class="$style['card-header']">
|
||||
<n8n-heading tag="h2" bold size="small" data-test-id="folder-card-name">
|
||||
{{ data.name }}
|
||||
</n8n-heading>
|
||||
<N8nBadge v-if="readOnly" class="ml-3xs" theme="tertiary" bold>
|
||||
{{ i18n.baseText('workflows.item.readonly') }}
|
||||
</N8nBadge>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div :class="$style['card-footer']">
|
||||
@@ -140,28 +136,15 @@ const onBreadcrumbsItemClick = async (item: PathItem) => {
|
||||
</template>
|
||||
<template #append>
|
||||
<div :class="$style['card-actions']" @click.prevent>
|
||||
<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']" 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 v-if="data.homeProject" :class="$style['project-pill']">
|
||||
<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>
|
||||
</div>
|
||||
<n8n-action-toggle
|
||||
v-if="actions.length"
|
||||
@@ -186,17 +169,24 @@ const onBreadcrumbsItemClick = async (item: PathItem) => {
|
||||
box-shadow: 0 2px 8px rgba(#441c17, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.folder-icon {
|
||||
width: var(--spacing-xl);
|
||||
height: var(--spacing-xl);
|
||||
flex-shrink: 0;
|
||||
background-color: var(--color-background-dark);
|
||||
color: var(--color-background-light-base);
|
||||
border-radius: 50%;
|
||||
color: var(--color-text-base);
|
||||
align-content: 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 {
|
||||
display: flex;
|
||||
}
|
||||
@@ -215,6 +205,14 @@ const onBreadcrumbsItemClick = async (item: PathItem) => {
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -57,7 +57,7 @@ const showSettings = computed(
|
||||
|
||||
const homeProject = computed(() => projectsStore.currentProject ?? projectsStore.personalProject);
|
||||
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 = {
|
||||
WORKFLOW: 'workflow',
|
||||
@@ -85,11 +85,13 @@ const menu = computed(() => {
|
||||
!getResourcePermissions(homeProject.value?.scopes).credential.create,
|
||||
},
|
||||
];
|
||||
if (isFoldersFeatureEnabled.value && !isOverviewPage.value) {
|
||||
if (isFoldersFeatureEnabled.value && isProjectPage.value) {
|
||||
items.push({
|
||||
value: ACTION_TYPES.FOLDER,
|
||||
label: i18n.baseText('projects.header.create.folder'),
|
||||
disabled: false,
|
||||
disabled:
|
||||
sourceControlStore.preferences.branchReadOnly ||
|
||||
!getResourcePermissions(homeProject.value?.scopes).folder.create,
|
||||
});
|
||||
}
|
||||
return items;
|
||||
|
||||
@@ -164,7 +164,7 @@ describe('WorkflowCard', () => {
|
||||
if (!actions) {
|
||||
throw new Error('Actions menu not found');
|
||||
}
|
||||
expect(actions).toHaveTextContent('Move');
|
||||
expect(actions).toHaveTextContent('Change owner');
|
||||
});
|
||||
|
||||
it('should show Read only mode', async () => {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import type { FolderPathItem, IUser } from '@/Interface';
|
||||
import {
|
||||
DUPLICATE_MODAL_KEY,
|
||||
MODAL_CONFIRM,
|
||||
@@ -26,7 +25,7 @@ import { useTelemetry } from '@/composables/useTelemetry';
|
||||
import { ResourceType } from '@/utils/projects.utils';
|
||||
import type { EventBus } from '@n8n/utils/event-bus';
|
||||
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 = {
|
||||
OPEN: 'open',
|
||||
@@ -39,10 +38,6 @@ const WORKFLOW_LIST_ITEM_ACTIONS = {
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
data: WorkflowResource;
|
||||
breadcrumbs: {
|
||||
visibleItems: FolderPathItem[];
|
||||
hiddenItems: FolderPathItem[];
|
||||
};
|
||||
readOnly?: boolean;
|
||||
workflowListEventBus?: EventBus;
|
||||
}>(),
|
||||
@@ -64,7 +59,6 @@ const message = useMessage();
|
||||
const locale = useI18n();
|
||||
const router = useRouter();
|
||||
const telemetry = useTelemetry();
|
||||
const i18n = useI18n();
|
||||
|
||||
const settingsStore = useSettingsStore();
|
||||
const uiStore = useUIStore();
|
||||
@@ -96,7 +90,7 @@ const actions = computed(() => {
|
||||
|
||||
if (workflowPermissions.value.move && projectsStore.isTeamProjectFeatureEnabled) {
|
||||
items.push({
|
||||
label: locale.baseText('workflows.item.move'),
|
||||
label: locale.baseText('workflows.item.changeOwner'),
|
||||
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) {
|
||||
if (event?.ctrlKey || event?.metaKey) {
|
||||
const route = router.resolve({
|
||||
@@ -291,35 +268,12 @@ const emitWorkflowActiveToggle = (value: { id: string; active: boolean }) => {
|
||||
<template #append>
|
||||
<div :class="$style.cardActions" @click.stop>
|
||||
<ProjectCardBadge
|
||||
v-if="!data.parentFolder"
|
||||
:class="$style.cardBadge"
|
||||
:resource="data"
|
||||
:resource-type="ResourceType.Workflow"
|
||||
:resource-type-label="resourceTypeLabel"
|
||||
: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
|
||||
class="mr-s"
|
||||
:workflow-active="data.active"
|
||||
@@ -383,7 +337,7 @@ const emitWorkflowActiveToggle = (value: { id: string; active: boolean }) => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3xs);
|
||||
color: var(--color-text-dark);
|
||||
color: var(--color-text-base);
|
||||
}
|
||||
|
||||
@include mixins.breakpoint('sm-and-down') {
|
||||
|
||||
@@ -96,6 +96,7 @@ const props = withDefaults(
|
||||
resourcesRefreshing?: boolean;
|
||||
// Set to true if sorting and filtering is done outside of the component
|
||||
dontPerformSortingAndFiltering?: boolean;
|
||||
hasEmptyState?: boolean;
|
||||
}>(),
|
||||
{
|
||||
displayName: (resource: Resource) => resource.name || '',
|
||||
@@ -114,6 +115,7 @@ const props = withDefaults(
|
||||
totalItems: 0,
|
||||
dontPerformSortingAndFiltering: false,
|
||||
resourcesRefreshing: false,
|
||||
hasEmptyState: true,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -163,7 +165,7 @@ const filtersModel = computed({
|
||||
|
||||
const showEmptyState = computed(() => {
|
||||
return (
|
||||
route.params.folderId === undefined &&
|
||||
props.hasEmptyState &&
|
||||
props.resources.length === 0 &&
|
||||
// Don't show empty state if resources are refreshing or if filters are being set
|
||||
!hasFilters.value &&
|
||||
@@ -700,6 +702,15 @@ const loadPaginationFromQueryString = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
.search {
|
||||
max-width: 196px;
|
||||
justify-self: end;
|
||||
|
||||
input {
|
||||
height: 42px;
|
||||
}
|
||||
}
|
||||
|
||||
.search {
|
||||
@include mixins.breakpoint('sm-and-down') {
|
||||
max-width: 100%;
|
||||
|
||||
Reference in New Issue
Block a user