feat(editor): Implement folder navigation in workflows list (#13370)

This commit is contained in:
Milorad FIlipović 2025-02-24 13:52:29 +01:00 committed by GitHub
parent f2b15ea086
commit 0eae14e27a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 1024 additions and 125 deletions

View file

@ -22,4 +22,5 @@ export const RESOURCES = {
variable: [...DEFAULT_OPERATIONS] as const,
workersView: ['manage'] as const,
workflow: ['share', 'execute', 'move', ...DEFAULT_OPERATIONS] as const,
folder: ['create', 'read'] as const,
} as const;

View file

@ -53,7 +53,7 @@ const hasHiddenItems = computed(() => {
});
const showEllipsis = computed(() => {
return hasHiddenItems.value || props.pathTruncated;
return props.items.length && (hasHiddenItems.value || props.pathTruncated);
});
const dropdownDisabled = computed(() => {
@ -137,7 +137,9 @@ const handleTooltipClose = () => {
>
<slot name="prepend"></slot>
<ul :class="$style.list">
<li v-if="$slots.prepend" :class="$style.separator" aria-hidden="true">{{ separator }}</li>
<li v-if="$slots.prepend && items.length" :class="$style.separator" aria-hidden="true">
{{ separator }}
</li>
<li
v-if="showEllipsis"
:class="{ [$style.ellipsis]: true, [$style.disabled]: dropdownDisabled }"
@ -218,7 +220,7 @@ const handleTooltipClose = () => {
&.small {
display: inline-flex;
padding: var(--spacing-4xs) var(--spacing-2xs);
padding: var(--spacing-4xs) var(--spacing-3xs);
}
&.border {
@ -242,6 +244,7 @@ const handleTooltipClose = () => {
.tooltip-ellipsis {
cursor: pointer;
user-select: none;
color: var(--color-text-base);
}
&.disabled {
.dots,

View file

@ -316,6 +316,41 @@ export interface IWorkflowDb {
meta?: WorkflowMetadata;
}
// For workflow list we don't need the full workflow data
export type BaseResource = {
id: string;
name: string;
};
export type WorkflowListItem = Omit<
IWorkflowDb,
'nodes' | 'connections' | 'settings' | 'pinData' | 'versionId' | 'usedCredentials' | 'meta'
> & {
resource: 'workflow';
parentFolder?: { id: string; name: string };
};
export type FolderShortInfo = {
id: string;
name: string;
};
export type BaseFolderItem = BaseResource & {
createdAt: string;
updatedAt: string;
workflowCount: number;
parentFolder?: FolderShortInfo;
homeProject?: ProjectSharingData;
sharedWithProjects?: ProjectSharingData[];
tags?: ITag[];
};
export interface FolderListItem extends BaseFolderItem {
resource: 'folder';
}
export type WorkflowListResource = WorkflowListItem | FolderListItem;
// Identical to cli.Interfaces.ts
export interface IWorkflowShortResponse {
id: string;

View file

@ -4,6 +4,7 @@ import type {
IRestApiContext,
IWorkflowDb,
NewWorkflowResponse,
WorkflowListResource,
} from '@/Interface';
import type {
ExecutionFilters,
@ -40,6 +41,20 @@ export async function getWorkflows(context: IRestApiContext, filter?: object, op
});
}
export async function getWorkflowsAndFolders(
context: IRestApiContext,
filter?: object,
options?: object,
includeFolders?: boolean,
) {
return await getFullApiResponse<WorkflowListResource[]>(context, 'GET', '/workflows', {
includeScopes: true,
includeFolders,
...(filter ? { filter } : {}),
...(options ? options : {}),
});
}
export async function getActiveWorkflows(context: IRestApiContext) {
return await makeRestApiRequest<string[]>(context, 'GET', '/active-workflows');
}

View file

@ -88,7 +88,8 @@ describe('CredentialCard', () => {
});
it('should set readOnly variant based on prop', () => {
const { getByRole } = renderComponent({ props: { readOnly: true } });
const data = createCredential({});
const { getByRole } = renderComponent({ props: { data, readOnly: true } });
const heading = getByRole('heading');
expect(heading).toHaveTextContent('Read only');
});

View file

@ -1,7 +1,6 @@
<script setup lang="ts">
import { computed } from 'vue';
import dateformat from 'dateformat';
import type { ICredentialsResponse } from '@/Interface';
import { MODAL_CONFIRM, PROJECT_MOVE_RESOURCE_MODAL } from '@/constants';
import { useMessage } from '@/composables/useMessage';
import CredentialIcon from '@/components/CredentialIcon.vue';
@ -9,11 +8,11 @@ import { getResourcePermissions } from '@/permissions';
import { useUIStore } from '@/stores/ui.store';
import { useCredentialsStore } from '@/stores/credentials.store';
import TimeAgo from '@/components/TimeAgo.vue';
import type { ProjectSharingData } from '@/types/projects.types';
import { useProjectsStore } from '@/stores/projects.store';
import ProjectCardBadge from '@/components/Projects/ProjectCardBadge.vue';
import { useI18n } from '@/composables/useI18n';
import { ResourceType } from '@/utils/projects.utils';
import type { CredentialsResource } from './layouts/ResourcesListLayout.vue';
const CREDENTIAL_LIST_ITEM_ACTIONS = {
OPEN: 'open',
@ -27,22 +26,13 @@ const emit = defineEmits<{
const props = withDefaults(
defineProps<{
data: ICredentialsResponse;
data: CredentialsResource;
readOnly?: boolean;
needsSetup?: boolean;
}>(),
{
data: () => ({
id: '',
createdAt: '',
updatedAt: '',
type: '',
name: '',
sharedWithProjects: [],
homeProject: {} as ProjectSharingData,
isManaged: false,
}),
readOnly: false,
needsSetup: false,
},
);
@ -53,7 +43,9 @@ const credentialsStore = useCredentialsStore();
const projectsStore = useProjectsStore();
const resourceTypeLabel = computed(() => locale.baseText('generic.credential').toLowerCase());
const credentialType = computed(() => credentialsStore.getCredentialTypeByName(props.data.type));
const credentialType = computed(() =>
credentialsStore.getCredentialTypeByName(props.data.type ?? ''),
);
const credentialPermissions = computed(() => getResourcePermissions(props.data.scopes).credential);
const actions = computed(() => {
const items = [

View file

@ -0,0 +1,204 @@
import { createComponentRenderer } from '@/__tests__/render';
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';
vi.mock('vue-router', () => {
const push = vi.fn();
const resolve = vi.fn().mockReturnValue({ href: '/projects/1/folders/1' });
return {
useRouter: vi.fn().mockReturnValue({
push,
resolve,
}),
useRoute: vi.fn().mockReturnValue({
params: {
projectId: '1',
folderId: '1',
},
query: {},
}),
RouterLink: vi.fn(),
};
});
const DEFAULT_FOLDER: FolderResource = {
id: '1',
name: 'Folder 1',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
resourceType: 'folder',
readOnly: false,
workflowCount: 0,
homeProject: {
id: '1',
name: 'Project 1',
icon: null,
type: 'personal',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
} 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,
homeProject: {
id: '1',
name: 'Project 1',
icon: null,
type: 'team',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
} as const satisfies FolderResource;
const renderComponent = createComponentRenderer(FolderCard, {
props: {
data: DEFAULT_FOLDER,
actions: [
{ label: 'Open', value: 'open', disabled: false },
{ label: 'Delete', value: 'delete', disabled: false },
] as const satisfies UserAction[],
},
global: {
stubs: {
'router-link': {
template: '<div data-test-id="folder-card-link"><slot /></div>',
},
},
},
});
describe('FolderCard', () => {
let pinia: ReturnType<typeof createPinia>;
beforeEach(async () => {
pinia = createPinia();
setActivePinia(pinia);
});
afterEach(() => {
vi.clearAllMocks();
});
it('should render folder info correctly', () => {
const { getByTestId } = renderComponent();
expect(getByTestId('folder-card-icon')).toBeInTheDocument();
expect(getByTestId('folder-card-name')).toHaveTextContent(DEFAULT_FOLDER.name);
expect(getByTestId('folder-card-workflow-count')).toHaveTextContent('0');
expect(getByTestId('folder-card-last-updated')).toHaveTextContent('Last updated just now');
expect(getByTestId('folder-card-created')).toHaveTextContent('Created just now');
});
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,
},
},
});
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: {
actions: [],
},
});
expect(queryByTestId('folder-card-actions')).not.toBeInTheDocument();
});
it('should render action dropdown if actions are provided', () => {
const { getByTestId } = renderComponent();
expect(getByTestId('folder-card-actions')).toBeInTheDocument();
});
it('should emit action event when action is clicked', async () => {
const { getByTestId, emitted } = renderComponent();
const actionButton = getByTestId('folder-card-actions').querySelector('[role=button]');
if (!actionButton) {
throw new Error('Action button not found');
}
await userEvent.click(actionButton);
const actionToggleId = actionButton.getAttribute('aria-controls');
const actionDropdown = document.getElementById(actionToggleId as string) as HTMLElement;
expect(actionDropdown).toBeInTheDocument();
const deleteAction = getByTestId('action-delete');
expect(deleteAction).toBeInTheDocument();
await userEvent.click(deleteAction);
expect(emitted('action')).toEqual([[{ action: 'delete', folderId: '1' }]]);
});
it('should emit folder-open action', async () => {
const { getByTestId, emitted } = renderComponent();
const actionButton = getByTestId('folder-card-actions').querySelector('[role=button]');
if (!actionButton) {
throw new Error('Action button not found');
}
await userEvent.click(actionButton);
const actionToggleId = actionButton.getAttribute('aria-controls');
const actionDropdown = document.getElementById(actionToggleId as string) as HTMLElement;
expect(actionDropdown).toBeInTheDocument();
const deleteAction = getByTestId('action-open');
expect(deleteAction).toBeInTheDocument();
await userEvent.click(deleteAction);
expect(emitted('folderOpened')).toEqual([[{ folder: DEFAULT_FOLDER }]]);
});
});

View file

@ -0,0 +1,217 @@
<script setup lang="ts">
import { computed } from 'vue';
import { FOLDER_LIST_ITEM_ACTIONS } from './constants';
import type { FolderResource } from '../layouts/ResourcesListLayout.vue';
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 { UserAction } from '@/Interface';
type Props = {
data: FolderResource;
actions: UserAction[];
};
const props = withDefaults(defineProps<Props>(), {
actions: () => [],
});
const i18n = useI18n();
const route = useRoute();
const router = useRouter();
const emit = defineEmits<{
action: [{ action: string; folderId: string }];
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) {
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;
});
const cardUrl = computed(() => {
return getFolderUrl(props.data.id);
});
const getFolderUrl = (folderId: string) => {
return router.resolve({
name: VIEWS.PROJECTS_FOLDERS,
params: {
projectId: route.params.projectId as string,
folderId,
},
query: route.query,
}).href;
};
const onAction = async (action: string) => {
if (action === FOLDER_LIST_ITEM_ACTIONS.OPEN) {
emit('folderOpened', { folder: props.data });
await router.push(cardUrl.value);
return;
}
emit('action', { action, folderId: props.data.id });
};
const onBreadcrumbsItemClick = async (item: PathItem) => {
if (item.href) {
await router.push(item.href);
}
};
</script>
<template>
<div>
<router-link :to="cardUrl" @click="() => emit('folderOpened', { folder: props.data })">
<n8n-card :class="$style.card">
<template #prepend>
<n8n-icon
data-test-id="folder-card-icon"
:class="$style['folder-icon']"
icon="folder"
size="large"
/>
</template>
<template #header>
<n8n-heading tag="h2" bold size="small" data-test-id="folder-card-name">
{{ data.name }}
</n8n-heading>
</template>
<template #footer>
<div :class="$style['card-footer']">
<n8n-text
size="small"
color="text-light"
:class="$style['info-cell']"
data-test-id="folder-card-workflow-count"
>
{{ data.workflowCount }} {{ i18n.baseText('generic.workflows') }}
</n8n-text>
<n8n-text
size="small"
color="text-light"
:class="$style['info-cell']"
data-test-id="folder-card-last-updated"
>
{{ i18n.baseText('workerList.item.lastUpdated') }}
<TimeAgo :date="String(data.updatedAt)" />
</n8n-text>
<n8n-text
size="small"
color="text-light"
:class="$style['info-cell']"
data-test-id="folder-card-created"
>
{{ i18n.baseText('workflows.item.created') }}
<TimeAgo :date="String(data.createdAt)" />
</n8n-text>
</div>
</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>
<n8n-action-toggle
v-if="actions.length"
:actions="actions"
theme="dark"
data-test-id="folder-card-actions"
@action="onAction"
/>
</div>
</template>
</n8n-card>
</router-link>
</div>
</template>
<style lang="scss" module>
.card {
transition: box-shadow 0.3s ease;
cursor: pointer;
&:hover {
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%;
align-content: center;
text-align: center;
}
.card-footer {
display: flex;
}
.info-cell {
& + & {
&::before {
content: '|';
margin: 0 var(--spacing-4xs);
}
}
}
.card-actions {
display: flex;
gap: var(--spacing-xs);
}
.home-project span {
display: flex;
align-items: center;
gap: var(--spacing-3xs);
color: var(--color-text-dark);
}
</style>

View file

@ -0,0 +1,10 @@
export const FOLDER_LIST_ITEM_ACTIONS = {
OPEN: 'open',
CREATE: 'create',
CREATE_WORKFLOW: 'create_workflow',
RENAME: 'rename',
MOVE: 'move',
CHOWN: 'change_owner',
TAGS: 'manage_tags',
DELETE: 'delete',
};

View file

@ -3,12 +3,12 @@ import { computed } from 'vue';
import { useI18n } from '@/composables/useI18n';
import type { ResourceType } from '@/utils/projects.utils';
import { splitName } from '@/utils/projects.utils';
import type { ICredentialsResponse, IWorkflowDb } from '@/Interface';
import type { Project } from '@/types/projects.types';
import type { Project, ProjectIcon as BadgeIcon } from '@/types/projects.types';
import { ProjectTypes } from '@/types/projects.types';
import type { CredentialsResource, WorkflowResource } from '../layouts/ResourcesListLayout.vue';
type Props = {
resource: IWorkflowDb | ICredentialsResponse;
resource: WorkflowResource | CredentialsResource;
resourceType: ResourceType;
resourceTypeLabel: string;
personalProject: Project | null;
@ -68,16 +68,16 @@ const badgeText = computed(() => {
return name ?? email ?? '';
}
});
const badgeIcon = computed(() => {
const badgeIcon = computed<BadgeIcon>(() => {
switch (projectState.value) {
case ProjectState.Owned:
case ProjectState.SharedOwned:
return 'user';
return { type: 'icon', value: 'user' };
case ProjectState.Team:
case ProjectState.SharedTeam:
return 'layer-group';
return props.resource.homeProject?.icon ?? { type: 'icon', value: 'layer-group' };
default:
return '';
return { type: 'icon', value: 'layer-group' };
}
});
const badgeTooltip = computed(() => {
@ -129,12 +129,12 @@ const badgeTooltip = computed(() => {
<div :class="$style.wrapper" v-bind="$attrs">
<N8nBadge
v-if="badgeText"
:class="$style.badge"
:class="[$style.badge, $style.projectBadge]"
theme="tertiary"
bold
data-test-id="card-badge"
>
<N8nIcon v-if="badgeIcon" :icon="badgeIcon" size="small" class="mr-3xs" />
<ProjectIcon :icon="badgeIcon" :border-less="true" size="mini" />
<span v-n8n-truncate:20>{{ badgeText }}</span>
</N8nBadge>
<N8nBadge
@ -170,6 +170,13 @@ const badgeTooltip = computed(() => {
}
}
.projectBadge {
& > span {
display: flex;
gap: var(--spacing-3xs);
}
}
.countBadge {
margin-left: -5px;
z-index: 0;

View file

@ -3,15 +3,17 @@ import type { ProjectIcon } from '@/types/projects.types';
type Props = {
icon: ProjectIcon;
size?: 'small' | 'medium' | 'large';
size?: 'mini' | 'small' | 'medium' | 'large';
round?: boolean;
borderLess?: boolean;
color?: 'text-light' | 'text-base' | 'text-dark';
};
const props = withDefaults(defineProps<Props>(), {
size: 'medium',
round: false,
borderLess: false,
color: 'text-base',
});
</script>
<template>
@ -23,9 +25,10 @@ const props = withDefaults(defineProps<Props>(), {
]"
>
<N8nIcon
v-if="props.icon.type === 'icon'"
:icon="props.icon.value"
color="text-light"
v-if="icon.type === 'icon'"
:icon="icon.value"
:class="$style.icon"
:color="color"
></N8nIcon>
<N8nText v-else-if="icon.type === 'emoji'" color="text-light" :class="$style.emoji">
{{ icon.value }}
@ -50,6 +53,19 @@ const props = withDefaults(defineProps<Props>(), {
}
}
.mini {
width: var(--spacing-xs);
height: var(--spacing-xs);
.icon {
font-size: var(--font-size-2xs);
}
.emoji {
font-size: var(--font-size-3xs);
}
}
.small {
min-width: var(--spacing-l);
height: var(--spacing-l);

View file

@ -73,6 +73,9 @@ watch(
() => route?.name,
() => {
selectedTab.value = route?.name;
// Select workflows tab if folders tab is selected
selectedTab.value =
route.name === VIEWS.PROJECTS_FOLDERS ? VIEWS.PROJECTS_WORKFLOWS : route.name;
},
{ immediate: true },
);

View file

@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed } from 'vue';
import type { IWorkflowDb, IUser } from '@/Interface';
import type { IUser } from '@/Interface';
import {
DUPLICATE_MODAL_KEY,
MODAL_CONFIRM,
@ -18,7 +18,6 @@ import { useSettingsStore } from '@/stores/settings.store';
import { useUsersStore } from '@/stores/users.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import TimeAgo from '@/components/TimeAgo.vue';
import type { ProjectSharingData } from '@/types/projects.types';
import { useProjectsStore } from '@/stores/projects.store';
import ProjectCardBadge from '@/components/Projects/ProjectCardBadge.vue';
import { useI18n } from '@/composables/useI18n';
@ -26,6 +25,8 @@ import { useRouter } from 'vue-router';
import { useTelemetry } from '@/composables/useTelemetry';
import { ResourceType } from '@/utils/projects.utils';
import type { EventBus } from 'n8n-design-system/utils';
import type { WorkflowResource } from './layouts/ResourcesListLayout.vue';
import { type ProjectIcon as CardProjectIcon, ProjectTypes } from '@/types/projects.types';
const WORKFLOW_LIST_ITEM_ACTIONS = {
OPEN: 'open',
@ -37,23 +38,11 @@ const WORKFLOW_LIST_ITEM_ACTIONS = {
const props = withDefaults(
defineProps<{
data: IWorkflowDb;
data: WorkflowResource;
readOnly?: boolean;
workflowListEventBus?: EventBus;
}>(),
{
data: () => ({
id: '',
createdAt: '',
updatedAt: '',
active: false,
connections: {},
nodes: [],
name: '',
sharedWithProjects: [],
homeProject: {} as ProjectSharingData,
versionId: '',
}),
readOnly: false,
workflowListEventBus: undefined,
},
@ -71,6 +60,7 @@ const message = useMessage();
const locale = useI18n();
const router = useRouter();
const telemetry = useTelemetry();
const i18n = useI18n();
const settingsStore = useSettingsStore();
const uiStore = useUIStore();
@ -125,6 +115,35 @@ 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) {
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({
@ -244,7 +263,7 @@ const emitWorkflowActiveToggle = (value: { id: string; active: boolean }) => {
</script>
<template>
<n8n-card :class="$style.cardLink" @click="onClick">
<n8n-card :class="$style.cardLink" data-test-id="workflow-card" @click="onClick">
<template #header>
<n8n-heading tag="h2" bold :class="$style.cardHeading" data-test-id="workflow-card-name">
{{ data.name }}
@ -280,12 +299,33 @@ 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"
/>
<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>
<WorkflowActivator
class="mr-s"
:workflow-active="data.active"
@ -345,6 +385,13 @@ const emitWorkflowActiveToggle = (value: { id: string; active: boolean }) => {
cursor: default;
}
.home-project span {
display: flex;
align-items: center;
gap: var(--spacing-3xs);
color: var(--color-text-dark);
}
@include mixins.breakpoint('sm-and-down') {
.cardLink {
--card--padding: 0 var(--spacing-s) var(--spacing-s);

View file

@ -15,12 +15,14 @@ const props = withDefaults(
keys?: string[];
shareable?: boolean;
reset?: () => void;
justIcon?: boolean;
}>(),
{
modelValue: () => ({}),
keys: () => [],
shareable: true,
reset: () => {},
justIcon: false,
},
);
@ -112,18 +114,21 @@ onBeforeMount(async () => {
icon="filter"
type="tertiary"
:active="hasFilters"
:class="$style['filter-button']"
:class="{
[$style['filter-button']]: true,
[$style['no-label']]: justIcon && filtersLength === 0,
}"
data-test-id="resources-list-filters-trigger"
>
<n8n-badge
v-show="filtersLength > 0"
v-if="filtersLength > 0"
:class="$style['filter-button-count']"
data-test-id="resources-list-filters-count"
theme="primary"
>
{{ filtersLength }}
</n8n-badge>
<span :class="$style['filter-button-text']">
<span v-if="!justIcon" :class="$style['filter-button-text']">
{{ i18n.baseText('forms.resourceFiltersDropdown.filters') }}
</span>
</n8n-button>
@ -163,6 +168,12 @@ onBeforeMount(async () => {
height: 40px;
align-items: center;
&.no-label {
span + span {
margin: 0;
}
}
.filter-button-count {
margin-right: var(--spacing-4xs);

View file

@ -14,28 +14,55 @@ import { useRoute, useRouter } from 'vue-router';
import type { BaseTextKey } from '@/plugins/i18n';
import type { Scope } from '@n8n/permissions';
import type { BaseFolderItem, BaseResource, FolderShortInfo, ITag } from '@/Interface';
import { isSharedResource, isResourceSortableByDate } from '@/utils/typeGuards';
export type Resource = {
id: string;
name?: string;
value?: string;
key?: string;
updatedAt?: string;
createdAt?: string;
type ResourceKeyType = 'credentials' | 'workflows' | 'variables' | 'folders';
export type FolderResource = BaseFolderItem & {
resourceType: 'folder';
readOnly: boolean;
};
export type WorkflowResource = BaseResource & {
resourceType: 'workflow';
updatedAt: string;
createdAt: string;
active: boolean;
homeProject?: ProjectSharingData;
scopes?: Scope[];
type?: string;
tags?: ITag[] | string[];
sharedWithProjects?: ProjectSharingData[];
readOnly: boolean;
parentFolder?: FolderShortInfo;
};
export type VariableResource = BaseResource & {
resourceType: 'variable';
key?: string;
value?: string;
};
export type CredentialsResource = BaseResource & {
resourceType: 'credential';
updatedAt: string;
createdAt: string;
type: string;
homeProject?: ProjectSharingData;
scopes?: Scope[];
sharedWithProjects?: ProjectSharingData[];
readOnly: boolean;
needsSetup: boolean;
};
export type Resource = WorkflowResource | FolderResource | CredentialsResource | VariableResource;
export type BaseFilters = {
search: string;
homeProject: string;
[key: string]: boolean | string | string[];
};
type ResourceKeyType = 'credentials' | 'workflows' | 'variables';
const route = useRoute();
const router = useRouter();
const i18n = useI18n();
@ -106,7 +133,7 @@ const emit = defineEmits<{
'update:search': [value: string];
}>();
defineSlots<{
const slots = defineSlots<{
header(): unknown;
empty(): unknown;
preamble(): unknown;
@ -120,6 +147,7 @@ defineSlots<{
// eslint-disable-next-line @typescript-eslint/no-explicit-any
default(props: { data: any; updateItemSize: (data: any) => void }): unknown;
item(props: { item: unknown; index: number }): unknown;
breadcrumbs(): unknown;
}>();
//computed
@ -130,6 +158,7 @@ const filtersModel = computed({
const showEmptyState = computed(() => {
return (
route.params.folderId === undefined &&
props.resources.length === 0 &&
// Don't show empty state if resources are refreshing or if filters are being set
!hasFilters.value &&
@ -149,7 +178,7 @@ const filteredAndSortedResources = computed(() => {
const filtered = props.resources.filter((resource) => {
let matches = true;
if (filtersModel.value.homeProject) {
if (filtersModel.value.homeProject && isSharedResource(resource)) {
matches =
matches &&
!!(resource.homeProject && resource.homeProject.id === filtersModel.value.homeProject);
@ -168,12 +197,19 @@ const filteredAndSortedResources = computed(() => {
});
return filtered.sort((a, b) => {
const sortableByDate = isResourceSortableByDate(a) && isResourceSortableByDate(b);
switch (sortBy.value) {
case 'lastUpdated':
if (!sortableByDate) {
return 0;
}
return props.sortFns.lastUpdated
? props.sortFns.lastUpdated(a, b)
: new Date(b.updatedAt ?? '').valueOf() - new Date(a.updatedAt ?? '').valueOf();
case 'lastCreated':
if (!sortableByDate) {
return 0;
}
return props.sortFns.lastCreated
? props.sortFns.lastCreated(a, b)
: new Date(b.createdAt ?? '').valueOf() - new Date(a.createdAt ?? '').valueOf();
@ -494,6 +530,7 @@ const loadPaginationFromQueryString = async () => {
<template #header>
<div :class="$style['filters-row']">
<div :class="$style.filters">
<slot name="breadcrumbs"></slot>
<n8n-input
ref="search"
:model-value="filtersModel.search"
@ -507,19 +544,6 @@ const loadPaginationFromQueryString = async () => {
<n8n-icon icon="search" />
</template>
</n8n-input>
<ResourceFiltersDropdown
v-if="showFiltersDropdown"
:keys="filterKeys"
:reset="resetFilters"
:model-value="filtersModel"
:shareable="shareable"
@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']">
<n8n-select v-model="sortBy" data-test-id="resources-list-sort">
<n8n-option
@ -531,6 +555,20 @@ 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>
<slot name="add-button"></slot>
</div>
@ -630,14 +668,16 @@ const loadPaginationFromQueryString = async () => {
align-items: center;
justify-content: space-between;
width: 100%;
gap: var(--spacing-2xs);
}
.filters {
display: grid;
grid-auto-flow: column;
grid-auto-columns: max-content;
grid-auto-columns: 1fr max-content max-content max-content;
gap: var(--spacing-2xs);
align-items: center;
justify-content: end;
width: 100%;
@include mixins.breakpoint('xs-only') {
@ -651,7 +691,7 @@ const loadPaginationFromQueryString = async () => {
}
.search {
max-width: 240px;
// max-width: 240px;
@include mixins.breakpoint('sm-and-down') {
max-width: 100%;

View file

@ -519,6 +519,8 @@ export const enum VIEWS {
PROJECTS_CREDENTIALS = 'ProjectsCredentials',
PROJECT_SETTINGS = 'ProjectSettings',
PROJECTS_EXECUTIONS = 'ProjectsExecutions',
FOLDERS = 'Folders',
PROJECTS_FOLDERS = 'ProjectsFolders',
}
export const EDITABLE_CANVAS_VIEWS = [VIEWS.WORKFLOW, VIEWS.NEW_WORKFLOW, VIEWS.EXECUTION_DEBUG];

View file

@ -27,6 +27,7 @@ describe('permissions', () => {
variable: {},
workersView: {},
workflow: {},
folder: {},
});
});
it('getResourcePermissions', () => {
@ -115,6 +116,7 @@ describe('permissions', () => {
share: true,
update: true,
},
folder: {},
};
expect(getResourcePermissions(scopes)).toEqual(permissionRecord);

View file

@ -85,6 +85,7 @@
"generic.variable_plural": "Variables",
"generic.variable": "Variable | {count} Variables",
"generic.viewDocs": "View docs",
"generic.workflows": "Workflows",
"about.aboutN8n": "About n8n",
"about.close": "Close",
"about.license": "License",
@ -2329,7 +2330,7 @@
"workflows.item.updated": "Last updated",
"workflows.item.created": "Created",
"workflows.item.readonly": "Read only",
"workflows.search.placeholder": "Search workflows...",
"workflows.search.placeholder": "Search",
"workflows.filters": "Filters",
"workflows.filters.tags": "Tags",
"workflows.filters.status": "Status",

View file

@ -71,6 +71,7 @@ import {
faFilter,
faFingerprint,
faFlask,
faFolder,
faFolderOpen,
faFont,
faGlobeAmericas,
@ -252,6 +253,7 @@ export const FontAwesomePlugin: Plugin = {
addIcon(faFilter);
addIcon(faFingerprint);
addIcon(faFlask);
addIcon(faFolder);
addIcon(faFolderOpen);
addIcon(faFont);
addIcon(faGift);

View file

@ -58,6 +58,19 @@ const commonChildRoutes: RouteRecordRaw[] = [
},
},
},
{
path: 'folders/:folderId?/workflows',
components: {
default: WorkflowsView,
sidebar: MainSidebar,
},
meta: {
middleware: ['authenticated', 'custom'],
middlewareOptions: {
custom: (options) => checkProjectAvailability(options?.to),
},
},
},
];
const commonChildRouteExtensions = {
@ -71,6 +84,9 @@ const commonChildRouteExtensions = {
{
name: VIEWS.EXECUTIONS,
},
{
name: VIEWS.FOLDERS,
},
],
projects: [
{
@ -82,6 +98,9 @@ const commonChildRouteExtensions = {
{
name: VIEWS.PROJECTS_EXECUTIONS,
},
{
name: VIEWS.PROJECTS_FOLDERS,
},
],
};

View file

@ -34,6 +34,7 @@ export const useRBACStore = defineStore(STORES.RBAC, () => {
logStreaming: {},
saml: {},
securityAudit: {},
folder: {},
});
function addGlobalRole(role: IRole) {

View file

@ -31,6 +31,7 @@ import type {
IExecutionFlattedResponse,
IWorkflowTemplateNode,
IWorkflowDataCreate,
WorkflowListResource,
} from '@/Interface';
import { defineStore } from 'pinia';
import type {
@ -483,8 +484,9 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
page = 1,
pageSize = DEFAULT_WORKFLOW_PAGE_SIZE,
sortBy?: string,
filters: { name?: string; tags?: string[]; active?: boolean } = {},
): Promise<IWorkflowDb[]> {
filters: { name?: string; tags?: string[]; active?: boolean; parentFolderId?: string } = {},
includeFolders: boolean = false,
): Promise<WorkflowListResource[]> {
const filter = { ...filters, projectId };
const options = {
skip: (page - 1) * pageSize,
@ -492,13 +494,13 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
sortBy,
};
const { count, data } = await workflowsApi.getWorkflows(
const { count, data } = await workflowsApi.getWorkflowsAndFolders(
rootStore.restApiContext,
Object.keys(filter).length ? filter : undefined,
Object.keys(options).length ? options : undefined,
includeFolders ? includeFolders : undefined,
);
setWorkflows(data);
totalWorkflowCount.value = count;
return data;
}

View file

@ -11,6 +11,13 @@ import type { RouteLocationRaw } from 'vue-router';
import type { CanvasConnectionMode } from '@/types';
import { canvasConnectionModes } from '@/types';
import type { ComponentPublicInstance } from 'vue';
import type {
CredentialsResource,
FolderResource,
Resource,
VariableResource,
WorkflowResource,
} from '@/components/layouts/ResourcesListLayout.vue';
/*
Type guards used in editor-ui project
@ -98,3 +105,31 @@ export function isRouteLocationRaw(value: unknown): value is RouteLocationRaw {
export function isComponentPublicInstance(value: unknown): value is ComponentPublicInstance {
return value !== null && typeof value === 'object' && '$props' in value;
}
export function isWorkflowResource(value: Resource): value is WorkflowResource {
return value.resourceType === 'workflow';
}
export function isFolderResource(value: Resource): value is FolderResource {
return value.resourceType === 'folder';
}
export function isVariableResource(value: Resource): value is VariableResource {
return value.resourceType === 'variable';
}
export function isCredentialsResource(value: Resource): value is CredentialsResource {
return value.resourceType === 'credential';
}
export function isSharedResource(
value: Resource,
): value is WorkflowResource | FolderResource | CredentialsResource {
return isWorkflowResource(value) || isFolderResource(value) || isCredentialsResource(value);
}
export function isResourceSortableByDate(
value: Resource,
): value is WorkflowResource | FolderResource | CredentialsResource {
return isWorkflowResource(value) || isFolderResource(value) || isCredentialsResource(value);
}

View file

@ -1,7 +1,7 @@
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue';
import { useRoute, useRouter, type LocationQueryRaw } from 'vue-router';
import type { ICredentialsResponse, ICredentialTypeMap } from '@/Interface';
import type { ICredentialTypeMap } from '@/Interface';
import type { ICredentialType, ICredentialsDecrypted } from 'n8n-workflow';
import ResourcesListLayout, {
type Resource,
@ -31,6 +31,7 @@ import ProjectHeader from '@/components/Projects/ProjectHeader.vue';
import { N8nCheckbox } from 'n8n-design-system';
import { pickBy } from 'lodash-es';
import { CREDENTIAL_EMPTY_VALUE } from 'n8n-workflow';
import { isCredentialsResource } from '@/utils/typeGuards';
const props = defineProps<{
credentialId?: string;
@ -76,6 +77,7 @@ const needsSetup = (data: string | undefined): boolean => {
const allCredentials = computed<Resource[]>(() =>
credentialsStore.allCredentials.map((credential) => ({
resourceType: 'credential',
id: credential.id,
name: credential.name,
value: '',
@ -83,10 +85,10 @@ const allCredentials = computed<Resource[]>(() =>
createdAt: credential.createdAt,
homeProject: credential.homeProject,
scopes: credential.scopes,
type: credential.type,
sharedWithProjects: credential.sharedWithProjects,
readOnly: !getResourcePermissions(credential.scopes).credential.update,
needsSetup: needsSetup(credential.data),
type: credential.type,
})),
);
@ -125,10 +127,10 @@ listenForModalChanges({
});
const onFilter = (resource: Resource, newFilters: BaseFilters, matches: boolean): boolean => {
const Resource = resource as ICredentialsResponse & { needsSetup: boolean };
if (!isCredentialsResource(resource)) return false;
const filtersToApply = newFilters as Filters;
if (filtersToApply.type && filtersToApply.type.length > 0) {
matches = matches && filtersToApply.type.includes(Resource.type);
matches = matches && filtersToApply.type.includes(resource.type);
}
if (filtersToApply.search) {
@ -136,12 +138,12 @@ const onFilter = (resource: Resource, newFilters: BaseFilters, matches: boolean)
matches =
matches ||
(credentialTypesById.value[Resource.type] &&
credentialTypesById.value[Resource.type].displayName.toLowerCase().includes(searchString));
(credentialTypesById.value[resource.type] &&
credentialTypesById.value[resource.type].displayName.toLowerCase().includes(searchString));
}
if (filtersToApply.setupNeeded) {
matches = matches && Resource.needsSetup;
matches = matches && resource.needsSetup;
}
return matches;

View file

@ -17,6 +17,7 @@ import VariablesUsageBadge from '@/components/VariablesUsageBadge.vue';
import ResourcesListLayout, {
type Resource,
type BaseFilters,
type VariableResource,
} from '@/components/layouts/ResourcesListLayout.vue';
import { EnterpriseEditionFeature, MODAL_CONFIRM } from '@/constants';
@ -85,7 +86,18 @@ const addEmptyVariableForm = () => {
telemetry.track('User clicked add variable button');
};
const variables = computed(() => [...variableForms.value.values(), ...environmentsStore.variables]);
const variables = computed<VariableResource[]>(() =>
[...variableForms.value.values(), ...environmentsStore.variables].map(
(variable) =>
({
resourceType: 'variable',
id: variable.id,
name: variable.key,
key: variable.key,
value: variable.value,
}) as VariableResource,
),
);
const canCreateVariables = computed(() => isFeatureEnabled.value && permissions.value.create);
@ -118,11 +130,18 @@ const columns = computed(() => {
const handleSubmit = async (variable: EnvironmentVariable) => {
try {
const { id, ...rest } = variable;
const { id } = variable;
if (id.startsWith(TEMPORARY_VARIABLE_UID_BASE)) {
await environmentsStore.createVariable(rest);
await environmentsStore.createVariable({
value: variable.value,
key: variable.key,
});
} else {
await environmentsStore.updateVariable(variable);
await environmentsStore.updateVariable({
id: variable.id,
value: variable.value,
key: variable.key,
});
}
removeEditableVariable(id);
} catch (error) {
@ -147,7 +166,11 @@ const handleDeleteVariable = async (variable: EnvironmentVariable) => {
return;
}
await environmentsStore.deleteVariable(variable);
await environmentsStore.deleteVariable({
id: variable.id,
value: variable.value,
key: variable.key,
});
removeEditableVariable(variable.id);
} catch (error) {
showError(error, i18n.baseText('variables.errors.delete'));

View file

@ -7,7 +7,7 @@ import { useProjectsStore } from '@/stores/projects.store';
import { createTestingPinia } from '@pinia/testing';
import { STORES, VIEWS } from '@/constants';
import { mockedStore, waitAllPromises } from '@/__tests__/utils';
import type { IUser } from '@/Interface';
import type { IUser, WorkflowListResource } from '@/Interface';
import { useSourceControlStore } from '@/stores/sourceControl.store';
import type { Project } from '@/types/projects.types';
import { useWorkflowsStore } from '@/stores/workflows.store';
@ -48,7 +48,7 @@ const renderComponent = createComponentRenderer(WorkflowsView, {
});
const initialState = {
[STORES.SETTINGS]: { settings: { enterprise: { sharing: false } } },
[STORES.SETTINGS]: { settings: { enterprise: { sharing: false }, folders: { enabled: false } } },
};
describe('WorkflowsView', () => {
@ -173,6 +173,7 @@ describe('WorkflowsView', () => {
expect.objectContaining({
tags: [TEST_TAG.name],
}),
expect.any(Boolean),
);
});
@ -193,6 +194,7 @@ describe('WorkflowsView', () => {
expect.objectContaining({
name: 'one',
}),
expect.any(Boolean),
);
});
@ -213,6 +215,7 @@ describe('WorkflowsView', () => {
expect.objectContaining({
active: true,
}),
expect.any(Boolean),
);
});
@ -271,4 +274,64 @@ describe('WorkflowsView', () => {
await sourceControl.pullWorkfolder(true);
expect(userStore.fetchUsers).toHaveBeenCalledTimes(2);
});
it('should render workflow and folder cards', async () => {
const TEST_WORKFLOW_RESOURCE: WorkflowListResource = {
resource: 'workflow',
id: '1',
name: 'Workflow 1',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
active: true,
homeProject: {
id: '1',
name: 'Project 1',
icon: null,
type: 'team',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
};
const TEST_FOLDER_RESOURCE: WorkflowListResource = {
resource: 'folder',
id: '2',
name: 'Folder 2',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
workflowCount: 1,
homeProject: {
id: '1',
name: 'Project 1',
icon: null,
type: 'team',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
};
// mock router resolve:
router.resolve = vi.fn().mockResolvedValue({
href: '/projects/1/folders/1',
});
const workflowsStore = mockedStore(useWorkflowsStore);
workflowsStore.fetchWorkflowsPage.mockResolvedValue([
TEST_WORKFLOW_RESOURCE,
TEST_FOLDER_RESOURCE,
]);
workflowsStore.fetchActiveWorkflows.mockResolvedValue([]);
const { getByTestId } = renderComponent({
pinia,
global: {
stubs: {
'router-link': {
template: '<div data-test-id="folder-card"><slot /></div>',
},
},
},
});
await waitAllPromises();
expect(getByTestId('resources-list-wrapper')).toBeInTheDocument();
expect(getByTestId('resources-list-wrapper').querySelectorAll('.listItem')).toHaveLength(2);
expect(getByTestId('workflow-card-name')).toHaveTextContent(TEST_WORKFLOW_RESOURCE.name);
expect(getByTestId('folder-card-name')).toHaveTextContent(TEST_FOLDER_RESOURCE.name);
});
});

View file

@ -1,8 +1,11 @@
<script lang="ts" setup>
import { computed, onMounted, watch, ref, onBeforeUnmount } from 'vue';
import ResourcesListLayout, {
type Resource,
type BaseFilters,
import ResourcesListLayout from '@/components/layouts/ResourcesListLayout.vue';
import type {
Resource,
BaseFilters,
FolderResource,
WorkflowResource,
} from '@/components/layouts/ResourcesListLayout.vue';
import WorkflowCard from '@/components/WorkflowCard.vue';
import WorkflowTagsDropdown from '@/components/WorkflowTagsDropdown.vue';
@ -13,7 +16,7 @@ import {
VIEWS,
DEFAULT_WORKFLOW_PAGE_SIZE,
} from '@/constants';
import type { IUser, IWorkflowDb } from '@/Interface';
import type { IUser, UserAction, WorkflowListResource, WorkflowListItem } from '@/Interface';
import { useUIStore } from '@/stores/ui.store';
import { useSettingsStore } from '@/stores/settings.store';
import { useUsersStore } from '@/stores/users.store';
@ -40,6 +43,9 @@ import ProjectHeader from '@/components/Projects/ProjectHeader.vue';
import { getEasyAiWorkflowJson } from '@/utils/easyAiWorkflowUtils';
import { useDebounce } from '@/composables/useDebounce';
import { createEventBus } from 'n8n-design-system/utils';
import type { PathItem } from 'n8n-design-system/components/N8nBreadcrumbs/Breadcrumbs.vue';
import { ProjectTypes } from '@/types/projects.types';
import { FOLDER_LIST_ITEM_ACTIONS } from '@/components/Folders/constants';
import { debounce } from 'lodash-es';
interface Filters extends BaseFilters {
@ -87,36 +93,122 @@ const filters = ref<Filters>({
const workflowListEventBus = createEventBus();
const workflows = ref<IWorkflowDb[]>([]);
const workflowsAndFolders = ref<WorkflowListResource[]>([]);
const easyAICalloutVisible = ref(true);
const currentFolder = ref<FolderResource | undefined>(undefined);
const currentPage = ref(1);
const pageSize = ref(DEFAULT_WORKFLOW_PAGE_SIZE);
const currentSort = ref('updatedAt:desc');
const folderCardActions = ref<UserAction[]>([
{
label: 'Open',
value: FOLDER_LIST_ITEM_ACTIONS.OPEN,
disabled: false,
},
{
label: 'Create Folder',
value: FOLDER_LIST_ITEM_ACTIONS.CREATE,
disabled: true,
},
{
label: 'Create Workflow',
value: FOLDER_LIST_ITEM_ACTIONS.CREATE_WORKFLOW,
disabled: true,
},
{
label: 'Rename',
value: FOLDER_LIST_ITEM_ACTIONS.RENAME,
disabled: true,
},
{
label: 'Move to Folder',
value: FOLDER_LIST_ITEM_ACTIONS.MOVE,
disabled: true,
},
{
label: 'Change Owner',
value: FOLDER_LIST_ITEM_ACTIONS.CHOWN,
disabled: true,
},
{
label: 'Manage Tags',
value: FOLDER_LIST_ITEM_ACTIONS.TAGS,
disabled: true,
},
{
label: 'Delete',
value: FOLDER_LIST_ITEM_ACTIONS.DELETE,
disabled: true,
},
]);
const readOnlyEnv = computed(() => sourceControlStore.preferences.branchReadOnly);
const foldersEnabled = computed(() => settingsStore.settings.folders.enabled);
const isOverviewPage = computed(() => route.name === VIEWS.WORKFLOWS);
const currentUser = computed(() => usersStore.currentUser ?? ({} as IUser));
const isShareable = computed(
() => settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.Sharing],
);
const workflowResources = computed<Resource[]>(() =>
workflows.value.map((workflow) => ({
id: workflow.id,
name: workflow.name,
value: '',
active: workflow.active,
updatedAt: workflow.updatedAt.toString(),
createdAt: workflow.createdAt.toString(),
homeProject: workflow.homeProject,
scopes: workflow.scopes,
type: 'workflow',
sharedWithProjects: workflow.sharedWithProjects,
readOnly: !getResourcePermissions(workflow.scopes).workflow.update,
tags: workflow.tags,
})),
);
const showFolders = computed(() => foldersEnabled.value && !isOverviewPage.value);
const mainBreadcrumbsItems = computed<PathItem[] | undefined>(() => {
if (!showFolders.value || !currentFolder.value) return;
const items: PathItem[] = [];
items.push({
id: currentFolder.value.id,
label: currentFolder.value.name,
});
return items;
});
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 workflowListResources = computed<Resource[]>(() => {
const resources: Resource[] = (workflowsAndFolders.value || []).map((resource) => {
if (resource.resource === 'folder') {
return {
resourceType: 'folder',
id: resource.id,
name: resource.name,
createdAt: resource.createdAt.toString(),
updatedAt: resource.updatedAt.toString(),
homeProject: resource.homeProject,
sharedWithProjects: resource.sharedWithProjects,
workflowCount: resource.workflowCount,
parentFolder: resource.parentFolder,
} as FolderResource;
} else {
// TODO: Once new endpoint is in place, we'll have to explicitly check for resource type
return {
resourceType: 'workflow',
id: resource.id,
name: resource.name,
active: resource.active ?? false,
updatedAt: resource.updatedAt.toString(),
createdAt: resource.createdAt.toString(),
homeProject: resource.homeProject,
scopes: resource.scopes,
sharedWithProjects: resource.sharedWithProjects,
readOnly: !getResourcePermissions(resource.scopes).workflow.update,
tags: resource.tags,
parentFolder: resource.parentFolder,
} as WorkflowResource;
}
});
return resources;
});
const statusFilterOptions = computed(() => [
{
@ -164,6 +256,16 @@ watch(
},
);
watch(
() => route.params?.folderId,
async (newVal) => {
if (!newVal) {
currentFolder.value = undefined;
}
await fetchWorkflows();
},
);
// Lifecycle hooks
onMounted(async () => {
documentTitle.set(i18n.baseText('workflows.heading'));
@ -217,13 +319,14 @@ const trackEmptyCardClick = (option: 'blank' | 'templates' | 'courses') => {
const initialize = async () => {
loading.value = true;
currentFolder.value = undefined;
await setFiltersFromQueryString();
const [, workflowsPage] = await Promise.all([
const [, resourcesPage] = await Promise.all([
usersStore.fetchUsers(),
fetchWorkflows(),
workflowsStore.fetchActiveWorkflows(),
]);
workflows.value = workflowsPage;
workflowsAndFolders.value = resourcesPage;
loading.value = false;
};
@ -245,8 +348,9 @@ const fetchWorkflows = async () => {
}, 300);
const routeProjectId = route.params?.projectId as string | undefined;
const homeProjectFilter = filters.value.homeProject || undefined;
const parentFolder = (route.params?.folderId as string) || '0';
const fetchedWorkflows = await workflowsStore.fetchWorkflowsPage(
const fetchedResources = await workflowsStore.fetchWorkflowsPage(
routeProjectId ?? homeProjectFilter,
currentPage.value,
pageSize.value,
@ -255,12 +359,17 @@ const fetchWorkflows = async () => {
name: filters.value.search || undefined,
active: filters.value.status ? Boolean(filters.value.status) : undefined,
tags: filters.value.tags.map((tagId) => tagsStore.tagsById[tagId]?.name),
parentFolderId: parentFolder,
},
showFolders.value,
);
// @ts-expect-error - Once we have an endpoint to fetch the path based on Id, we should remove this and fetch the path from the endpoint
currentFolder.value = fetchedResources[0]?.parentFolder;
delayedLoading.cancel();
workflows.value = fetchedWorkflows;
workflowsAndFolders.value = fetchedResources;
loading.value = false;
return fetchedWorkflows;
return fetchedResources;
};
const onClickTag = async (tagId: string) => {
@ -423,10 +532,16 @@ const onSortUpdated = async (sort: string) => {
};
const onWorkflowActiveToggle = (data: { id: string; active: boolean }) => {
const workflow = workflows.value.find((w) => w.id === data.id);
const workflow: WorkflowListItem | undefined = workflowsAndFolders.value.find(
(w): w is WorkflowListItem => w.id === data.id,
);
if (!workflow) return;
workflow.active = data.active;
};
const onFolderOpened = (data: { folder: FolderResource }) => {
currentFolder.value = data.folder;
};
</script>
<template>
@ -434,7 +549,7 @@ const onWorkflowActiveToggle = (data: { id: string; active: boolean }) => {
v-model:filters="filters"
resource-key="workflows"
type="list-paginated"
:resources="workflowResources"
:resources="workflowListResources"
:type-props="{ itemSize: 80 }"
:shareable="isShareable"
:initialize="initialize"
@ -483,11 +598,36 @@ const onWorkflowActiveToggle = (data: { id: string; active: boolean }) => {
</template>
</N8nCallout>
</template>
<template #breadcrumbs>
<n8n-breadcrumbs
v-if="mainBreadcrumbsItems"
:items="mainBreadcrumbsItems"
:highlight-last-item="false"
:path-truncated="currentFolder !== undefined"
data-test-id="folder-card-breadcrumbs"
>
<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>
</template>
<template #item="{ item: data }">
<FolderCard
v-if="(data as FolderResource | WorkflowResource).resourceType === 'folder'"
:data="data as FolderResource"
:actions="folderCardActions"
class="mb-2xs"
@folder-opened="onFolderOpened"
/>
<WorkflowCard
v-else
data-test-id="resources-list-item"
class="mb-2xs"
:data="data as IWorkflowDb"
:data="data as WorkflowResource"
:workflow-list-event-bus="workflowListEventBus"
:read-only="readOnlyEnv"
@click:tag="onClickTag"
@ -629,4 +769,9 @@ const onWorkflowActiveToggle = (data: { id: string; active: boolean }) => {
transition: color 0.3s ease;
}
}
.home-project {
display: flex;
align-items: center;
}
</style>