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,
|
variable: [...DEFAULT_OPERATIONS] as const,
|
||||||
workersView: ['manage'] as const,
|
workersView: ['manage'] as const,
|
||||||
workflow: ['share', 'execute', 'move', ...DEFAULT_OPERATIONS] as const,
|
workflow: ['share', 'execute', 'move', ...DEFAULT_OPERATIONS] as const,
|
||||||
|
folder: ['create', 'read'] as const,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
|
@ -53,7 +53,7 @@ const hasHiddenItems = computed(() => {
|
||||||
});
|
});
|
||||||
|
|
||||||
const showEllipsis = computed(() => {
|
const showEllipsis = computed(() => {
|
||||||
return hasHiddenItems.value || props.pathTruncated;
|
return props.items.length && (hasHiddenItems.value || props.pathTruncated);
|
||||||
});
|
});
|
||||||
|
|
||||||
const dropdownDisabled = computed(() => {
|
const dropdownDisabled = computed(() => {
|
||||||
|
@ -137,7 +137,9 @@ const handleTooltipClose = () => {
|
||||||
>
|
>
|
||||||
<slot name="prepend"></slot>
|
<slot name="prepend"></slot>
|
||||||
<ul :class="$style.list">
|
<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
|
<li
|
||||||
v-if="showEllipsis"
|
v-if="showEllipsis"
|
||||||
:class="{ [$style.ellipsis]: true, [$style.disabled]: dropdownDisabled }"
|
:class="{ [$style.ellipsis]: true, [$style.disabled]: dropdownDisabled }"
|
||||||
|
@ -218,7 +220,7 @@ const handleTooltipClose = () => {
|
||||||
|
|
||||||
&.small {
|
&.small {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
padding: var(--spacing-4xs) var(--spacing-2xs);
|
padding: var(--spacing-4xs) var(--spacing-3xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.border {
|
&.border {
|
||||||
|
@ -242,6 +244,7 @@ const handleTooltipClose = () => {
|
||||||
.tooltip-ellipsis {
|
.tooltip-ellipsis {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
color: var(--color-text-base);
|
||||||
}
|
}
|
||||||
&.disabled {
|
&.disabled {
|
||||||
.dots,
|
.dots,
|
||||||
|
|
|
@ -316,6 +316,41 @@ export interface IWorkflowDb {
|
||||||
meta?: WorkflowMetadata;
|
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
|
// Identical to cli.Interfaces.ts
|
||||||
export interface IWorkflowShortResponse {
|
export interface IWorkflowShortResponse {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
|
@ -4,6 +4,7 @@ import type {
|
||||||
IRestApiContext,
|
IRestApiContext,
|
||||||
IWorkflowDb,
|
IWorkflowDb,
|
||||||
NewWorkflowResponse,
|
NewWorkflowResponse,
|
||||||
|
WorkflowListResource,
|
||||||
} from '@/Interface';
|
} from '@/Interface';
|
||||||
import type {
|
import type {
|
||||||
ExecutionFilters,
|
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) {
|
export async function getActiveWorkflows(context: IRestApiContext) {
|
||||||
return await makeRestApiRequest<string[]>(context, 'GET', '/active-workflows');
|
return await makeRestApiRequest<string[]>(context, 'GET', '/active-workflows');
|
||||||
}
|
}
|
||||||
|
|
|
@ -88,7 +88,8 @@ describe('CredentialCard', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should set readOnly variant based on prop', () => {
|
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');
|
const heading = getByRole('heading');
|
||||||
expect(heading).toHaveTextContent('Read only');
|
expect(heading).toHaveTextContent('Read only');
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import dateformat from 'dateformat';
|
import dateformat from 'dateformat';
|
||||||
import type { ICredentialsResponse } from '@/Interface';
|
|
||||||
import { MODAL_CONFIRM, PROJECT_MOVE_RESOURCE_MODAL } from '@/constants';
|
import { MODAL_CONFIRM, PROJECT_MOVE_RESOURCE_MODAL } from '@/constants';
|
||||||
import { useMessage } from '@/composables/useMessage';
|
import { useMessage } from '@/composables/useMessage';
|
||||||
import CredentialIcon from '@/components/CredentialIcon.vue';
|
import CredentialIcon from '@/components/CredentialIcon.vue';
|
||||||
|
@ -9,11 +8,11 @@ import { getResourcePermissions } from '@/permissions';
|
||||||
import { useUIStore } from '@/stores/ui.store';
|
import { useUIStore } from '@/stores/ui.store';
|
||||||
import { useCredentialsStore } from '@/stores/credentials.store';
|
import { useCredentialsStore } from '@/stores/credentials.store';
|
||||||
import TimeAgo from '@/components/TimeAgo.vue';
|
import TimeAgo from '@/components/TimeAgo.vue';
|
||||||
import type { ProjectSharingData } from '@/types/projects.types';
|
|
||||||
import { useProjectsStore } from '@/stores/projects.store';
|
import { useProjectsStore } from '@/stores/projects.store';
|
||||||
import ProjectCardBadge from '@/components/Projects/ProjectCardBadge.vue';
|
import ProjectCardBadge from '@/components/Projects/ProjectCardBadge.vue';
|
||||||
import { useI18n } from '@/composables/useI18n';
|
import { useI18n } from '@/composables/useI18n';
|
||||||
import { ResourceType } from '@/utils/projects.utils';
|
import { ResourceType } from '@/utils/projects.utils';
|
||||||
|
import type { CredentialsResource } from './layouts/ResourcesListLayout.vue';
|
||||||
|
|
||||||
const CREDENTIAL_LIST_ITEM_ACTIONS = {
|
const CREDENTIAL_LIST_ITEM_ACTIONS = {
|
||||||
OPEN: 'open',
|
OPEN: 'open',
|
||||||
|
@ -27,22 +26,13 @@ const emit = defineEmits<{
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
data: ICredentialsResponse;
|
data: CredentialsResource;
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
needsSetup?: boolean;
|
needsSetup?: boolean;
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
data: () => ({
|
|
||||||
id: '',
|
|
||||||
createdAt: '',
|
|
||||||
updatedAt: '',
|
|
||||||
type: '',
|
|
||||||
name: '',
|
|
||||||
sharedWithProjects: [],
|
|
||||||
homeProject: {} as ProjectSharingData,
|
|
||||||
isManaged: false,
|
|
||||||
}),
|
|
||||||
readOnly: false,
|
readOnly: false,
|
||||||
|
needsSetup: false,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -53,7 +43,9 @@ const credentialsStore = useCredentialsStore();
|
||||||
const projectsStore = useProjectsStore();
|
const projectsStore = useProjectsStore();
|
||||||
|
|
||||||
const resourceTypeLabel = computed(() => locale.baseText('generic.credential').toLowerCase());
|
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 credentialPermissions = computed(() => getResourcePermissions(props.data.scopes).credential);
|
||||||
const actions = computed(() => {
|
const actions = computed(() => {
|
||||||
const items = [
|
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 { useI18n } from '@/composables/useI18n';
|
||||||
import type { ResourceType } from '@/utils/projects.utils';
|
import type { ResourceType } from '@/utils/projects.utils';
|
||||||
import { splitName } from '@/utils/projects.utils';
|
import { splitName } from '@/utils/projects.utils';
|
||||||
import type { ICredentialsResponse, IWorkflowDb } from '@/Interface';
|
import type { Project, ProjectIcon as BadgeIcon } from '@/types/projects.types';
|
||||||
import type { Project } from '@/types/projects.types';
|
|
||||||
import { ProjectTypes } from '@/types/projects.types';
|
import { ProjectTypes } from '@/types/projects.types';
|
||||||
|
import type { CredentialsResource, WorkflowResource } from '../layouts/ResourcesListLayout.vue';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
resource: IWorkflowDb | ICredentialsResponse;
|
resource: WorkflowResource | CredentialsResource;
|
||||||
resourceType: ResourceType;
|
resourceType: ResourceType;
|
||||||
resourceTypeLabel: string;
|
resourceTypeLabel: string;
|
||||||
personalProject: Project | null;
|
personalProject: Project | null;
|
||||||
|
@ -68,16 +68,16 @@ const badgeText = computed(() => {
|
||||||
return name ?? email ?? '';
|
return name ?? email ?? '';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
const badgeIcon = computed(() => {
|
const badgeIcon = computed<BadgeIcon>(() => {
|
||||||
switch (projectState.value) {
|
switch (projectState.value) {
|
||||||
case ProjectState.Owned:
|
case ProjectState.Owned:
|
||||||
case ProjectState.SharedOwned:
|
case ProjectState.SharedOwned:
|
||||||
return 'user';
|
return { type: 'icon', value: 'user' };
|
||||||
case ProjectState.Team:
|
case ProjectState.Team:
|
||||||
case ProjectState.SharedTeam:
|
case ProjectState.SharedTeam:
|
||||||
return 'layer-group';
|
return props.resource.homeProject?.icon ?? { type: 'icon', value: 'layer-group' };
|
||||||
default:
|
default:
|
||||||
return '';
|
return { type: 'icon', value: 'layer-group' };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
const badgeTooltip = computed(() => {
|
const badgeTooltip = computed(() => {
|
||||||
|
@ -129,12 +129,12 @@ const badgeTooltip = computed(() => {
|
||||||
<div :class="$style.wrapper" v-bind="$attrs">
|
<div :class="$style.wrapper" v-bind="$attrs">
|
||||||
<N8nBadge
|
<N8nBadge
|
||||||
v-if="badgeText"
|
v-if="badgeText"
|
||||||
:class="$style.badge"
|
:class="[$style.badge, $style.projectBadge]"
|
||||||
theme="tertiary"
|
theme="tertiary"
|
||||||
bold
|
bold
|
||||||
data-test-id="card-badge"
|
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>
|
<span v-n8n-truncate:20>{{ badgeText }}</span>
|
||||||
</N8nBadge>
|
</N8nBadge>
|
||||||
<N8nBadge
|
<N8nBadge
|
||||||
|
@ -170,6 +170,13 @@ const badgeTooltip = computed(() => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.projectBadge {
|
||||||
|
& > span {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-3xs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.countBadge {
|
.countBadge {
|
||||||
margin-left: -5px;
|
margin-left: -5px;
|
||||||
z-index: 0;
|
z-index: 0;
|
||||||
|
|
|
@ -3,15 +3,17 @@ import type { ProjectIcon } from '@/types/projects.types';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
icon: ProjectIcon;
|
icon: ProjectIcon;
|
||||||
size?: 'small' | 'medium' | 'large';
|
size?: 'mini' | 'small' | 'medium' | 'large';
|
||||||
round?: boolean;
|
round?: boolean;
|
||||||
borderLess?: boolean;
|
borderLess?: boolean;
|
||||||
|
color?: 'text-light' | 'text-base' | 'text-dark';
|
||||||
};
|
};
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
size: 'medium',
|
size: 'medium',
|
||||||
round: false,
|
round: false,
|
||||||
borderLess: false,
|
borderLess: false,
|
||||||
|
color: 'text-base',
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
|
@ -23,9 +25,10 @@ const props = withDefaults(defineProps<Props>(), {
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
<N8nIcon
|
<N8nIcon
|
||||||
v-if="props.icon.type === 'icon'"
|
v-if="icon.type === 'icon'"
|
||||||
:icon="props.icon.value"
|
:icon="icon.value"
|
||||||
color="text-light"
|
:class="$style.icon"
|
||||||
|
:color="color"
|
||||||
></N8nIcon>
|
></N8nIcon>
|
||||||
<N8nText v-else-if="icon.type === 'emoji'" color="text-light" :class="$style.emoji">
|
<N8nText v-else-if="icon.type === 'emoji'" color="text-light" :class="$style.emoji">
|
||||||
{{ icon.value }}
|
{{ 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 {
|
.small {
|
||||||
min-width: var(--spacing-l);
|
min-width: var(--spacing-l);
|
||||||
height: var(--spacing-l);
|
height: var(--spacing-l);
|
||||||
|
|
|
@ -73,6 +73,9 @@ watch(
|
||||||
() => route?.name,
|
() => route?.name,
|
||||||
() => {
|
() => {
|
||||||
selectedTab.value = 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 },
|
{ immediate: true },
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import type { IWorkflowDb, IUser } from '@/Interface';
|
import type { IUser } from '@/Interface';
|
||||||
import {
|
import {
|
||||||
DUPLICATE_MODAL_KEY,
|
DUPLICATE_MODAL_KEY,
|
||||||
MODAL_CONFIRM,
|
MODAL_CONFIRM,
|
||||||
|
@ -18,7 +18,6 @@ import { useSettingsStore } from '@/stores/settings.store';
|
||||||
import { useUsersStore } from '@/stores/users.store';
|
import { useUsersStore } from '@/stores/users.store';
|
||||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||||
import TimeAgo from '@/components/TimeAgo.vue';
|
import TimeAgo from '@/components/TimeAgo.vue';
|
||||||
import type { ProjectSharingData } from '@/types/projects.types';
|
|
||||||
import { useProjectsStore } from '@/stores/projects.store';
|
import { useProjectsStore } from '@/stores/projects.store';
|
||||||
import ProjectCardBadge from '@/components/Projects/ProjectCardBadge.vue';
|
import ProjectCardBadge from '@/components/Projects/ProjectCardBadge.vue';
|
||||||
import { useI18n } from '@/composables/useI18n';
|
import { useI18n } from '@/composables/useI18n';
|
||||||
|
@ -26,6 +25,8 @@ import { useRouter } from 'vue-router';
|
||||||
import { useTelemetry } from '@/composables/useTelemetry';
|
import { useTelemetry } from '@/composables/useTelemetry';
|
||||||
import { ResourceType } from '@/utils/projects.utils';
|
import { ResourceType } from '@/utils/projects.utils';
|
||||||
import type { EventBus } from 'n8n-design-system/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 = {
|
const WORKFLOW_LIST_ITEM_ACTIONS = {
|
||||||
OPEN: 'open',
|
OPEN: 'open',
|
||||||
|
@ -37,23 +38,11 @@ const WORKFLOW_LIST_ITEM_ACTIONS = {
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
data: IWorkflowDb;
|
data: WorkflowResource;
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
workflowListEventBus?: EventBus;
|
workflowListEventBus?: EventBus;
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
data: () => ({
|
|
||||||
id: '',
|
|
||||||
createdAt: '',
|
|
||||||
updatedAt: '',
|
|
||||||
active: false,
|
|
||||||
connections: {},
|
|
||||||
nodes: [],
|
|
||||||
name: '',
|
|
||||||
sharedWithProjects: [],
|
|
||||||
homeProject: {} as ProjectSharingData,
|
|
||||||
versionId: '',
|
|
||||||
}),
|
|
||||||
readOnly: false,
|
readOnly: false,
|
||||||
workflowListEventBus: undefined,
|
workflowListEventBus: undefined,
|
||||||
},
|
},
|
||||||
|
@ -71,6 +60,7 @@ const message = useMessage();
|
||||||
const locale = useI18n();
|
const locale = useI18n();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const telemetry = useTelemetry();
|
const telemetry = useTelemetry();
|
||||||
|
const i18n = useI18n();
|
||||||
|
|
||||||
const settingsStore = useSettingsStore();
|
const settingsStore = useSettingsStore();
|
||||||
const uiStore = useUIStore();
|
const uiStore = useUIStore();
|
||||||
|
@ -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) {
|
async function onClick(event?: KeyboardEvent | PointerEvent) {
|
||||||
if (event?.ctrlKey || event?.metaKey) {
|
if (event?.ctrlKey || event?.metaKey) {
|
||||||
const route = router.resolve({
|
const route = router.resolve({
|
||||||
|
@ -244,7 +263,7 @@ const emitWorkflowActiveToggle = (value: { id: string; active: boolean }) => {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<n8n-card :class="$style.cardLink" @click="onClick">
|
<n8n-card :class="$style.cardLink" data-test-id="workflow-card" @click="onClick">
|
||||||
<template #header>
|
<template #header>
|
||||||
<n8n-heading tag="h2" bold :class="$style.cardHeading" data-test-id="workflow-card-name">
|
<n8n-heading tag="h2" bold :class="$style.cardHeading" data-test-id="workflow-card-name">
|
||||||
{{ data.name }}
|
{{ data.name }}
|
||||||
|
@ -280,12 +299,33 @@ const emitWorkflowActiveToggle = (value: { id: string; active: boolean }) => {
|
||||||
<template #append>
|
<template #append>
|
||||||
<div :class="$style.cardActions" @click.stop>
|
<div :class="$style.cardActions" @click.stop>
|
||||||
<ProjectCardBadge
|
<ProjectCardBadge
|
||||||
|
v-if="!data.parentFolder"
|
||||||
:class="$style.cardBadge"
|
:class="$style.cardBadge"
|
||||||
:resource="data"
|
:resource="data"
|
||||||
:resource-type="ResourceType.Workflow"
|
:resource-type="ResourceType.Workflow"
|
||||||
:resource-type-label="resourceTypeLabel"
|
:resource-type-label="resourceTypeLabel"
|
||||||
:personal-project="projectsStore.personalProject"
|
:personal-project="projectsStore.personalProject"
|
||||||
/>
|
/>
|
||||||
|
<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
|
<WorkflowActivator
|
||||||
class="mr-s"
|
class="mr-s"
|
||||||
:workflow-active="data.active"
|
:workflow-active="data.active"
|
||||||
|
@ -345,6 +385,13 @@ const emitWorkflowActiveToggle = (value: { id: string; active: boolean }) => {
|
||||||
cursor: default;
|
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') {
|
@include mixins.breakpoint('sm-and-down') {
|
||||||
.cardLink {
|
.cardLink {
|
||||||
--card--padding: 0 var(--spacing-s) var(--spacing-s);
|
--card--padding: 0 var(--spacing-s) var(--spacing-s);
|
||||||
|
|
|
@ -15,12 +15,14 @@ const props = withDefaults(
|
||||||
keys?: string[];
|
keys?: string[];
|
||||||
shareable?: boolean;
|
shareable?: boolean;
|
||||||
reset?: () => void;
|
reset?: () => void;
|
||||||
|
justIcon?: boolean;
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
modelValue: () => ({}),
|
modelValue: () => ({}),
|
||||||
keys: () => [],
|
keys: () => [],
|
||||||
shareable: true,
|
shareable: true,
|
||||||
reset: () => {},
|
reset: () => {},
|
||||||
|
justIcon: false,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -112,18 +114,21 @@ onBeforeMount(async () => {
|
||||||
icon="filter"
|
icon="filter"
|
||||||
type="tertiary"
|
type="tertiary"
|
||||||
:active="hasFilters"
|
:active="hasFilters"
|
||||||
:class="$style['filter-button']"
|
:class="{
|
||||||
|
[$style['filter-button']]: true,
|
||||||
|
[$style['no-label']]: justIcon && filtersLength === 0,
|
||||||
|
}"
|
||||||
data-test-id="resources-list-filters-trigger"
|
data-test-id="resources-list-filters-trigger"
|
||||||
>
|
>
|
||||||
<n8n-badge
|
<n8n-badge
|
||||||
v-show="filtersLength > 0"
|
v-if="filtersLength > 0"
|
||||||
:class="$style['filter-button-count']"
|
:class="$style['filter-button-count']"
|
||||||
data-test-id="resources-list-filters-count"
|
data-test-id="resources-list-filters-count"
|
||||||
theme="primary"
|
theme="primary"
|
||||||
>
|
>
|
||||||
{{ filtersLength }}
|
{{ filtersLength }}
|
||||||
</n8n-badge>
|
</n8n-badge>
|
||||||
<span :class="$style['filter-button-text']">
|
<span v-if="!justIcon" :class="$style['filter-button-text']">
|
||||||
{{ i18n.baseText('forms.resourceFiltersDropdown.filters') }}
|
{{ i18n.baseText('forms.resourceFiltersDropdown.filters') }}
|
||||||
</span>
|
</span>
|
||||||
</n8n-button>
|
</n8n-button>
|
||||||
|
@ -163,6 +168,12 @@ onBeforeMount(async () => {
|
||||||
height: 40px;
|
height: 40px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
||||||
|
&.no-label {
|
||||||
|
span + span {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.filter-button-count {
|
.filter-button-count {
|
||||||
margin-right: var(--spacing-4xs);
|
margin-right: var(--spacing-4xs);
|
||||||
|
|
||||||
|
|
|
@ -14,28 +14,55 @@ import { useRoute, useRouter } from 'vue-router';
|
||||||
|
|
||||||
import type { BaseTextKey } from '@/plugins/i18n';
|
import type { BaseTextKey } from '@/plugins/i18n';
|
||||||
import type { Scope } from '@n8n/permissions';
|
import type { Scope } from '@n8n/permissions';
|
||||||
|
import type { BaseFolderItem, BaseResource, FolderShortInfo, ITag } from '@/Interface';
|
||||||
|
import { isSharedResource, isResourceSortableByDate } from '@/utils/typeGuards';
|
||||||
|
|
||||||
export type Resource = {
|
type ResourceKeyType = 'credentials' | 'workflows' | 'variables' | 'folders';
|
||||||
id: string;
|
|
||||||
name?: string;
|
export type FolderResource = BaseFolderItem & {
|
||||||
value?: string;
|
resourceType: 'folder';
|
||||||
key?: string;
|
readOnly: boolean;
|
||||||
updatedAt?: string;
|
};
|
||||||
createdAt?: string;
|
|
||||||
|
export type WorkflowResource = BaseResource & {
|
||||||
|
resourceType: 'workflow';
|
||||||
|
updatedAt: string;
|
||||||
|
createdAt: string;
|
||||||
|
active: boolean;
|
||||||
homeProject?: ProjectSharingData;
|
homeProject?: ProjectSharingData;
|
||||||
scopes?: Scope[];
|
scopes?: Scope[];
|
||||||
type?: string;
|
tags?: ITag[] | string[];
|
||||||
sharedWithProjects?: ProjectSharingData[];
|
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 = {
|
export type BaseFilters = {
|
||||||
search: string;
|
search: string;
|
||||||
homeProject: string;
|
homeProject: string;
|
||||||
[key: string]: boolean | string | string[];
|
[key: string]: boolean | string | string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type ResourceKeyType = 'credentials' | 'workflows' | 'variables';
|
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const i18n = useI18n();
|
const i18n = useI18n();
|
||||||
|
@ -106,7 +133,7 @@ const emit = defineEmits<{
|
||||||
'update:search': [value: string];
|
'update:search': [value: string];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
defineSlots<{
|
const slots = defineSlots<{
|
||||||
header(): unknown;
|
header(): unknown;
|
||||||
empty(): unknown;
|
empty(): unknown;
|
||||||
preamble(): unknown;
|
preamble(): unknown;
|
||||||
|
@ -120,6 +147,7 @@ defineSlots<{
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
default(props: { data: any; updateItemSize: (data: any) => void }): unknown;
|
default(props: { data: any; updateItemSize: (data: any) => void }): unknown;
|
||||||
item(props: { item: unknown; index: number }): unknown;
|
item(props: { item: unknown; index: number }): unknown;
|
||||||
|
breadcrumbs(): unknown;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
//computed
|
//computed
|
||||||
|
@ -130,6 +158,7 @@ const filtersModel = computed({
|
||||||
|
|
||||||
const showEmptyState = computed(() => {
|
const showEmptyState = computed(() => {
|
||||||
return (
|
return (
|
||||||
|
route.params.folderId === undefined &&
|
||||||
props.resources.length === 0 &&
|
props.resources.length === 0 &&
|
||||||
// Don't show empty state if resources are refreshing or if filters are being set
|
// Don't show empty state if resources are refreshing or if filters are being set
|
||||||
!hasFilters.value &&
|
!hasFilters.value &&
|
||||||
|
@ -149,7 +178,7 @@ const filteredAndSortedResources = computed(() => {
|
||||||
const filtered = props.resources.filter((resource) => {
|
const filtered = props.resources.filter((resource) => {
|
||||||
let matches = true;
|
let matches = true;
|
||||||
|
|
||||||
if (filtersModel.value.homeProject) {
|
if (filtersModel.value.homeProject && isSharedResource(resource)) {
|
||||||
matches =
|
matches =
|
||||||
matches &&
|
matches &&
|
||||||
!!(resource.homeProject && resource.homeProject.id === filtersModel.value.homeProject);
|
!!(resource.homeProject && resource.homeProject.id === filtersModel.value.homeProject);
|
||||||
|
@ -168,12 +197,19 @@ const filteredAndSortedResources = computed(() => {
|
||||||
});
|
});
|
||||||
|
|
||||||
return filtered.sort((a, b) => {
|
return filtered.sort((a, b) => {
|
||||||
|
const sortableByDate = isResourceSortableByDate(a) && isResourceSortableByDate(b);
|
||||||
switch (sortBy.value) {
|
switch (sortBy.value) {
|
||||||
case 'lastUpdated':
|
case 'lastUpdated':
|
||||||
|
if (!sortableByDate) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
return props.sortFns.lastUpdated
|
return props.sortFns.lastUpdated
|
||||||
? props.sortFns.lastUpdated(a, b)
|
? props.sortFns.lastUpdated(a, b)
|
||||||
: new Date(b.updatedAt ?? '').valueOf() - new Date(a.updatedAt ?? '').valueOf();
|
: new Date(b.updatedAt ?? '').valueOf() - new Date(a.updatedAt ?? '').valueOf();
|
||||||
case 'lastCreated':
|
case 'lastCreated':
|
||||||
|
if (!sortableByDate) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
return props.sortFns.lastCreated
|
return props.sortFns.lastCreated
|
||||||
? props.sortFns.lastCreated(a, b)
|
? props.sortFns.lastCreated(a, b)
|
||||||
: new Date(b.createdAt ?? '').valueOf() - new Date(a.createdAt ?? '').valueOf();
|
: new Date(b.createdAt ?? '').valueOf() - new Date(a.createdAt ?? '').valueOf();
|
||||||
|
@ -494,6 +530,7 @@ const loadPaginationFromQueryString = async () => {
|
||||||
<template #header>
|
<template #header>
|
||||||
<div :class="$style['filters-row']">
|
<div :class="$style['filters-row']">
|
||||||
<div :class="$style.filters">
|
<div :class="$style.filters">
|
||||||
|
<slot name="breadcrumbs"></slot>
|
||||||
<n8n-input
|
<n8n-input
|
||||||
ref="search"
|
ref="search"
|
||||||
:model-value="filtersModel.search"
|
:model-value="filtersModel.search"
|
||||||
|
@ -507,19 +544,6 @@ const loadPaginationFromQueryString = async () => {
|
||||||
<n8n-icon icon="search" />
|
<n8n-icon icon="search" />
|
||||||
</template>
|
</template>
|
||||||
</n8n-input>
|
</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']">
|
<div :class="$style['sort-and-filter']">
|
||||||
<n8n-select v-model="sortBy" data-test-id="resources-list-sort">
|
<n8n-select v-model="sortBy" data-test-id="resources-list-sort">
|
||||||
<n8n-option
|
<n8n-option
|
||||||
|
@ -531,6 +555,20 @@ const loadPaginationFromQueryString = async () => {
|
||||||
/>
|
/>
|
||||||
</n8n-select>
|
</n8n-select>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
<slot name="add-button"></slot>
|
<slot name="add-button"></slot>
|
||||||
</div>
|
</div>
|
||||||
|
@ -630,14 +668,16 @@ const loadPaginationFromQueryString = async () => {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
gap: var(--spacing-2xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
.filters {
|
.filters {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-auto-flow: column;
|
grid-auto-flow: column;
|
||||||
grid-auto-columns: max-content;
|
grid-auto-columns: 1fr max-content max-content max-content;
|
||||||
gap: var(--spacing-2xs);
|
gap: var(--spacing-2xs);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: end;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
@include mixins.breakpoint('xs-only') {
|
@include mixins.breakpoint('xs-only') {
|
||||||
|
@ -651,7 +691,7 @@ const loadPaginationFromQueryString = async () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
.search {
|
.search {
|
||||||
max-width: 240px;
|
// max-width: 240px;
|
||||||
|
|
||||||
@include mixins.breakpoint('sm-and-down') {
|
@include mixins.breakpoint('sm-and-down') {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
|
|
|
@ -519,6 +519,8 @@ export const enum VIEWS {
|
||||||
PROJECTS_CREDENTIALS = 'ProjectsCredentials',
|
PROJECTS_CREDENTIALS = 'ProjectsCredentials',
|
||||||
PROJECT_SETTINGS = 'ProjectSettings',
|
PROJECT_SETTINGS = 'ProjectSettings',
|
||||||
PROJECTS_EXECUTIONS = 'ProjectsExecutions',
|
PROJECTS_EXECUTIONS = 'ProjectsExecutions',
|
||||||
|
FOLDERS = 'Folders',
|
||||||
|
PROJECTS_FOLDERS = 'ProjectsFolders',
|
||||||
}
|
}
|
||||||
|
|
||||||
export const EDITABLE_CANVAS_VIEWS = [VIEWS.WORKFLOW, VIEWS.NEW_WORKFLOW, VIEWS.EXECUTION_DEBUG];
|
export const EDITABLE_CANVAS_VIEWS = [VIEWS.WORKFLOW, VIEWS.NEW_WORKFLOW, VIEWS.EXECUTION_DEBUG];
|
||||||
|
|
|
@ -27,6 +27,7 @@ describe('permissions', () => {
|
||||||
variable: {},
|
variable: {},
|
||||||
workersView: {},
|
workersView: {},
|
||||||
workflow: {},
|
workflow: {},
|
||||||
|
folder: {},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
it('getResourcePermissions', () => {
|
it('getResourcePermissions', () => {
|
||||||
|
@ -115,6 +116,7 @@ describe('permissions', () => {
|
||||||
share: true,
|
share: true,
|
||||||
update: true,
|
update: true,
|
||||||
},
|
},
|
||||||
|
folder: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(getResourcePermissions(scopes)).toEqual(permissionRecord);
|
expect(getResourcePermissions(scopes)).toEqual(permissionRecord);
|
||||||
|
|
|
@ -85,6 +85,7 @@
|
||||||
"generic.variable_plural": "Variables",
|
"generic.variable_plural": "Variables",
|
||||||
"generic.variable": "Variable | {count} Variables",
|
"generic.variable": "Variable | {count} Variables",
|
||||||
"generic.viewDocs": "View docs",
|
"generic.viewDocs": "View docs",
|
||||||
|
"generic.workflows": "Workflows",
|
||||||
"about.aboutN8n": "About n8n",
|
"about.aboutN8n": "About n8n",
|
||||||
"about.close": "Close",
|
"about.close": "Close",
|
||||||
"about.license": "License",
|
"about.license": "License",
|
||||||
|
@ -2329,7 +2330,7 @@
|
||||||
"workflows.item.updated": "Last updated",
|
"workflows.item.updated": "Last updated",
|
||||||
"workflows.item.created": "Created",
|
"workflows.item.created": "Created",
|
||||||
"workflows.item.readonly": "Read only",
|
"workflows.item.readonly": "Read only",
|
||||||
"workflows.search.placeholder": "Search workflows...",
|
"workflows.search.placeholder": "Search",
|
||||||
"workflows.filters": "Filters",
|
"workflows.filters": "Filters",
|
||||||
"workflows.filters.tags": "Tags",
|
"workflows.filters.tags": "Tags",
|
||||||
"workflows.filters.status": "Status",
|
"workflows.filters.status": "Status",
|
||||||
|
|
|
@ -71,6 +71,7 @@ import {
|
||||||
faFilter,
|
faFilter,
|
||||||
faFingerprint,
|
faFingerprint,
|
||||||
faFlask,
|
faFlask,
|
||||||
|
faFolder,
|
||||||
faFolderOpen,
|
faFolderOpen,
|
||||||
faFont,
|
faFont,
|
||||||
faGlobeAmericas,
|
faGlobeAmericas,
|
||||||
|
@ -252,6 +253,7 @@ export const FontAwesomePlugin: Plugin = {
|
||||||
addIcon(faFilter);
|
addIcon(faFilter);
|
||||||
addIcon(faFingerprint);
|
addIcon(faFingerprint);
|
||||||
addIcon(faFlask);
|
addIcon(faFlask);
|
||||||
|
addIcon(faFolder);
|
||||||
addIcon(faFolderOpen);
|
addIcon(faFolderOpen);
|
||||||
addIcon(faFont);
|
addIcon(faFont);
|
||||||
addIcon(faGift);
|
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 = {
|
const commonChildRouteExtensions = {
|
||||||
|
@ -71,6 +84,9 @@ const commonChildRouteExtensions = {
|
||||||
{
|
{
|
||||||
name: VIEWS.EXECUTIONS,
|
name: VIEWS.EXECUTIONS,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: VIEWS.FOLDERS,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
projects: [
|
projects: [
|
||||||
{
|
{
|
||||||
|
@ -82,6 +98,9 @@ const commonChildRouteExtensions = {
|
||||||
{
|
{
|
||||||
name: VIEWS.PROJECTS_EXECUTIONS,
|
name: VIEWS.PROJECTS_EXECUTIONS,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: VIEWS.PROJECTS_FOLDERS,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -34,6 +34,7 @@ export const useRBACStore = defineStore(STORES.RBAC, () => {
|
||||||
logStreaming: {},
|
logStreaming: {},
|
||||||
saml: {},
|
saml: {},
|
||||||
securityAudit: {},
|
securityAudit: {},
|
||||||
|
folder: {},
|
||||||
});
|
});
|
||||||
|
|
||||||
function addGlobalRole(role: IRole) {
|
function addGlobalRole(role: IRole) {
|
||||||
|
|
|
@ -31,6 +31,7 @@ import type {
|
||||||
IExecutionFlattedResponse,
|
IExecutionFlattedResponse,
|
||||||
IWorkflowTemplateNode,
|
IWorkflowTemplateNode,
|
||||||
IWorkflowDataCreate,
|
IWorkflowDataCreate,
|
||||||
|
WorkflowListResource,
|
||||||
} from '@/Interface';
|
} from '@/Interface';
|
||||||
import { defineStore } from 'pinia';
|
import { defineStore } from 'pinia';
|
||||||
import type {
|
import type {
|
||||||
|
@ -483,8 +484,9 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
|
||||||
page = 1,
|
page = 1,
|
||||||
pageSize = DEFAULT_WORKFLOW_PAGE_SIZE,
|
pageSize = DEFAULT_WORKFLOW_PAGE_SIZE,
|
||||||
sortBy?: string,
|
sortBy?: string,
|
||||||
filters: { name?: string; tags?: string[]; active?: boolean } = {},
|
filters: { name?: string; tags?: string[]; active?: boolean; parentFolderId?: string } = {},
|
||||||
): Promise<IWorkflowDb[]> {
|
includeFolders: boolean = false,
|
||||||
|
): Promise<WorkflowListResource[]> {
|
||||||
const filter = { ...filters, projectId };
|
const filter = { ...filters, projectId };
|
||||||
const options = {
|
const options = {
|
||||||
skip: (page - 1) * pageSize,
|
skip: (page - 1) * pageSize,
|
||||||
|
@ -492,13 +494,13 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
|
||||||
sortBy,
|
sortBy,
|
||||||
};
|
};
|
||||||
|
|
||||||
const { count, data } = await workflowsApi.getWorkflows(
|
const { count, data } = await workflowsApi.getWorkflowsAndFolders(
|
||||||
rootStore.restApiContext,
|
rootStore.restApiContext,
|
||||||
Object.keys(filter).length ? filter : undefined,
|
Object.keys(filter).length ? filter : undefined,
|
||||||
Object.keys(options).length ? options : undefined,
|
Object.keys(options).length ? options : undefined,
|
||||||
|
includeFolders ? includeFolders : undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
setWorkflows(data);
|
|
||||||
totalWorkflowCount.value = count;
|
totalWorkflowCount.value = count;
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,6 +11,13 @@ import type { RouteLocationRaw } from 'vue-router';
|
||||||
import type { CanvasConnectionMode } from '@/types';
|
import type { CanvasConnectionMode } from '@/types';
|
||||||
import { canvasConnectionModes } from '@/types';
|
import { canvasConnectionModes } from '@/types';
|
||||||
import type { ComponentPublicInstance } from 'vue';
|
import type { ComponentPublicInstance } from 'vue';
|
||||||
|
import type {
|
||||||
|
CredentialsResource,
|
||||||
|
FolderResource,
|
||||||
|
Resource,
|
||||||
|
VariableResource,
|
||||||
|
WorkflowResource,
|
||||||
|
} from '@/components/layouts/ResourcesListLayout.vue';
|
||||||
|
|
||||||
/*
|
/*
|
||||||
Type guards used in editor-ui project
|
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 {
|
export function isComponentPublicInstance(value: unknown): value is ComponentPublicInstance {
|
||||||
return value !== null && typeof value === 'object' && '$props' in value;
|
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">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, watch } from 'vue';
|
import { ref, computed, onMounted, watch } from 'vue';
|
||||||
import { useRoute, useRouter, type LocationQueryRaw } from 'vue-router';
|
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 type { ICredentialType, ICredentialsDecrypted } from 'n8n-workflow';
|
||||||
import ResourcesListLayout, {
|
import ResourcesListLayout, {
|
||||||
type Resource,
|
type Resource,
|
||||||
|
@ -31,6 +31,7 @@ import ProjectHeader from '@/components/Projects/ProjectHeader.vue';
|
||||||
import { N8nCheckbox } from 'n8n-design-system';
|
import { N8nCheckbox } from 'n8n-design-system';
|
||||||
import { pickBy } from 'lodash-es';
|
import { pickBy } from 'lodash-es';
|
||||||
import { CREDENTIAL_EMPTY_VALUE } from 'n8n-workflow';
|
import { CREDENTIAL_EMPTY_VALUE } from 'n8n-workflow';
|
||||||
|
import { isCredentialsResource } from '@/utils/typeGuards';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
credentialId?: string;
|
credentialId?: string;
|
||||||
|
@ -76,6 +77,7 @@ const needsSetup = (data: string | undefined): boolean => {
|
||||||
|
|
||||||
const allCredentials = computed<Resource[]>(() =>
|
const allCredentials = computed<Resource[]>(() =>
|
||||||
credentialsStore.allCredentials.map((credential) => ({
|
credentialsStore.allCredentials.map((credential) => ({
|
||||||
|
resourceType: 'credential',
|
||||||
id: credential.id,
|
id: credential.id,
|
||||||
name: credential.name,
|
name: credential.name,
|
||||||
value: '',
|
value: '',
|
||||||
|
@ -83,10 +85,10 @@ const allCredentials = computed<Resource[]>(() =>
|
||||||
createdAt: credential.createdAt,
|
createdAt: credential.createdAt,
|
||||||
homeProject: credential.homeProject,
|
homeProject: credential.homeProject,
|
||||||
scopes: credential.scopes,
|
scopes: credential.scopes,
|
||||||
type: credential.type,
|
|
||||||
sharedWithProjects: credential.sharedWithProjects,
|
sharedWithProjects: credential.sharedWithProjects,
|
||||||
readOnly: !getResourcePermissions(credential.scopes).credential.update,
|
readOnly: !getResourcePermissions(credential.scopes).credential.update,
|
||||||
needsSetup: needsSetup(credential.data),
|
needsSetup: needsSetup(credential.data),
|
||||||
|
type: credential.type,
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -125,10 +127,10 @@ listenForModalChanges({
|
||||||
});
|
});
|
||||||
|
|
||||||
const onFilter = (resource: Resource, newFilters: BaseFilters, matches: boolean): boolean => {
|
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;
|
const filtersToApply = newFilters as Filters;
|
||||||
if (filtersToApply.type && filtersToApply.type.length > 0) {
|
if (filtersToApply.type && filtersToApply.type.length > 0) {
|
||||||
matches = matches && filtersToApply.type.includes(Resource.type);
|
matches = matches && filtersToApply.type.includes(resource.type);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filtersToApply.search) {
|
if (filtersToApply.search) {
|
||||||
|
@ -136,12 +138,12 @@ const onFilter = (resource: Resource, newFilters: BaseFilters, matches: boolean)
|
||||||
|
|
||||||
matches =
|
matches =
|
||||||
matches ||
|
matches ||
|
||||||
(credentialTypesById.value[Resource.type] &&
|
(credentialTypesById.value[resource.type] &&
|
||||||
credentialTypesById.value[Resource.type].displayName.toLowerCase().includes(searchString));
|
credentialTypesById.value[resource.type].displayName.toLowerCase().includes(searchString));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filtersToApply.setupNeeded) {
|
if (filtersToApply.setupNeeded) {
|
||||||
matches = matches && Resource.needsSetup;
|
matches = matches && resource.needsSetup;
|
||||||
}
|
}
|
||||||
|
|
||||||
return matches;
|
return matches;
|
||||||
|
|
|
@ -17,6 +17,7 @@ import VariablesUsageBadge from '@/components/VariablesUsageBadge.vue';
|
||||||
import ResourcesListLayout, {
|
import ResourcesListLayout, {
|
||||||
type Resource,
|
type Resource,
|
||||||
type BaseFilters,
|
type BaseFilters,
|
||||||
|
type VariableResource,
|
||||||
} from '@/components/layouts/ResourcesListLayout.vue';
|
} from '@/components/layouts/ResourcesListLayout.vue';
|
||||||
|
|
||||||
import { EnterpriseEditionFeature, MODAL_CONFIRM } from '@/constants';
|
import { EnterpriseEditionFeature, MODAL_CONFIRM } from '@/constants';
|
||||||
|
@ -85,7 +86,18 @@ const addEmptyVariableForm = () => {
|
||||||
telemetry.track('User clicked add variable button');
|
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);
|
const canCreateVariables = computed(() => isFeatureEnabled.value && permissions.value.create);
|
||||||
|
|
||||||
|
@ -118,11 +130,18 @@ const columns = computed(() => {
|
||||||
|
|
||||||
const handleSubmit = async (variable: EnvironmentVariable) => {
|
const handleSubmit = async (variable: EnvironmentVariable) => {
|
||||||
try {
|
try {
|
||||||
const { id, ...rest } = variable;
|
const { id } = variable;
|
||||||
if (id.startsWith(TEMPORARY_VARIABLE_UID_BASE)) {
|
if (id.startsWith(TEMPORARY_VARIABLE_UID_BASE)) {
|
||||||
await environmentsStore.createVariable(rest);
|
await environmentsStore.createVariable({
|
||||||
|
value: variable.value,
|
||||||
|
key: variable.key,
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
await environmentsStore.updateVariable(variable);
|
await environmentsStore.updateVariable({
|
||||||
|
id: variable.id,
|
||||||
|
value: variable.value,
|
||||||
|
key: variable.key,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
removeEditableVariable(id);
|
removeEditableVariable(id);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
@ -147,7 +166,11 @@ const handleDeleteVariable = async (variable: EnvironmentVariable) => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await environmentsStore.deleteVariable(variable);
|
await environmentsStore.deleteVariable({
|
||||||
|
id: variable.id,
|
||||||
|
value: variable.value,
|
||||||
|
key: variable.key,
|
||||||
|
});
|
||||||
removeEditableVariable(variable.id);
|
removeEditableVariable(variable.id);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError(error, i18n.baseText('variables.errors.delete'));
|
showError(error, i18n.baseText('variables.errors.delete'));
|
||||||
|
|
|
@ -7,7 +7,7 @@ import { useProjectsStore } from '@/stores/projects.store';
|
||||||
import { createTestingPinia } from '@pinia/testing';
|
import { createTestingPinia } from '@pinia/testing';
|
||||||
import { STORES, VIEWS } from '@/constants';
|
import { STORES, VIEWS } from '@/constants';
|
||||||
import { mockedStore, waitAllPromises } from '@/__tests__/utils';
|
import { mockedStore, waitAllPromises } from '@/__tests__/utils';
|
||||||
import type { IUser } from '@/Interface';
|
import type { IUser, WorkflowListResource } from '@/Interface';
|
||||||
import { useSourceControlStore } from '@/stores/sourceControl.store';
|
import { useSourceControlStore } from '@/stores/sourceControl.store';
|
||||||
import type { Project } from '@/types/projects.types';
|
import type { Project } from '@/types/projects.types';
|
||||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||||
|
@ -48,7 +48,7 @@ const renderComponent = createComponentRenderer(WorkflowsView, {
|
||||||
});
|
});
|
||||||
|
|
||||||
const initialState = {
|
const initialState = {
|
||||||
[STORES.SETTINGS]: { settings: { enterprise: { sharing: false } } },
|
[STORES.SETTINGS]: { settings: { enterprise: { sharing: false }, folders: { enabled: false } } },
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('WorkflowsView', () => {
|
describe('WorkflowsView', () => {
|
||||||
|
@ -173,6 +173,7 @@ describe('WorkflowsView', () => {
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
tags: [TEST_TAG.name],
|
tags: [TEST_TAG.name],
|
||||||
}),
|
}),
|
||||||
|
expect.any(Boolean),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -193,6 +194,7 @@ describe('WorkflowsView', () => {
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
name: 'one',
|
name: 'one',
|
||||||
}),
|
}),
|
||||||
|
expect.any(Boolean),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -213,6 +215,7 @@ describe('WorkflowsView', () => {
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
active: true,
|
active: true,
|
||||||
}),
|
}),
|
||||||
|
expect.any(Boolean),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -271,4 +274,64 @@ describe('WorkflowsView', () => {
|
||||||
await sourceControl.pullWorkfolder(true);
|
await sourceControl.pullWorkfolder(true);
|
||||||
expect(userStore.fetchUsers).toHaveBeenCalledTimes(2);
|
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>
|
<script lang="ts" setup>
|
||||||
import { computed, onMounted, watch, ref, onBeforeUnmount } from 'vue';
|
import { computed, onMounted, watch, ref, onBeforeUnmount } from 'vue';
|
||||||
import ResourcesListLayout, {
|
import ResourcesListLayout from '@/components/layouts/ResourcesListLayout.vue';
|
||||||
type Resource,
|
import type {
|
||||||
type BaseFilters,
|
Resource,
|
||||||
|
BaseFilters,
|
||||||
|
FolderResource,
|
||||||
|
WorkflowResource,
|
||||||
} from '@/components/layouts/ResourcesListLayout.vue';
|
} from '@/components/layouts/ResourcesListLayout.vue';
|
||||||
import WorkflowCard from '@/components/WorkflowCard.vue';
|
import WorkflowCard from '@/components/WorkflowCard.vue';
|
||||||
import WorkflowTagsDropdown from '@/components/WorkflowTagsDropdown.vue';
|
import WorkflowTagsDropdown from '@/components/WorkflowTagsDropdown.vue';
|
||||||
|
@ -13,7 +16,7 @@ import {
|
||||||
VIEWS,
|
VIEWS,
|
||||||
DEFAULT_WORKFLOW_PAGE_SIZE,
|
DEFAULT_WORKFLOW_PAGE_SIZE,
|
||||||
} from '@/constants';
|
} from '@/constants';
|
||||||
import type { IUser, IWorkflowDb } from '@/Interface';
|
import type { IUser, UserAction, WorkflowListResource, WorkflowListItem } from '@/Interface';
|
||||||
import { useUIStore } from '@/stores/ui.store';
|
import { useUIStore } from '@/stores/ui.store';
|
||||||
import { useSettingsStore } from '@/stores/settings.store';
|
import { useSettingsStore } from '@/stores/settings.store';
|
||||||
import { useUsersStore } from '@/stores/users.store';
|
import { useUsersStore } from '@/stores/users.store';
|
||||||
|
@ -40,6 +43,9 @@ import ProjectHeader from '@/components/Projects/ProjectHeader.vue';
|
||||||
import { getEasyAiWorkflowJson } from '@/utils/easyAiWorkflowUtils';
|
import { getEasyAiWorkflowJson } from '@/utils/easyAiWorkflowUtils';
|
||||||
import { useDebounce } from '@/composables/useDebounce';
|
import { useDebounce } from '@/composables/useDebounce';
|
||||||
import { createEventBus } from 'n8n-design-system/utils';
|
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';
|
import { debounce } from 'lodash-es';
|
||||||
|
|
||||||
interface Filters extends BaseFilters {
|
interface Filters extends BaseFilters {
|
||||||
|
@ -87,36 +93,122 @@ const filters = ref<Filters>({
|
||||||
|
|
||||||
const workflowListEventBus = createEventBus();
|
const workflowListEventBus = createEventBus();
|
||||||
|
|
||||||
const workflows = ref<IWorkflowDb[]>([]);
|
const workflowsAndFolders = ref<WorkflowListResource[]>([]);
|
||||||
|
|
||||||
const easyAICalloutVisible = ref(true);
|
const easyAICalloutVisible = ref(true);
|
||||||
|
|
||||||
|
const currentFolder = ref<FolderResource | undefined>(undefined);
|
||||||
|
|
||||||
const currentPage = ref(1);
|
const currentPage = ref(1);
|
||||||
const pageSize = ref(DEFAULT_WORKFLOW_PAGE_SIZE);
|
const pageSize = ref(DEFAULT_WORKFLOW_PAGE_SIZE);
|
||||||
const currentSort = ref('updatedAt:desc');
|
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 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 currentUser = computed(() => usersStore.currentUser ?? ({} as IUser));
|
||||||
const isShareable = computed(
|
const isShareable = computed(
|
||||||
() => settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.Sharing],
|
() => settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.Sharing],
|
||||||
);
|
);
|
||||||
|
|
||||||
const workflowResources = computed<Resource[]>(() =>
|
const showFolders = computed(() => foldersEnabled.value && !isOverviewPage.value);
|
||||||
workflows.value.map((workflow) => ({
|
|
||||||
id: workflow.id,
|
const mainBreadcrumbsItems = computed<PathItem[] | undefined>(() => {
|
||||||
name: workflow.name,
|
if (!showFolders.value || !currentFolder.value) return;
|
||||||
value: '',
|
const items: PathItem[] = [];
|
||||||
active: workflow.active,
|
items.push({
|
||||||
updatedAt: workflow.updatedAt.toString(),
|
id: currentFolder.value.id,
|
||||||
createdAt: workflow.createdAt.toString(),
|
label: currentFolder.value.name,
|
||||||
homeProject: workflow.homeProject,
|
});
|
||||||
scopes: workflow.scopes,
|
return items;
|
||||||
type: 'workflow',
|
});
|
||||||
sharedWithProjects: workflow.sharedWithProjects,
|
|
||||||
readOnly: !getResourcePermissions(workflow.scopes).workflow.update,
|
const currentProject = computed(() => projectsStore.currentProject);
|
||||||
tags: workflow.tags,
|
|
||||||
})),
|
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(() => [
|
const statusFilterOptions = computed(() => [
|
||||||
{
|
{
|
||||||
|
@ -164,6 +256,16 @@ watch(
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => route.params?.folderId,
|
||||||
|
async (newVal) => {
|
||||||
|
if (!newVal) {
|
||||||
|
currentFolder.value = undefined;
|
||||||
|
}
|
||||||
|
await fetchWorkflows();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Lifecycle hooks
|
// Lifecycle hooks
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
documentTitle.set(i18n.baseText('workflows.heading'));
|
documentTitle.set(i18n.baseText('workflows.heading'));
|
||||||
|
@ -217,13 +319,14 @@ const trackEmptyCardClick = (option: 'blank' | 'templates' | 'courses') => {
|
||||||
|
|
||||||
const initialize = async () => {
|
const initialize = async () => {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
|
currentFolder.value = undefined;
|
||||||
await setFiltersFromQueryString();
|
await setFiltersFromQueryString();
|
||||||
const [, workflowsPage] = await Promise.all([
|
const [, resourcesPage] = await Promise.all([
|
||||||
usersStore.fetchUsers(),
|
usersStore.fetchUsers(),
|
||||||
fetchWorkflows(),
|
fetchWorkflows(),
|
||||||
workflowsStore.fetchActiveWorkflows(),
|
workflowsStore.fetchActiveWorkflows(),
|
||||||
]);
|
]);
|
||||||
workflows.value = workflowsPage;
|
workflowsAndFolders.value = resourcesPage;
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -245,8 +348,9 @@ const fetchWorkflows = async () => {
|
||||||
}, 300);
|
}, 300);
|
||||||
const routeProjectId = route.params?.projectId as string | undefined;
|
const routeProjectId = route.params?.projectId as string | undefined;
|
||||||
const homeProjectFilter = filters.value.homeProject || 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,
|
routeProjectId ?? homeProjectFilter,
|
||||||
currentPage.value,
|
currentPage.value,
|
||||||
pageSize.value,
|
pageSize.value,
|
||||||
|
@ -255,12 +359,17 @@ const fetchWorkflows = async () => {
|
||||||
name: filters.value.search || undefined,
|
name: filters.value.search || undefined,
|
||||||
active: filters.value.status ? Boolean(filters.value.status) : undefined,
|
active: filters.value.status ? Boolean(filters.value.status) : undefined,
|
||||||
tags: filters.value.tags.map((tagId) => tagsStore.tagsById[tagId]?.name),
|
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();
|
delayedLoading.cancel();
|
||||||
workflows.value = fetchedWorkflows;
|
workflowsAndFolders.value = fetchedResources;
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
return fetchedWorkflows;
|
return fetchedResources;
|
||||||
};
|
};
|
||||||
|
|
||||||
const onClickTag = async (tagId: string) => {
|
const onClickTag = async (tagId: string) => {
|
||||||
|
@ -423,10 +532,16 @@ const onSortUpdated = async (sort: string) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const onWorkflowActiveToggle = (data: { id: string; active: boolean }) => {
|
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;
|
if (!workflow) return;
|
||||||
workflow.active = data.active;
|
workflow.active = data.active;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onFolderOpened = (data: { folder: FolderResource }) => {
|
||||||
|
currentFolder.value = data.folder;
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
@ -434,7 +549,7 @@ const onWorkflowActiveToggle = (data: { id: string; active: boolean }) => {
|
||||||
v-model:filters="filters"
|
v-model:filters="filters"
|
||||||
resource-key="workflows"
|
resource-key="workflows"
|
||||||
type="list-paginated"
|
type="list-paginated"
|
||||||
:resources="workflowResources"
|
:resources="workflowListResources"
|
||||||
:type-props="{ itemSize: 80 }"
|
:type-props="{ itemSize: 80 }"
|
||||||
:shareable="isShareable"
|
:shareable="isShareable"
|
||||||
:initialize="initialize"
|
:initialize="initialize"
|
||||||
|
@ -483,11 +598,36 @@ const onWorkflowActiveToggle = (data: { id: string; active: boolean }) => {
|
||||||
</template>
|
</template>
|
||||||
</N8nCallout>
|
</N8nCallout>
|
||||||
</template>
|
</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 }">
|
<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
|
<WorkflowCard
|
||||||
|
v-else
|
||||||
data-test-id="resources-list-item"
|
data-test-id="resources-list-item"
|
||||||
class="mb-2xs"
|
class="mb-2xs"
|
||||||
:data="data as IWorkflowDb"
|
:data="data as WorkflowResource"
|
||||||
:workflow-list-event-bus="workflowListEventBus"
|
:workflow-list-event-bus="workflowListEventBus"
|
||||||
:read-only="readOnlyEnv"
|
:read-only="readOnlyEnv"
|
||||||
@click:tag="onClickTag"
|
@click:tag="onClickTag"
|
||||||
|
@ -629,4 +769,9 @@ const onWorkflowActiveToggle = (data: { id: string; active: boolean }) => {
|
||||||
transition: color 0.3s ease;
|
transition: color 0.3s ease;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.home-project {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
Loading…
Reference in a new issue