mirror of
https://github.com/n8n-io/n8n.git
synced 2025-03-05 20:50:17 -08:00
feat(editor): Implement folder navigation in workflows list (#13370)
This commit is contained in:
parent
f2b15ea086
commit
0eae14e27a
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
|
|
|
@ -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 = [
|
||||
|
|
204
packages/editor-ui/src/components/Folders/FolderCard.test.ts
Normal file
204
packages/editor-ui/src/components/Folders/FolderCard.test.ts
Normal 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 }]]);
|
||||
});
|
||||
});
|
217
packages/editor-ui/src/components/Folders/FolderCard.vue
Normal file
217
packages/editor-ui/src/components/Folders/FolderCard.vue
Normal 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>
|
10
packages/editor-ui/src/components/Folders/constants.ts
Normal file
10
packages/editor-ui/src/components/Folders/constants.ts
Normal 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',
|
||||
};
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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 },
|
||||
);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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%;
|
||||
|
|
|
@ -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];
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
|
|
|
@ -34,6 +34,7 @@ export const useRBACStore = defineStore(STORES.RBAC, () => {
|
|||
logStreaming: {},
|
||||
saml: {},
|
||||
securityAudit: {},
|
||||
folder: {},
|
||||
});
|
||||
|
||||
function addGlobalRole(role: IRole) {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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'));
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
|
|
Loading…
Reference in a new issue