mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 10:02:05 +00:00
feat(editor): Implement 'Shared with you' section in the main navigation (#15140)
This commit is contained in:
committed by
GitHub
parent
abdbe50907
commit
1c65e82b38
@@ -30,11 +30,13 @@ export async function getAllCredentials(
|
|||||||
context: IRestApiContext,
|
context: IRestApiContext,
|
||||||
filter?: object,
|
filter?: object,
|
||||||
includeScopes?: boolean,
|
includeScopes?: boolean,
|
||||||
|
onlySharedWithMe?: boolean,
|
||||||
): Promise<ICredentialsResponse[]> {
|
): Promise<ICredentialsResponse[]> {
|
||||||
return await makeRestApiRequest(context, 'GET', '/credentials', {
|
return await makeRestApiRequest(context, 'GET', '/credentials', {
|
||||||
...(includeScopes ? { includeScopes } : {}),
|
...(includeScopes ? { includeScopes } : {}),
|
||||||
includeData: true,
|
includeData: true,
|
||||||
...(filter ? { filter } : {}),
|
...(filter ? { filter } : {}),
|
||||||
|
...(onlySharedWithMe ? { onlySharedWithMe } : {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -47,10 +47,12 @@ export async function getWorkflowsAndFolders(
|
|||||||
filter?: object,
|
filter?: object,
|
||||||
options?: object,
|
options?: object,
|
||||||
includeFolders?: boolean,
|
includeFolders?: boolean,
|
||||||
|
onlySharedWithMe?: boolean,
|
||||||
) {
|
) {
|
||||||
return await getFullApiResponse<WorkflowListResource[]>(context, 'GET', '/workflows', {
|
return await getFullApiResponse<WorkflowListResource[]>(context, 'GET', '/workflows', {
|
||||||
includeScopes: true,
|
includeScopes: true,
|
||||||
includeFolders,
|
includeFolders,
|
||||||
|
onlySharedWithMe,
|
||||||
...(filter ? { filter } : {}),
|
...(filter ? { filter } : {}),
|
||||||
...(options ? options : {}),
|
...(options ? options : {}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -163,6 +163,7 @@ function moveResource() {
|
|||||||
:resource-type="ResourceType.Credential"
|
:resource-type="ResourceType.Credential"
|
||||||
:resource-type-label="resourceTypeLabel"
|
:resource-type-label="resourceTypeLabel"
|
||||||
:personal-project="projectsStore.personalProject"
|
:personal-project="projectsStore.personalProject"
|
||||||
|
:show-badge-border="false"
|
||||||
/>
|
/>
|
||||||
<n8n-action-toggle
|
<n8n-action-toggle
|
||||||
data-test-id="credential-card-actions"
|
data-test-id="credential-card-actions"
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useI18n } from '@/composables/useI18n';
|
||||||
|
import { VIEWS } from '@/constants';
|
||||||
|
import type { Project } from '@/types/projects.types';
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
personalProject: Project;
|
||||||
|
resourceType?: 'workflows' | 'credentials';
|
||||||
|
};
|
||||||
|
|
||||||
|
const i18n = useI18n();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
resourceType: 'workflows',
|
||||||
|
});
|
||||||
|
|
||||||
|
const heading = computed(() => {
|
||||||
|
return i18n.baseText('workflows.empty.shared-with-me', {
|
||||||
|
interpolate: {
|
||||||
|
resource: i18n
|
||||||
|
.baseText(`generic.${props.resourceType === 'workflows' ? 'workflow' : 'credential'}`)
|
||||||
|
.toLowerCase(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const onPersonalLinkClick = (event: MouseEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
void router.push({
|
||||||
|
name: VIEWS.PROJECTS_WORKFLOWS,
|
||||||
|
params: { projectId: props.personalProject.id },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<n8n-action-box
|
||||||
|
data-test-id="empty-shared-action-box"
|
||||||
|
:heading="heading"
|
||||||
|
:description="i18n.baseText('workflows.empty.shared-with-me.link')"
|
||||||
|
@description-click="onPersonalLinkClick"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
@@ -12,7 +12,7 @@ import { VIEWS } from '@/constants';
|
|||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import { waitFor, within } from '@testing-library/vue';
|
import { waitFor, within } from '@testing-library/vue';
|
||||||
import { useSettingsStore } from '@/stores/settings.store';
|
import { useSettingsStore } from '@/stores/settings.store';
|
||||||
import { useOverview } from '@/composables/useOverview';
|
import { useProjectPages } from '@/composables/useProjectPages';
|
||||||
|
|
||||||
const mockPush = vi.fn();
|
const mockPush = vi.fn();
|
||||||
vi.mock('vue-router', async () => {
|
vi.mock('vue-router', async () => {
|
||||||
@@ -31,9 +31,10 @@ vi.mock('vue-router', async () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
vi.mock('@/composables/useOverview', () => ({
|
vi.mock('@/composables/useProjectPages', () => ({
|
||||||
useOverview: vi.fn().mockReturnValue({
|
useProjectPages: vi.fn().mockReturnValue({
|
||||||
isOverviewSubPage: false,
|
isOverviewSubPage: false,
|
||||||
|
isSharedSubPage: false,
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -52,7 +53,7 @@ const renderComponent = createComponentRenderer(ProjectHeader, {
|
|||||||
let route: ReturnType<typeof router.useRoute>;
|
let route: ReturnType<typeof router.useRoute>;
|
||||||
let projectsStore: ReturnType<typeof mockedStore<typeof useProjectsStore>>;
|
let projectsStore: ReturnType<typeof mockedStore<typeof useProjectsStore>>;
|
||||||
let settingsStore: ReturnType<typeof mockedStore<typeof useSettingsStore>>;
|
let settingsStore: ReturnType<typeof mockedStore<typeof useSettingsStore>>;
|
||||||
let overview: ReturnType<typeof useOverview>;
|
let projectPages: ReturnType<typeof useProjectPages>;
|
||||||
|
|
||||||
describe('ProjectHeader', () => {
|
describe('ProjectHeader', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -60,7 +61,7 @@ describe('ProjectHeader', () => {
|
|||||||
route = router.useRoute();
|
route = router.useRoute();
|
||||||
projectsStore = mockedStore(useProjectsStore);
|
projectsStore = mockedStore(useProjectsStore);
|
||||||
settingsStore = mockedStore(useSettingsStore);
|
settingsStore = mockedStore(useSettingsStore);
|
||||||
overview = useOverview();
|
projectPages = useProjectPages();
|
||||||
|
|
||||||
projectsStore.teamProjectsLimit = -1;
|
projectsStore.teamProjectsLimit = -1;
|
||||||
settingsStore.settings.folders = { enabled: false };
|
settingsStore.settings.folders = { enabled: false };
|
||||||
@@ -71,19 +72,20 @@ describe('ProjectHeader', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should not render title icon on overview page', async () => {
|
it('should not render title icon on overview page', async () => {
|
||||||
vi.spyOn(overview, 'isOverviewSubPage', 'get').mockReturnValue(true);
|
vi.spyOn(projectPages, 'isOverviewSubPage', 'get').mockReturnValue(true);
|
||||||
const { container } = renderComponent();
|
const { container } = renderComponent();
|
||||||
|
|
||||||
expect(container.querySelector('.fa-home')).not.toBeInTheDocument();
|
expect(container.querySelector('.fa-home')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should render the correct icon', async () => {
|
it('should render the correct icon', async () => {
|
||||||
vi.spyOn(overview, 'isOverviewSubPage', 'get').mockReturnValue(false);
|
vi.spyOn(projectPages, 'isOverviewSubPage', 'get').mockReturnValue(false);
|
||||||
const { container, rerender } = renderComponent();
|
const { container, rerender } = renderComponent();
|
||||||
|
|
||||||
|
// We no longer render icon for personal project
|
||||||
projectsStore.currentProject = { type: ProjectTypes.Personal } as Project;
|
projectsStore.currentProject = { type: ProjectTypes.Personal } as Project;
|
||||||
await rerender({});
|
await rerender({});
|
||||||
expect(container.querySelector('.fa-user')).toBeVisible();
|
expect(container.querySelector('.fa-user')).not.toBeInTheDocument();
|
||||||
|
|
||||||
const projectName = 'My Project';
|
const projectName = 'My Project';
|
||||||
projectsStore.currentProject = { name: projectName } as Project;
|
projectsStore.currentProject = { name: projectName } as Project;
|
||||||
@@ -91,23 +93,55 @@ describe('ProjectHeader', () => {
|
|||||||
expect(container.querySelector('.fa-layer-group')).toBeVisible();
|
expect(container.querySelector('.fa-layer-group')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should render the correct title and subtitle', async () => {
|
it('Overview: should render the correct title and subtitle', async () => {
|
||||||
const { getByText, queryByText, rerender } = renderComponent();
|
vi.spyOn(projectPages, 'isOverviewSubPage', 'get').mockReturnValue(true);
|
||||||
const subtitle = 'All the workflows, credentials and executions you have access to';
|
const { getByTestId, rerender } = renderComponent();
|
||||||
|
const overviewSubtitle = 'All the workflows, credentials and executions you have access to';
|
||||||
|
|
||||||
expect(getByText('Overview')).toBeVisible();
|
await rerender({});
|
||||||
expect(getByText(subtitle)).toBeVisible();
|
|
||||||
|
expect(getByTestId('project-name')).toHaveTextContent('Overview');
|
||||||
|
expect(getByTestId('project-subtitle')).toHaveTextContent(overviewSubtitle);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Shared with you: should render the correct title and subtitle', async () => {
|
||||||
|
vi.spyOn(projectPages, 'isOverviewSubPage', 'get').mockReturnValue(false);
|
||||||
|
vi.spyOn(projectPages, 'isSharedSubPage', 'get').mockReturnValue(true);
|
||||||
|
const { getByTestId, rerender } = renderComponent();
|
||||||
|
const sharedSubtitle = 'Workflows and credentials other users have shared with you';
|
||||||
|
|
||||||
|
await rerender({});
|
||||||
|
|
||||||
|
expect(getByTestId('project-name')).toHaveTextContent('Shared with you');
|
||||||
|
expect(getByTestId('project-subtitle')).toHaveTextContent(sharedSubtitle);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Personal: should render the correct title and subtitle', async () => {
|
||||||
|
vi.spyOn(projectPages, 'isOverviewSubPage', 'get').mockReturnValue(false);
|
||||||
|
vi.spyOn(projectPages, 'isSharedSubPage', 'get').mockReturnValue(false);
|
||||||
|
const { getByTestId, rerender } = renderComponent();
|
||||||
|
const personalSubtitle = 'Workflows and credentials owned by you';
|
||||||
|
|
||||||
projectsStore.currentProject = { type: ProjectTypes.Personal } as Project;
|
projectsStore.currentProject = { type: ProjectTypes.Personal } as Project;
|
||||||
|
|
||||||
await rerender({});
|
await rerender({});
|
||||||
expect(getByText('Personal')).toBeVisible();
|
|
||||||
expect(queryByText(subtitle)).not.toBeInTheDocument();
|
expect(getByTestId('project-name')).toHaveTextContent('Personal');
|
||||||
|
expect(getByTestId('project-subtitle')).toHaveTextContent(personalSubtitle);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Team project: should render the correct title and subtitle', async () => {
|
||||||
|
vi.spyOn(projectPages, 'isOverviewSubPage', 'get').mockReturnValue(false);
|
||||||
|
vi.spyOn(projectPages, 'isSharedSubPage', 'get').mockReturnValue(false);
|
||||||
|
const { getByTestId, queryByTestId, rerender } = renderComponent();
|
||||||
|
|
||||||
const projectName = 'My Project';
|
const projectName = 'My Project';
|
||||||
projectsStore.currentProject = { name: projectName } as Project;
|
projectsStore.currentProject = { name: projectName } as Project;
|
||||||
|
|
||||||
await rerender({});
|
await rerender({});
|
||||||
expect(getByText(projectName)).toBeVisible();
|
|
||||||
expect(queryByText(subtitle)).not.toBeInTheDocument();
|
expect(getByTestId('project-name')).toHaveTextContent(projectName);
|
||||||
|
expect(queryByTestId('project-subtitle')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should overwrite default subtitle with slot', () => {
|
it('should overwrite default subtitle with slot', () => {
|
||||||
@@ -130,9 +164,9 @@ describe('ProjectHeader', () => {
|
|||||||
renderComponent();
|
renderComponent();
|
||||||
|
|
||||||
expect(projectTabsSpy).toHaveBeenCalledWith(
|
expect(projectTabsSpy).toHaveBeenCalledWith(
|
||||||
{
|
expect.objectContaining({
|
||||||
'show-settings': true,
|
'show-settings': true,
|
||||||
},
|
}),
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -143,9 +177,9 @@ describe('ProjectHeader', () => {
|
|||||||
renderComponent();
|
renderComponent();
|
||||||
|
|
||||||
expect(projectTabsSpy).toHaveBeenCalledWith(
|
expect(projectTabsSpy).toHaveBeenCalledWith(
|
||||||
{
|
expect.objectContaining({
|
||||||
'show-settings': false,
|
'show-settings': false,
|
||||||
},
|
}),
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -159,9 +193,9 @@ describe('ProjectHeader', () => {
|
|||||||
renderComponent();
|
renderComponent();
|
||||||
|
|
||||||
expect(projectTabsSpy).toHaveBeenCalledWith(
|
expect(projectTabsSpy).toHaveBeenCalledWith(
|
||||||
{
|
expect.objectContaining({
|
||||||
'show-settings': false,
|
'show-settings': false,
|
||||||
},
|
}),
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { VIEWS } from '@/constants';
|
|||||||
import { useSourceControlStore } from '@/stores/sourceControl.store';
|
import { useSourceControlStore } from '@/stores/sourceControl.store';
|
||||||
import ProjectCreateResource from '@/components/Projects/ProjectCreateResource.vue';
|
import ProjectCreateResource from '@/components/Projects/ProjectCreateResource.vue';
|
||||||
import { useSettingsStore } from '@/stores/settings.store';
|
import { useSettingsStore } from '@/stores/settings.store';
|
||||||
import { useOverview } from '@/composables/useOverview';
|
import { useProjectPages } from '@/composables/useProjectPages';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -21,7 +21,7 @@ const i18n = useI18n();
|
|||||||
const projectsStore = useProjectsStore();
|
const projectsStore = useProjectsStore();
|
||||||
const sourceControlStore = useSourceControlStore();
|
const sourceControlStore = useSourceControlStore();
|
||||||
const settingsStore = useSettingsStore();
|
const settingsStore = useSettingsStore();
|
||||||
const overview = useOverview();
|
const projectPages = useProjectPages();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
createFolder: [];
|
createFolder: [];
|
||||||
@@ -39,7 +39,12 @@ const headerIcon = computed((): ProjectIconType => {
|
|||||||
|
|
||||||
const projectName = computed(() => {
|
const projectName = computed(() => {
|
||||||
if (!projectsStore.currentProject) {
|
if (!projectsStore.currentProject) {
|
||||||
return i18n.baseText('projects.menu.overview');
|
if (projectPages.isOverviewSubPage) {
|
||||||
|
return i18n.baseText('projects.menu.overview');
|
||||||
|
} else if (projectPages.isSharedSubPage) {
|
||||||
|
return i18n.baseText('projects.header.shared.title');
|
||||||
|
}
|
||||||
|
return null;
|
||||||
} else if (projectsStore.currentProject.type === ProjectTypes.Personal) {
|
} else if (projectsStore.currentProject.type === ProjectTypes.Personal) {
|
||||||
return i18n.baseText('projects.menu.personal');
|
return i18n.baseText('projects.menu.personal');
|
||||||
} else {
|
} else {
|
||||||
@@ -60,6 +65,10 @@ const showSettings = computed(
|
|||||||
|
|
||||||
const homeProject = computed(() => projectsStore.currentProject ?? projectsStore.personalProject);
|
const homeProject = computed(() => projectsStore.currentProject ?? projectsStore.personalProject);
|
||||||
|
|
||||||
|
const isPersonalProject = computed(() => {
|
||||||
|
return homeProject.value?.type === ProjectTypes.Personal;
|
||||||
|
});
|
||||||
|
|
||||||
const showFolders = computed(() => {
|
const showFolders = computed(() => {
|
||||||
return (
|
return (
|
||||||
settingsStore.isFoldersFeatureEnabled &&
|
settingsStore.isFoldersFeatureEnabled &&
|
||||||
@@ -83,6 +92,7 @@ const createWorkflowButton = computed(() => ({
|
|||||||
sourceControlStore.preferences.branchReadOnly ||
|
sourceControlStore.preferences.branchReadOnly ||
|
||||||
!getResourcePermissions(homeProject.value?.scopes).workflow.create,
|
!getResourcePermissions(homeProject.value?.scopes).workflow.create,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const menu = computed(() => {
|
const menu = computed(() => {
|
||||||
const items: UserAction[] = [
|
const items: UserAction[] = [
|
||||||
{
|
{
|
||||||
@@ -105,6 +115,12 @@ const menu = computed(() => {
|
|||||||
return items;
|
return items;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const showProjectIcon = computed(() => {
|
||||||
|
return (
|
||||||
|
!projectPages.isOverviewSubPage && !projectPages.isSharedSubPage && !isPersonalProject.value
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
const actions: Record<ActionTypes, (projectId: string) => void> = {
|
const actions: Record<ActionTypes, (projectId: string) => void> = {
|
||||||
[ACTION_TYPES.WORKFLOW]: (projectId: string) => {
|
[ACTION_TYPES.WORKFLOW]: (projectId: string) => {
|
||||||
void router.push({
|
void router.push({
|
||||||
@@ -129,6 +145,27 @@ const actions: Record<ActionTypes, (projectId: string) => void> = {
|
|||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
const pageType = computed(() => {
|
||||||
|
if (projectPages.isOverviewSubPage) {
|
||||||
|
return 'overview';
|
||||||
|
} else if (projectPages.isSharedSubPage) {
|
||||||
|
return 'shared';
|
||||||
|
} else {
|
||||||
|
return 'project';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const subtitle = computed(() => {
|
||||||
|
if (projectPages.isOverviewSubPage) {
|
||||||
|
return i18n.baseText('projects.header.overview.subtitle');
|
||||||
|
} else if (projectPages.isSharedSubPage) {
|
||||||
|
return i18n.baseText('projects.header.shared.subtitle');
|
||||||
|
} else if (isPersonalProject.value) {
|
||||||
|
return i18n.baseText('projects.header.personal.subtitle');
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
const onSelect = (action: string) => {
|
const onSelect = (action: string) => {
|
||||||
const executableAction = actions[action as ActionTypes];
|
const executableAction = actions[action as ActionTypes];
|
||||||
if (!homeProject.value) {
|
if (!homeProject.value) {
|
||||||
@@ -142,19 +179,16 @@ const onSelect = (action: string) => {
|
|||||||
<div>
|
<div>
|
||||||
<div :class="$style.projectHeader">
|
<div :class="$style.projectHeader">
|
||||||
<div :class="$style.projectDetails">
|
<div :class="$style.projectDetails">
|
||||||
<ProjectIcon
|
<ProjectIcon v-if="showProjectIcon" :icon="headerIcon" :border-less="true" size="medium" />
|
||||||
v-if="!overview.isOverviewSubPage"
|
|
||||||
:icon="headerIcon"
|
|
||||||
:border-less="true"
|
|
||||||
size="medium"
|
|
||||||
/>
|
|
||||||
<div :class="$style.headerActions">
|
<div :class="$style.headerActions">
|
||||||
<N8nHeading bold tag="h2" size="xlarge">{{ projectName }}</N8nHeading>
|
<N8nHeading v-if="projectName" bold tag="h2" size="xlarge" data-test-id="project-name">{{
|
||||||
|
projectName
|
||||||
|
}}</N8nHeading>
|
||||||
<N8nText color="text-light">
|
<N8nText color="text-light">
|
||||||
<slot name="subtitle">
|
<slot name="subtitle">
|
||||||
<span v-if="!projectsStore.currentProject">{{
|
<N8nText v-if="subtitle" color="text-light" data-test-id="project-subtitle">{{
|
||||||
i18n.baseText('projects.header.subtitle')
|
subtitle
|
||||||
}}</span>
|
}}</N8nText>
|
||||||
</slot>
|
</slot>
|
||||||
</N8nText>
|
</N8nText>
|
||||||
</div>
|
</div>
|
||||||
@@ -181,7 +215,11 @@ const onSelect = (action: string) => {
|
|||||||
</div>
|
</div>
|
||||||
<slot></slot>
|
<slot></slot>
|
||||||
<div :class="$style.actions">
|
<div :class="$style.actions">
|
||||||
<ProjectTabs :show-settings="showSettings" />
|
<ProjectTabs
|
||||||
|
:page-type="pageType"
|
||||||
|
:show-executions="!projectPages.isSharedSubPage"
|
||||||
|
:show-settings="showSettings"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ const settingsStore = useSettingsStore();
|
|||||||
|
|
||||||
const isCreatingProject = computed(() => globalEntityCreation.isCreatingProject.value);
|
const isCreatingProject = computed(() => globalEntityCreation.isCreatingProject.value);
|
||||||
const displayProjects = computed(() => globalEntityCreation.displayProjects.value);
|
const displayProjects = computed(() => globalEntityCreation.displayProjects.value);
|
||||||
// TODO: Once we remove the feature flag, we can remove this computed property
|
|
||||||
const isFoldersFeatureEnabled = computed(() => settingsStore.isFoldersFeatureEnabled);
|
const isFoldersFeatureEnabled = computed(() => settingsStore.isFoldersFeatureEnabled);
|
||||||
|
|
||||||
const home = computed<IMenuItem>(() => ({
|
const home = computed<IMenuItem>(() => ({
|
||||||
@@ -35,6 +34,15 @@ const home = computed<IMenuItem>(() => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const shared = computed<IMenuItem>(() => ({
|
||||||
|
id: 'shared',
|
||||||
|
label: locale.baseText('projects.menu.shared'),
|
||||||
|
icon: 'share',
|
||||||
|
route: {
|
||||||
|
to: { name: VIEWS.SHARED_WITH_ME },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
const getProjectMenuItem = (project: ProjectListItem) => ({
|
const getProjectMenuItem = (project: ProjectListItem) => ({
|
||||||
id: project.id,
|
id: project.id,
|
||||||
label: project.name,
|
label: project.name,
|
||||||
@@ -74,6 +82,22 @@ const showAddFirstProject = computed(
|
|||||||
mode="tabs"
|
mode="tabs"
|
||||||
data-test-id="project-home-menu-item"
|
data-test-id="project-home-menu-item"
|
||||||
/>
|
/>
|
||||||
|
<N8nMenuItem
|
||||||
|
v-if="projectsStore.isTeamProjectFeatureEnabled || isFoldersFeatureEnabled"
|
||||||
|
:item="personalProject"
|
||||||
|
:compact="props.collapsed"
|
||||||
|
:active-tab="projectsStore.projectNavActiveId"
|
||||||
|
mode="tabs"
|
||||||
|
data-test-id="project-personal-menu-item"
|
||||||
|
/>
|
||||||
|
<N8nMenuItem
|
||||||
|
v-if="projectsStore.isTeamProjectFeatureEnabled || isFoldersFeatureEnabled"
|
||||||
|
:item="shared"
|
||||||
|
:compact="props.collapsed"
|
||||||
|
:active-tab="projectsStore.projectNavActiveId"
|
||||||
|
mode="tabs"
|
||||||
|
data-test-id="project-shared-menu-item"
|
||||||
|
/>
|
||||||
</ElMenu>
|
</ElMenu>
|
||||||
<hr v-if="projectsStore.isTeamProjectFeatureEnabled" class="mt-m mb-m" />
|
<hr v-if="projectsStore.isTeamProjectFeatureEnabled" class="mt-m mb-m" />
|
||||||
<N8nText
|
<N8nText
|
||||||
@@ -104,13 +128,6 @@ const showAddFirstProject = computed(
|
|||||||
:collapse="props.collapsed"
|
:collapse="props.collapsed"
|
||||||
:class="$style.projectItems"
|
:class="$style.projectItems"
|
||||||
>
|
>
|
||||||
<N8nMenuItem
|
|
||||||
:item="personalProject"
|
|
||||||
:compact="props.collapsed"
|
|
||||||
:active-tab="projectsStore.projectNavActiveId"
|
|
||||||
mode="tabs"
|
|
||||||
data-test-id="project-personal-menu-item"
|
|
||||||
/>
|
|
||||||
<N8nMenuItem
|
<N8nMenuItem
|
||||||
v-for="project in displayProjects"
|
v-for="project in displayProjects"
|
||||||
:key="project.id"
|
:key="project.id"
|
||||||
|
|||||||
@@ -4,75 +4,106 @@ import type { RouteRecordName } from 'vue-router';
|
|||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
import { VIEWS } from '@/constants';
|
import { VIEWS } from '@/constants';
|
||||||
import { useI18n } from '@/composables/useI18n';
|
import { useI18n } from '@/composables/useI18n';
|
||||||
|
import type { BaseTextKey } from '@/plugins/i18n';
|
||||||
|
|
||||||
const props = defineProps<{
|
type Props = {
|
||||||
showSettings?: boolean;
|
showSettings?: boolean;
|
||||||
}>();
|
showExecutions?: boolean;
|
||||||
|
pageType?: 'overview' | 'shared' | 'project';
|
||||||
|
};
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
showSettings: false,
|
||||||
|
showExecutions: true,
|
||||||
|
pageType: 'project',
|
||||||
|
});
|
||||||
|
|
||||||
const locale = useI18n();
|
const locale = useI18n();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
|
||||||
const selectedTab = ref<RouteRecordName | null | undefined>('');
|
const selectedTab = ref<RouteRecordName | null | undefined>('');
|
||||||
|
|
||||||
|
const projectId = computed(() => {
|
||||||
|
return Array.isArray(route?.params?.projectId)
|
||||||
|
? route.params.projectId[0]
|
||||||
|
: route?.params?.projectId;
|
||||||
|
});
|
||||||
|
|
||||||
|
const getRouteConfigs = () => {
|
||||||
|
// For project pages
|
||||||
|
if (projectId.value) {
|
||||||
|
return {
|
||||||
|
workflows: {
|
||||||
|
name: VIEWS.PROJECTS_WORKFLOWS,
|
||||||
|
params: { projectId: projectId.value },
|
||||||
|
},
|
||||||
|
credentials: {
|
||||||
|
name: VIEWS.PROJECTS_CREDENTIALS,
|
||||||
|
params: { projectId: projectId.value },
|
||||||
|
},
|
||||||
|
executions: {
|
||||||
|
name: VIEWS.PROJECTS_EXECUTIONS,
|
||||||
|
params: { projectId: projectId.value },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shared with me
|
||||||
|
if (props.pageType === 'shared') {
|
||||||
|
return {
|
||||||
|
workflows: { name: VIEWS.SHARED_WORKFLOWS },
|
||||||
|
credentials: { name: VIEWS.SHARED_CREDENTIALS },
|
||||||
|
executions: { name: VIEWS.NOT_FOUND },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Overview
|
||||||
|
return {
|
||||||
|
workflows: { name: VIEWS.WORKFLOWS },
|
||||||
|
credentials: { name: VIEWS.CREDENTIALS },
|
||||||
|
executions: { name: VIEWS.EXECUTIONS },
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create individual tab objects
|
||||||
|
const createTab = (
|
||||||
|
label: BaseTextKey,
|
||||||
|
routeKey: string,
|
||||||
|
routes: Record<string, { name: RouteRecordName; params?: Record<string, string | number> }>,
|
||||||
|
) => {
|
||||||
|
return {
|
||||||
|
label: locale.baseText(label),
|
||||||
|
value: routes[routeKey].name,
|
||||||
|
to: routes[routeKey],
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Generate the tabs configuration
|
||||||
const options = computed(() => {
|
const options = computed(() => {
|
||||||
const projectId = route?.params?.projectId;
|
const routes = getRouteConfigs();
|
||||||
const to = projectId
|
|
||||||
? {
|
|
||||||
workflows: {
|
|
||||||
name: VIEWS.PROJECTS_WORKFLOWS,
|
|
||||||
params: { projectId },
|
|
||||||
},
|
|
||||||
credentials: {
|
|
||||||
name: VIEWS.PROJECTS_CREDENTIALS,
|
|
||||||
params: { projectId },
|
|
||||||
},
|
|
||||||
executions: {
|
|
||||||
name: VIEWS.PROJECTS_EXECUTIONS,
|
|
||||||
params: { projectId },
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
workflows: {
|
|
||||||
name: VIEWS.WORKFLOWS,
|
|
||||||
},
|
|
||||||
credentials: {
|
|
||||||
name: VIEWS.CREDENTIALS,
|
|
||||||
},
|
|
||||||
executions: {
|
|
||||||
name: VIEWS.EXECUTIONS,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{
|
createTab('mainSidebar.workflows', 'workflows', routes),
|
||||||
label: locale.baseText('mainSidebar.workflows'),
|
createTab('mainSidebar.credentials', 'credentials', routes),
|
||||||
value: to.workflows.name,
|
|
||||||
to: to.workflows,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: locale.baseText('mainSidebar.credentials'),
|
|
||||||
value: to.credentials.name,
|
|
||||||
to: to.credentials,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: locale.baseText('mainSidebar.executions'),
|
|
||||||
value: to.executions.name,
|
|
||||||
to: to.executions,
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
if (props.showExecutions) {
|
||||||
|
tabs.push(createTab('mainSidebar.executions', 'executions', routes));
|
||||||
|
}
|
||||||
|
|
||||||
if (props.showSettings) {
|
if (props.showSettings) {
|
||||||
tabs.push({
|
tabs.push({
|
||||||
label: locale.baseText('projects.settings'),
|
label: locale.baseText('projects.settings'),
|
||||||
value: VIEWS.PROJECT_SETTINGS,
|
value: VIEWS.PROJECT_SETTINGS,
|
||||||
to: { name: VIEWS.PROJECT_SETTINGS, params: { projectId } },
|
to: { name: VIEWS.PROJECT_SETTINGS, params: { projectId: projectId.value } },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return tabs;
|
return tabs;
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => route?.name,
|
() => route?.name,
|
||||||
() => {
|
() => {
|
||||||
selectedTab.value = route?.name;
|
|
||||||
// Select workflows tab if folders tab is selected
|
// Select workflows tab if folders tab is selected
|
||||||
selectedTab.value =
|
selectedTab.value =
|
||||||
route.name === VIEWS.PROJECTS_FOLDERS ? VIEWS.PROJECTS_WORKFLOWS : route.name;
|
route.name === VIEWS.PROJECTS_FOLDERS ? VIEWS.PROJECTS_WORKFLOWS : route.name;
|
||||||
|
|||||||
@@ -297,14 +297,11 @@ watch(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
watch(
|
watch([() => route.params?.projectId, () => route.name], async () => {
|
||||||
() => route?.params?.projectId,
|
await resetFilters();
|
||||||
async () => {
|
await loadPaginationPreferences();
|
||||||
await resetFilters();
|
await props.initialize();
|
||||||
await loadPaginationPreferences();
|
});
|
||||||
await props.initialize();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// Lifecycle hooks
|
// Lifecycle hooks
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ const mockOverview = {
|
|||||||
// Create a shared storage object that persists between calls
|
// Create a shared storage object that persists between calls
|
||||||
let mockLocalStorageValue: Record<string, unknown> = {};
|
let mockLocalStorageValue: Record<string, unknown> = {};
|
||||||
|
|
||||||
vi.mock('@/composables/useOverview', () => ({
|
vi.mock('@/composables/useProjectPages', () => ({
|
||||||
useOverview: vi.fn(() => mockOverview),
|
useProjectPages: vi.fn(() => mockOverview),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('@vueuse/core', () => ({
|
vi.mock('@vueuse/core', () => ({
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useOverview } from '@/composables/useOverview';
|
import { useProjectPages } from '@/composables/useProjectPages';
|
||||||
import { LOCAL_STORAGE_WORKFLOW_LIST_PREFERENCES_KEY } from '@/constants';
|
import { LOCAL_STORAGE_WORKFLOW_LIST_PREFERENCES_KEY } from '@/constants';
|
||||||
import { useLocalStorage } from '@vueuse/core';
|
import { useLocalStorage } from '@vueuse/core';
|
||||||
|
|
||||||
@@ -22,10 +22,10 @@ export type WorkflowListPreferences = {
|
|||||||
* Currently only used for workflow list user preferences.
|
* Currently only used for workflow list user preferences.
|
||||||
*/
|
*/
|
||||||
export function useN8nLocalStorage() {
|
export function useN8nLocalStorage() {
|
||||||
const overview = useOverview();
|
const projectPages = useProjectPages();
|
||||||
|
|
||||||
const getProjectKey = (projectId?: string) => {
|
const getProjectKey = (projectId?: string) => {
|
||||||
return overview.isOverviewSubPage ? 'home' : projectId;
|
return projectPages.isOverviewSubPage ? 'home' : projectId;
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveProjectPreferencesToLocalStorage = (
|
const saveProjectPreferencesToLocalStorage = (
|
||||||
|
|||||||
@@ -2,7 +2,10 @@ import { computed, reactive } from 'vue';
|
|||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
import { VIEWS } from '@/constants';
|
import { VIEWS } from '@/constants';
|
||||||
|
|
||||||
export const useOverview = () => {
|
/**
|
||||||
|
* This composable holds reusable logic that detects the current page type
|
||||||
|
*/
|
||||||
|
export const useProjectPages = () => {
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
|
||||||
const isOverviewSubPage = computed(
|
const isOverviewSubPage = computed(
|
||||||
@@ -14,7 +17,15 @@ export const useOverview = () => {
|
|||||||
route.name === VIEWS.FOLDERS,
|
route.name === VIEWS.FOLDERS,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const isSharedSubPage = computed(
|
||||||
|
() =>
|
||||||
|
route.name === VIEWS.SHARED_WITH_ME ||
|
||||||
|
route.name === VIEWS.SHARED_WORKFLOWS ||
|
||||||
|
route.name === VIEWS.SHARED_CREDENTIALS,
|
||||||
|
);
|
||||||
|
|
||||||
return reactive({
|
return reactive({
|
||||||
isOverviewSubPage,
|
isOverviewSubPage,
|
||||||
|
isSharedSubPage,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -563,6 +563,9 @@ export const enum VIEWS {
|
|||||||
FOLDERS = 'Folders',
|
FOLDERS = 'Folders',
|
||||||
PROJECTS_FOLDERS = 'ProjectsFolders',
|
PROJECTS_FOLDERS = 'ProjectsFolders',
|
||||||
INSIGHTS = 'Insights',
|
INSIGHTS = 'Insights',
|
||||||
|
SHARED_WITH_ME = 'SharedWithMe',
|
||||||
|
SHARED_WORKFLOWS = 'SharedWorkflows',
|
||||||
|
SHARED_CREDENTIALS = 'SharedCredentials',
|
||||||
}
|
}
|
||||||
|
|
||||||
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];
|
||||||
|
|||||||
@@ -2516,6 +2516,8 @@
|
|||||||
"workflows.empty.learnN8n": "Learn n8n",
|
"workflows.empty.learnN8n": "Learn n8n",
|
||||||
"workflows.empty.button.disabled.tooltip": "Your current role in the project does not allow you to create workflows",
|
"workflows.empty.button.disabled.tooltip": "Your current role in the project does not allow you to create workflows",
|
||||||
"workflows.empty.easyAI": "Test a simple AI Agent example",
|
"workflows.empty.easyAI": "Test a simple AI Agent example",
|
||||||
|
"workflows.empty.shared-with-me": "No {resource} have been shared with you",
|
||||||
|
"workflows.empty.shared-with-me.link": "<a href=\"#\">Back to Personal</a>",
|
||||||
"workflows.list.easyAI": "Test the power of AI in n8n with this simple AI Agent Workflow",
|
"workflows.list.easyAI": "Test the power of AI in n8n with this simple AI Agent Workflow",
|
||||||
"workflows.list.error.fetching": "Error fetching workflows",
|
"workflows.list.error.fetching": "Error fetching workflows",
|
||||||
"workflows.shareModal.title": "Share '{name}'",
|
"workflows.shareModal.title": "Share '{name}'",
|
||||||
@@ -2746,7 +2748,10 @@
|
|||||||
"settings.mfa.title": "Multi-factor Authentication",
|
"settings.mfa.title": "Multi-factor Authentication",
|
||||||
"settings.mfa.updateConfiguration": "MFA configuration updated",
|
"settings.mfa.updateConfiguration": "MFA configuration updated",
|
||||||
"settings.mfa.invalidAuthenticatorCode": "Invalid authenticator code",
|
"settings.mfa.invalidAuthenticatorCode": "Invalid authenticator code",
|
||||||
"projects.header.subtitle": "All the workflows, credentials and executions you have access to",
|
"projects.header.overview.subtitle": "All the workflows, credentials and executions you have access to",
|
||||||
|
"projects.header.shared.title": "Shared with you",
|
||||||
|
"projects.header.personal.subtitle": "Workflows and credentials owned by you",
|
||||||
|
"projects.header.shared.subtitle": "Workflows and credentials other users have shared with you",
|
||||||
"projects.header.create.workflow": "Create Workflow",
|
"projects.header.create.workflow": "Create Workflow",
|
||||||
"projects.header.create.credential": "Create Credential",
|
"projects.header.create.credential": "Create Credential",
|
||||||
"projects.header.create.folder": "Create Folder",
|
"projects.header.create.folder": "Create Folder",
|
||||||
@@ -2754,6 +2759,7 @@
|
|||||||
"projects.create.personal": "Create in personal",
|
"projects.create.personal": "Create in personal",
|
||||||
"projects.create.team": "Create in project",
|
"projects.create.team": "Create in project",
|
||||||
"projects.menu.overview": "Overview",
|
"projects.menu.overview": "Overview",
|
||||||
|
"projects.menu.shared": "Shared with you",
|
||||||
"projects.menu.title": "Projects",
|
"projects.menu.title": "Projects",
|
||||||
"projects.menu.personal": "Personal",
|
"projects.menu.personal": "Personal",
|
||||||
"projects.menu.addFirstProject": "Add project",
|
"projects.menu.addFirstProject": "Add project",
|
||||||
|
|||||||
@@ -132,6 +132,7 @@ import {
|
|||||||
faSearchPlus,
|
faSearchPlus,
|
||||||
faServer,
|
faServer,
|
||||||
faScrewdriver,
|
faScrewdriver,
|
||||||
|
faShare,
|
||||||
faSmile,
|
faSmile,
|
||||||
faSignInAlt,
|
faSignInAlt,
|
||||||
faSignOutAlt,
|
faSignOutAlt,
|
||||||
@@ -337,6 +338,7 @@ export const FontAwesomePlugin: Plugin = {
|
|||||||
addIcon(faSearchPlus);
|
addIcon(faSearchPlus);
|
||||||
addIcon(faServer);
|
addIcon(faServer);
|
||||||
addIcon(faScrewdriver);
|
addIcon(faScrewdriver);
|
||||||
|
addIcon(faShare);
|
||||||
addIcon(faSmile);
|
addIcon(faSmile);
|
||||||
addIcon(faSignInAlt);
|
addIcon(faSignInAlt);
|
||||||
addIcon(faSignOutAlt);
|
addIcon(faSignOutAlt);
|
||||||
|
|||||||
@@ -160,6 +160,45 @@ export const projectsRoutes: RouteRecordRaw[] = [
|
|||||||
name: commonChildRouteExtensions.home[idx].name,
|
name: commonChildRouteExtensions.home[idx].name,
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/shared',
|
||||||
|
name: VIEWS.SHARED_WITH_ME,
|
||||||
|
meta: {
|
||||||
|
middleware: ['authenticated'],
|
||||||
|
},
|
||||||
|
redirect: '/shared/workflows',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: 'workflows',
|
||||||
|
name: VIEWS.SHARED_WORKFLOWS,
|
||||||
|
components: {
|
||||||
|
default: WorkflowsView,
|
||||||
|
sidebar: MainSidebar,
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
middleware: ['authenticated', 'custom'],
|
||||||
|
middlewareOptions: {
|
||||||
|
custom: (options) => checkProjectAvailability(options?.to),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'credentials/:credentialId?',
|
||||||
|
props: true,
|
||||||
|
name: VIEWS.SHARED_CREDENTIALS,
|
||||||
|
components: {
|
||||||
|
default: CredentialsView,
|
||||||
|
sidebar: MainSidebar,
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
middleware: ['authenticated', 'custom'],
|
||||||
|
middlewareOptions: {
|
||||||
|
custom: (options) => checkProjectAvailability(options?.to),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/workflows',
|
path: '/workflows',
|
||||||
redirect: '/home/workflows',
|
redirect: '/home/workflows',
|
||||||
|
|||||||
@@ -262,6 +262,7 @@ export const useCredentialsStore = defineStore(STORES.CREDENTIALS, () => {
|
|||||||
const fetchAllCredentials = async (
|
const fetchAllCredentials = async (
|
||||||
projectId?: string,
|
projectId?: string,
|
||||||
includeScopes = true,
|
includeScopes = true,
|
||||||
|
onlySharedWithMe = false,
|
||||||
): Promise<ICredentialsResponse[]> => {
|
): Promise<ICredentialsResponse[]> => {
|
||||||
const filter = {
|
const filter = {
|
||||||
projectId,
|
projectId,
|
||||||
@@ -271,6 +272,7 @@ export const useCredentialsStore = defineStore(STORES.CREDENTIALS, () => {
|
|||||||
rootStore.restApiContext,
|
rootStore.restApiContext,
|
||||||
isEmpty(filter) ? undefined : filter,
|
isEmpty(filter) ? undefined : filter,
|
||||||
includeScopes,
|
includeScopes,
|
||||||
|
onlySharedWithMe,
|
||||||
);
|
);
|
||||||
setCredentials(credentials);
|
setCredentials(credentials);
|
||||||
return credentials;
|
return credentials;
|
||||||
|
|||||||
@@ -199,6 +199,11 @@ export const useProjectsStore = defineStore(STORES.PROJECTS, () => {
|
|||||||
setCurrentProject(null);
|
setCurrentProject(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (newRoute?.path?.includes('shared')) {
|
||||||
|
projectNavActiveId.value = 'shared';
|
||||||
|
setCurrentProject(null);
|
||||||
|
}
|
||||||
|
|
||||||
if (newRoute?.path?.includes('workflow/')) {
|
if (newRoute?.path?.includes('workflow/')) {
|
||||||
if (currentProjectId.value) {
|
if (currentProjectId.value) {
|
||||||
projectNavActiveId.value = currentProjectId.value;
|
projectNavActiveId.value = currentProjectId.value;
|
||||||
|
|||||||
@@ -550,6 +550,7 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
|
|||||||
parentFolderId?: string;
|
parentFolderId?: string;
|
||||||
} = {},
|
} = {},
|
||||||
includeFolders: boolean = false,
|
includeFolders: boolean = false,
|
||||||
|
onlySharedWithMe: boolean = false,
|
||||||
): Promise<WorkflowListResource[]> {
|
): Promise<WorkflowListResource[]> {
|
||||||
const filter = { ...filters, projectId };
|
const filter = { ...filters, projectId };
|
||||||
const options = {
|
const options = {
|
||||||
@@ -563,6 +564,7 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
|
|||||||
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,
|
includeFolders ? includeFolders : undefined,
|
||||||
|
onlySharedWithMe ? onlySharedWithMe : undefined,
|
||||||
);
|
);
|
||||||
totalWorkflowCount.value = count;
|
totalWorkflowCount.value = count;
|
||||||
// Also set fetched workflows to store
|
// Also set fetched workflows to store
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import ResourcesListLayout, {
|
|||||||
import ProjectHeader from '@/components/Projects/ProjectHeader.vue';
|
import ProjectHeader from '@/components/Projects/ProjectHeader.vue';
|
||||||
import { useDocumentTitle } from '@/composables/useDocumentTitle';
|
import { useDocumentTitle } from '@/composables/useDocumentTitle';
|
||||||
import { useI18n } from '@/composables/useI18n';
|
import { useI18n } from '@/composables/useI18n';
|
||||||
import { useOverview } from '@/composables/useOverview';
|
import { useProjectPages } from '@/composables/useProjectPages';
|
||||||
import { useTelemetry } from '@/composables/useTelemetry';
|
import { useTelemetry } from '@/composables/useTelemetry';
|
||||||
import {
|
import {
|
||||||
CREDENTIAL_EDIT_MODAL_KEY,
|
CREDENTIAL_EDIT_MODAL_KEY,
|
||||||
@@ -28,6 +28,7 @@ import { useSettingsStore } from '@/stores/settings.store';
|
|||||||
import { useSourceControlStore } from '@/stores/sourceControl.store';
|
import { useSourceControlStore } from '@/stores/sourceControl.store';
|
||||||
import { listenForModalChanges, useUIStore } from '@/stores/ui.store';
|
import { listenForModalChanges, useUIStore } from '@/stores/ui.store';
|
||||||
import { useUsersStore } from '@/stores/users.store';
|
import { useUsersStore } from '@/stores/users.store';
|
||||||
|
import type { Project } from '@/types/projects.types';
|
||||||
import { isCredentialsResource } from '@/utils/typeGuards';
|
import { isCredentialsResource } from '@/utils/typeGuards';
|
||||||
import { N8nCheckbox } from '@n8n/design-system';
|
import { N8nCheckbox } from '@n8n/design-system';
|
||||||
import { pickBy } from 'lodash-es';
|
import { pickBy } from 'lodash-es';
|
||||||
@@ -54,7 +55,7 @@ const route = useRoute();
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const telemetry = useTelemetry();
|
const telemetry = useTelemetry();
|
||||||
const i18n = useI18n();
|
const i18n = useI18n();
|
||||||
const overview = useOverview();
|
const overview = useProjectPages();
|
||||||
|
|
||||||
type Filters = BaseFilters & { type?: string[]; setupNeeded?: boolean };
|
type Filters = BaseFilters & { type?: string[]; setupNeeded?: boolean };
|
||||||
const updateFilter = (state: Filters) => {
|
const updateFilter = (state: Filters) => {
|
||||||
@@ -111,6 +112,10 @@ const projectPermissions = computed(() =>
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const personalProject = computed<Project | null>(() => {
|
||||||
|
return projectsStore.personalProject;
|
||||||
|
});
|
||||||
|
|
||||||
const setRouteCredentialId = (credentialId?: string) => {
|
const setRouteCredentialId = (credentialId?: string) => {
|
||||||
void router.replace({ params: { credentialId }, query: route.query });
|
void router.replace({ params: { credentialId }, query: route.query });
|
||||||
};
|
};
|
||||||
@@ -182,7 +187,11 @@ const initialize = async () => {
|
|||||||
useSettingsStore().isEnterpriseFeatureEnabled[EnterpriseEditionFeature.Variables];
|
useSettingsStore().isEnterpriseFeatureEnabled[EnterpriseEditionFeature.Variables];
|
||||||
|
|
||||||
const loadPromises = [
|
const loadPromises = [
|
||||||
credentialsStore.fetchAllCredentials(route?.params?.projectId as string | undefined),
|
credentialsStore.fetchAllCredentials(
|
||||||
|
route?.params?.projectId as string | undefined,
|
||||||
|
true,
|
||||||
|
overview.isSharedSubPage,
|
||||||
|
),
|
||||||
credentialsStore.fetchCredentialTypes(false),
|
credentialsStore.fetchCredentialTypes(false),
|
||||||
externalSecretsStore.fetchAllSecrets(),
|
externalSecretsStore.fetchAllSecrets(),
|
||||||
nodeTypesStore.loadNodeTypesIfNotLoaded(),
|
nodeTypesStore.loadNodeTypesIfNotLoaded(),
|
||||||
@@ -304,7 +313,13 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template #empty>
|
<template #empty>
|
||||||
|
<EmptySharedSectionActionBox
|
||||||
|
v-if="overview.isSharedSubPage && personalProject"
|
||||||
|
:personal-project="personalProject"
|
||||||
|
resource-type="credentials"
|
||||||
|
/>
|
||||||
<n8n-action-box
|
<n8n-action-box
|
||||||
|
v-else
|
||||||
data-test-id="empty-resources-list"
|
data-test-id="empty-resources-list"
|
||||||
emoji="👋"
|
emoji="👋"
|
||||||
:heading="
|
:heading="
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import GlobalExecutionsList from '@/components/executions/global/GlobalExecution
|
|||||||
import { useDocumentTitle } from '@/composables/useDocumentTitle';
|
import { useDocumentTitle } from '@/composables/useDocumentTitle';
|
||||||
import { useExternalHooks } from '@/composables/useExternalHooks';
|
import { useExternalHooks } from '@/composables/useExternalHooks';
|
||||||
import { useI18n } from '@/composables/useI18n';
|
import { useI18n } from '@/composables/useI18n';
|
||||||
import { useOverview } from '@/composables/useOverview';
|
import { useProjectPages } from '@/composables/useProjectPages';
|
||||||
import { useTelemetry } from '@/composables/useTelemetry';
|
import { useTelemetry } from '@/composables/useTelemetry';
|
||||||
import { useToast } from '@/composables/useToast';
|
import { useToast } from '@/composables/useToast';
|
||||||
import InsightsSummary from '@/features/insights/components/InsightsSummary.vue';
|
import InsightsSummary from '@/features/insights/components/InsightsSummary.vue';
|
||||||
@@ -25,7 +25,7 @@ const executionsStore = useExecutionsStore();
|
|||||||
const insightsStore = useInsightsStore();
|
const insightsStore = useInsightsStore();
|
||||||
const documentTitle = useDocumentTitle();
|
const documentTitle = useDocumentTitle();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const overview = useOverview();
|
const overview = useProjectPages();
|
||||||
|
|
||||||
const { executionsCount, executionsCountEstimated, filters, allExecutions } =
|
const { executionsCount, executionsCountEstimated, filters, allExecutions } =
|
||||||
storeToRefs(executionsStore);
|
storeToRefs(executionsStore);
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import { useTagsStore } from '@/stores/tags.store';
|
|||||||
import { createRouter, createWebHistory } from 'vue-router';
|
import { createRouter, createWebHistory } from 'vue-router';
|
||||||
import * as usersApi from '@/api/users';
|
import * as usersApi from '@/api/users';
|
||||||
import { useFoldersStore } from '@/stores/folders.store';
|
import { useFoldersStore } from '@/stores/folders.store';
|
||||||
|
import { useSettingsStore } from '@/stores/settings.store';
|
||||||
|
import { useProjectPages } from '@/composables/useProjectPages';
|
||||||
|
|
||||||
vi.mock('@/api/projects.api');
|
vi.mock('@/api/projects.api');
|
||||||
vi.mock('@/api/users');
|
vi.mock('@/api/users');
|
||||||
@@ -24,6 +26,12 @@ vi.mock('@/composables/useGlobalEntityCreation', () => ({
|
|||||||
menu: [],
|
menu: [],
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
vi.mock('@/composables/useProjectPages', () => ({
|
||||||
|
useProjectPages: vi.fn().mockReturnValue({
|
||||||
|
isOverviewSubPage: false,
|
||||||
|
isSharedSubPage: false,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(),
|
history: createWebHistory(),
|
||||||
@@ -47,6 +55,8 @@ vi.mock('@/api/usage', () => ({
|
|||||||
let pinia: ReturnType<typeof createTestingPinia>;
|
let pinia: ReturnType<typeof createTestingPinia>;
|
||||||
let foldersStore: ReturnType<typeof mockedStore<typeof useFoldersStore>>;
|
let foldersStore: ReturnType<typeof mockedStore<typeof useFoldersStore>>;
|
||||||
let workflowsStore: ReturnType<typeof mockedStore<typeof useWorkflowsStore>>;
|
let workflowsStore: ReturnType<typeof mockedStore<typeof useWorkflowsStore>>;
|
||||||
|
let settingsStore: ReturnType<typeof mockedStore<typeof useSettingsStore>>;
|
||||||
|
let projectPages: ReturnType<typeof useProjectPages>;
|
||||||
|
|
||||||
const renderComponent = createComponentRenderer(WorkflowsView, {
|
const renderComponent = createComponentRenderer(WorkflowsView, {
|
||||||
global: {
|
global: {
|
||||||
@@ -55,7 +65,12 @@ const renderComponent = createComponentRenderer(WorkflowsView, {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const initialState = {
|
const initialState = {
|
||||||
[STORES.SETTINGS]: { settings: { enterprise: { sharing: false }, folders: { enabled: false } } },
|
[STORES.SETTINGS]: {
|
||||||
|
settings: {
|
||||||
|
enterprise: { sharing: false, projects: { team: { limit: 5 } } },
|
||||||
|
folders: { enabled: false },
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('WorkflowsView', () => {
|
describe('WorkflowsView', () => {
|
||||||
@@ -65,12 +80,15 @@ describe('WorkflowsView', () => {
|
|||||||
pinia = createTestingPinia({ initialState });
|
pinia = createTestingPinia({ initialState });
|
||||||
foldersStore = mockedStore(useFoldersStore);
|
foldersStore = mockedStore(useFoldersStore);
|
||||||
workflowsStore = mockedStore(useWorkflowsStore);
|
workflowsStore = mockedStore(useWorkflowsStore);
|
||||||
|
settingsStore = mockedStore(useSettingsStore);
|
||||||
|
|
||||||
workflowsStore.fetchWorkflowsPage.mockResolvedValue([]);
|
workflowsStore.fetchWorkflowsPage.mockResolvedValue([]);
|
||||||
workflowsStore.fetchActiveWorkflows.mockResolvedValue([]);
|
workflowsStore.fetchActiveWorkflows.mockResolvedValue([]);
|
||||||
|
|
||||||
foldersStore.totalWorkflowCount = 0;
|
foldersStore.totalWorkflowCount = 0;
|
||||||
foldersStore.fetchTotalWorkflowsAndFoldersCount.mockResolvedValue(0);
|
foldersStore.fetchTotalWorkflowsAndFoldersCount.mockResolvedValue(0);
|
||||||
|
|
||||||
|
projectPages = useProjectPages();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('should show empty state', () => {
|
describe('should show empty state', () => {
|
||||||
@@ -133,6 +151,48 @@ describe('WorkflowsView', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('fetch workflow options', () => {
|
||||||
|
it('should not fetch folders for overview page', async () => {
|
||||||
|
workflowsStore.fetchWorkflowsPage.mockResolvedValue([]);
|
||||||
|
settingsStore.isFoldersFeatureEnabled = true;
|
||||||
|
vi.spyOn(projectPages, 'isOverviewSubPage', 'get').mockReturnValue(true);
|
||||||
|
vi.spyOn(projectPages, 'isSharedSubPage', 'get').mockReturnValue(false);
|
||||||
|
|
||||||
|
renderComponent({ pinia });
|
||||||
|
await waitAllPromises();
|
||||||
|
|
||||||
|
expect(workflowsStore.fetchWorkflowsPage).toHaveBeenCalledWith(
|
||||||
|
expect.any(String),
|
||||||
|
expect.any(Number),
|
||||||
|
expect.any(Number),
|
||||||
|
expect.any(String),
|
||||||
|
expect.any(Object),
|
||||||
|
false,
|
||||||
|
expect.any(Boolean),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should send proper API parameters for "Shared with you" page', async () => {
|
||||||
|
workflowsStore.fetchWorkflowsPage.mockResolvedValue([]);
|
||||||
|
settingsStore.isFoldersFeatureEnabled = true;
|
||||||
|
vi.spyOn(projectPages, 'isOverviewSubPage', 'get').mockReturnValue(false);
|
||||||
|
vi.spyOn(projectPages, 'isSharedSubPage', 'get').mockReturnValue(true);
|
||||||
|
|
||||||
|
renderComponent({ pinia });
|
||||||
|
await waitAllPromises();
|
||||||
|
|
||||||
|
expect(workflowsStore.fetchWorkflowsPage).toHaveBeenCalledWith(
|
||||||
|
expect.any(String),
|
||||||
|
expect.any(Number),
|
||||||
|
expect.any(Number),
|
||||||
|
expect.any(String),
|
||||||
|
expect.any(Object),
|
||||||
|
false, // No folders
|
||||||
|
true, // onlySharedWithMe = true
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('filters', () => {
|
describe('filters', () => {
|
||||||
it('should set tag filter based on query parameters', async () => {
|
it('should set tag filter based on query parameters', async () => {
|
||||||
await router.replace({ query: { tags: 'test-tag' } });
|
await router.replace({ query: { tags: 'test-tag' } });
|
||||||
@@ -158,6 +218,7 @@ describe('WorkflowsView', () => {
|
|||||||
tags: [TEST_TAG.name],
|
tags: [TEST_TAG.name],
|
||||||
isArchived: false,
|
isArchived: false,
|
||||||
}),
|
}),
|
||||||
|
false, // No folders if tag filter is set
|
||||||
expect.any(Boolean),
|
expect.any(Boolean),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -180,6 +241,7 @@ describe('WorkflowsView', () => {
|
|||||||
isArchived: false,
|
isArchived: false,
|
||||||
}),
|
}),
|
||||||
expect.any(Boolean),
|
expect.any(Boolean),
|
||||||
|
expect.any(Boolean),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -200,6 +262,7 @@ describe('WorkflowsView', () => {
|
|||||||
active: true,
|
active: true,
|
||||||
isArchived: false,
|
isArchived: false,
|
||||||
}),
|
}),
|
||||||
|
false, // No folders if active filter is set
|
||||||
expect.any(Boolean),
|
expect.any(Boolean),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -221,6 +284,7 @@ describe('WorkflowsView', () => {
|
|||||||
active: false,
|
active: false,
|
||||||
isArchived: false,
|
isArchived: false,
|
||||||
}),
|
}),
|
||||||
|
false,
|
||||||
expect.any(Boolean),
|
expect.any(Boolean),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -241,6 +305,7 @@ describe('WorkflowsView', () => {
|
|||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
isArchived: undefined,
|
isArchived: undefined,
|
||||||
}),
|
}),
|
||||||
|
false, // No folders if active filter is set
|
||||||
expect.any(Boolean),
|
expect.any(Boolean),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import type { DragTarget, DropTarget } from '@/composables/useFolders';
|
|||||||
import { useFolders } from '@/composables/useFolders';
|
import { useFolders } from '@/composables/useFolders';
|
||||||
import { useI18n } from '@/composables/useI18n';
|
import { useI18n } from '@/composables/useI18n';
|
||||||
import { useMessage } from '@/composables/useMessage';
|
import { useMessage } from '@/composables/useMessage';
|
||||||
import { useOverview } from '@/composables/useOverview';
|
import { useProjectPages } from '@/composables/useProjectPages';
|
||||||
import { useTelemetry } from '@/composables/useTelemetry';
|
import { useTelemetry } from '@/composables/useTelemetry';
|
||||||
import { useToast } from '@/composables/useToast';
|
import { useToast } from '@/composables/useToast';
|
||||||
import {
|
import {
|
||||||
@@ -49,7 +49,7 @@ import { useUIStore } from '@/stores/ui.store';
|
|||||||
import { useUsageStore } from '@/stores/usage.store';
|
import { useUsageStore } from '@/stores/usage.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 { type ProjectSharingData, ProjectTypes } from '@/types/projects.types';
|
import { type Project, type ProjectSharingData, ProjectTypes } from '@/types/projects.types';
|
||||||
import { getEasyAiWorkflowJson } from '@/utils/easyAiWorkflowUtils';
|
import { getEasyAiWorkflowJson } from '@/utils/easyAiWorkflowUtils';
|
||||||
import {
|
import {
|
||||||
N8nCard,
|
N8nCard,
|
||||||
@@ -112,7 +112,7 @@ const insightsStore = useInsightsStore();
|
|||||||
|
|
||||||
const documentTitle = useDocumentTitle();
|
const documentTitle = useDocumentTitle();
|
||||||
const { callDebounced } = useDebounce();
|
const { callDebounced } = useDebounce();
|
||||||
const overview = useOverview();
|
const projectPages = useProjectPages();
|
||||||
|
|
||||||
// We render component in a loading state until initialization is done
|
// We render component in a loading state until initialization is done
|
||||||
// This will prevent any additional workflow fetches while initializing
|
// This will prevent any additional workflow fetches while initializing
|
||||||
@@ -197,7 +197,6 @@ const mainBreadcrumbsActions = computed(() =>
|
|||||||
);
|
);
|
||||||
|
|
||||||
const readOnlyEnv = computed(() => sourceControlStore.preferences.branchReadOnly);
|
const readOnlyEnv = computed(() => sourceControlStore.preferences.branchReadOnly);
|
||||||
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],
|
||||||
@@ -212,7 +211,7 @@ const teamProjectsEnabled = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const showFolders = computed(() => {
|
const showFolders = computed(() => {
|
||||||
return foldersEnabled.value && !isOverviewPage.value;
|
return foldersEnabled.value && !projectPages.isOverviewSubPage && !projectPages.isSharedSubPage;
|
||||||
});
|
});
|
||||||
|
|
||||||
const currentFolder = computed(() => {
|
const currentFolder = computed(() => {
|
||||||
@@ -269,6 +268,10 @@ const currentParentName = computed(() => {
|
|||||||
return projectName.value;
|
return projectName.value;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const personalProject = computed<Project | null>(() => {
|
||||||
|
return projectsStore.personalProject;
|
||||||
|
});
|
||||||
|
|
||||||
const workflowListResources = computed<Resource[]>(() => {
|
const workflowListResources = computed<Resource[]>(() => {
|
||||||
const resources: Resource[] = (workflowsAndFolders.value || []).map((resource) => {
|
const resources: Resource[] = (workflowsAndFolders.value || []).map((resource) => {
|
||||||
if (resource.resource === 'folder') {
|
if (resource.resource === 'folder') {
|
||||||
@@ -330,7 +333,7 @@ const showEasyAIWorkflowCallout = computed(() => {
|
|||||||
|
|
||||||
const projectPermissions = computed(() => {
|
const projectPermissions = computed(() => {
|
||||||
return getResourcePermissions(
|
return getResourcePermissions(
|
||||||
projectsStore.currentProject?.scopes ?? projectsStore.personalProject?.scopes,
|
projectsStore.currentProject?.scopes ?? personalProject.value?.scopes,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -367,12 +370,9 @@ const showRegisteredCommunityCTA = computed(
|
|||||||
* WATCHERS, STORE SUBSCRIPTIONS AND EVENT BUS HANDLERS
|
* WATCHERS, STORE SUBSCRIPTIONS AND EVENT BUS HANDLERS
|
||||||
*/
|
*/
|
||||||
|
|
||||||
watch(
|
watch([() => route.params?.projectId, () => route.name], async () => {
|
||||||
() => route.params?.projectId,
|
loading.value = true;
|
||||||
async () => {
|
});
|
||||||
loading.value = true;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => route.params?.folderId,
|
() => route.params?.folderId,
|
||||||
@@ -510,11 +510,10 @@ const fetchWorkflows = async () => {
|
|||||||
active: activeFilter,
|
active: activeFilter,
|
||||||
isArchived: archivedFilter,
|
isArchived: archivedFilter,
|
||||||
tags: tags.length ? tags : undefined,
|
tags: tags.length ? tags : undefined,
|
||||||
parentFolderId:
|
parentFolderId: getParentFolderId(parentFolder),
|
||||||
parentFolder ??
|
|
||||||
(isOverviewPage.value ? undefined : filters?.value.search ? undefined : PROJECT_ROOT), // Sending 0 will only show one level of folders
|
|
||||||
},
|
},
|
||||||
fetchFolders,
|
fetchFolders,
|
||||||
|
projectPages.isSharedSubPage,
|
||||||
);
|
);
|
||||||
|
|
||||||
foldersStore.cacheFolders(
|
foldersStore.cacheFolders(
|
||||||
@@ -536,7 +535,8 @@ const fetchWorkflows = async () => {
|
|||||||
workflowsAndFolders.value = fetchedResources;
|
workflowsAndFolders.value = fetchedResources;
|
||||||
|
|
||||||
// Toggle ownership cards visibility only after we have fetched the workflows
|
// Toggle ownership cards visibility only after we have fetched the workflows
|
||||||
showCardsBadge.value = isOverviewPage.value || filters.value.search !== '';
|
showCardsBadge.value =
|
||||||
|
projectPages.isOverviewSubPage || projectPages.isSharedSubPage || filters.value.search !== '';
|
||||||
|
|
||||||
return fetchedResources;
|
return fetchedResources;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -553,11 +553,31 @@ const fetchWorkflows = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get parent folder id for filtering requests
|
||||||
|
*/
|
||||||
|
const getParentFolderId = (routeId?: string) => {
|
||||||
|
// If parentFolder is defined in route, use it
|
||||||
|
if (routeId !== null && routeId !== undefined) {
|
||||||
|
return routeId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we're on overview/shared page or searching, don't filter by parent folder
|
||||||
|
if (projectPages.isOverviewSubPage || projectPages.isSharedSubPage || filters?.value.search) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: 0 will only show one level of folders
|
||||||
|
return PROJECT_ROOT;
|
||||||
|
};
|
||||||
|
|
||||||
// Filter and sort methods
|
// Filter and sort methods
|
||||||
const onFiltersUpdated = async () => {
|
const onFiltersUpdated = async () => {
|
||||||
currentPage.value = 1;
|
currentPage.value = 1;
|
||||||
saveFiltersOnQueryString();
|
saveFiltersOnQueryString();
|
||||||
await callDebounced(fetchWorkflows, { debounceTime: FILTERS_DEBOUNCE_TIME, trailing: true });
|
if (!loading.value) {
|
||||||
|
await callDebounced(fetchWorkflows, { debounceTime: FILTERS_DEBOUNCE_TIME, trailing: true });
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onSearchUpdated = async (search: string) => {
|
const onSearchUpdated = async (search: string) => {
|
||||||
@@ -1369,7 +1389,7 @@ const onNameSubmit = async ({
|
|||||||
<template #header>
|
<template #header>
|
||||||
<ProjectHeader @create-folder="createFolderInCurrent">
|
<ProjectHeader @create-folder="createFolderInCurrent">
|
||||||
<InsightsSummary
|
<InsightsSummary
|
||||||
v-if="overview.isOverviewSubPage && insightsStore.isSummaryEnabled"
|
v-if="projectPages.isOverviewSubPage && insightsStore.isSummaryEnabled"
|
||||||
:loading="insightsStore.weeklySummary.isLoading"
|
:loading="insightsStore.weeklySummary.isLoading"
|
||||||
:summary="insightsStore.weeklySummary.state"
|
:summary="insightsStore.weeklySummary.state"
|
||||||
time-range="week"
|
time-range="week"
|
||||||
@@ -1379,10 +1399,21 @@ const onNameSubmit = async ({
|
|||||||
<template v-if="foldersEnabled || showRegisteredCommunityCTA" #add-button>
|
<template v-if="foldersEnabled || showRegisteredCommunityCTA" #add-button>
|
||||||
<N8nTooltip
|
<N8nTooltip
|
||||||
placement="top"
|
placement="top"
|
||||||
:disabled="!(isOverviewPage || (!readOnlyEnv && hasPermissionToCreateFolders))"
|
:disabled="
|
||||||
|
!(
|
||||||
|
projectPages.isOverviewSubPage ||
|
||||||
|
projectPages.isSharedSubPage ||
|
||||||
|
(!readOnlyEnv && hasPermissionToCreateFolders)
|
||||||
|
)
|
||||||
|
"
|
||||||
>
|
>
|
||||||
<template #content>
|
<template #content>
|
||||||
<span v-if="isOverviewPage && !showRegisteredCommunityCTA">
|
<span
|
||||||
|
v-if="
|
||||||
|
(projectPages.isOverviewSubPage || projectPages.isSharedSubPage) &&
|
||||||
|
!showRegisteredCommunityCTA
|
||||||
|
"
|
||||||
|
>
|
||||||
<span v-if="teamProjectsEnabled">
|
<span v-if="teamProjectsEnabled">
|
||||||
{{ i18n.baseText('folders.add.overview.withProjects.message') }}
|
{{ i18n.baseText('folders.add.overview.withProjects.message') }}
|
||||||
</span>
|
</span>
|
||||||
@@ -1413,7 +1444,7 @@ const onNameSubmit = async ({
|
|||||||
</template>
|
</template>
|
||||||
<template #callout>
|
<template #callout>
|
||||||
<N8nCallout
|
<N8nCallout
|
||||||
v-if="showEasyAIWorkflowCallout && easyAICalloutVisible"
|
v-if="!loading && showEasyAIWorkflowCallout && easyAICalloutVisible"
|
||||||
theme="secondary"
|
theme="secondary"
|
||||||
icon="robot"
|
icon="robot"
|
||||||
:class="$style['easy-ai-workflow-callout']"
|
:class="$style['easy-ai-workflow-callout']"
|
||||||
@@ -1500,7 +1531,7 @@ const onNameSubmit = async ({
|
|||||||
:read-only="
|
:read-only="
|
||||||
readOnlyEnv || (!hasPermissionToDeleteFolders && !hasPermissionToCreateFolders)
|
readOnlyEnv || (!hasPermissionToDeleteFolders && !hasPermissionToCreateFolders)
|
||||||
"
|
"
|
||||||
:personal-project="projectsStore.personalProject"
|
:personal-project="personalProject"
|
||||||
:data-resourceid="(data as FolderResource).id"
|
:data-resourceid="(data as FolderResource).id"
|
||||||
:data-resourcename="(data as FolderResource).name"
|
:data-resourcename="(data as FolderResource).name"
|
||||||
:class="{
|
:class="{
|
||||||
@@ -1565,47 +1596,54 @@ const onNameSubmit = async ({
|
|||||||
</Draggable>
|
</Draggable>
|
||||||
</template>
|
</template>
|
||||||
<template #empty>
|
<template #empty>
|
||||||
<div class="text-center mt-s" data-test-id="list-empty-state">
|
<EmptySharedSectionActionBox
|
||||||
<N8nHeading tag="h2" size="xlarge" class="mb-2xs">
|
v-if="projectPages.isSharedSubPage && personalProject"
|
||||||
{{
|
:personal-project="personalProject"
|
||||||
currentUser.firstName
|
resource-type="workflows"
|
||||||
? i18n.baseText('workflows.empty.heading', {
|
/>
|
||||||
interpolate: { name: currentUser.firstName },
|
<div v-else>
|
||||||
})
|
<div class="text-center mt-s" data-test-id="list-empty-state">
|
||||||
: i18n.baseText('workflows.empty.heading.userNotSetup')
|
<N8nHeading tag="h2" size="xlarge" class="mb-2xs">
|
||||||
}}
|
{{
|
||||||
</N8nHeading>
|
currentUser.firstName
|
||||||
<N8nText size="large" color="text-base">
|
? i18n.baseText('workflows.empty.heading', {
|
||||||
{{ emptyListDescription }}
|
interpolate: { name: currentUser.firstName },
|
||||||
</N8nText>
|
})
|
||||||
</div>
|
: i18n.baseText('workflows.empty.heading.userNotSetup')
|
||||||
<div
|
}}
|
||||||
v-if="!readOnlyEnv && projectPermissions.workflow.create"
|
</N8nHeading>
|
||||||
:class="['text-center', 'mt-2xl', $style.actionsContainer]"
|
<N8nText size="large" color="text-base">
|
||||||
>
|
{{ emptyListDescription }}
|
||||||
<N8nCard
|
|
||||||
:class="$style.emptyStateCard"
|
|
||||||
hoverable
|
|
||||||
data-test-id="new-workflow-card"
|
|
||||||
@click="addWorkflow"
|
|
||||||
>
|
|
||||||
<N8nIcon :class="$style.emptyStateCardIcon" icon="file" />
|
|
||||||
<N8nText size="large" class="mt-xs" color="text-dark">
|
|
||||||
{{ i18n.baseText('workflows.empty.startFromScratch') }}
|
|
||||||
</N8nText>
|
</N8nText>
|
||||||
</N8nCard>
|
</div>
|
||||||
<N8nCard
|
<div
|
||||||
v-if="showEasyAIWorkflowCallout"
|
v-if="!readOnlyEnv && projectPermissions.workflow.create"
|
||||||
:class="$style.emptyStateCard"
|
:class="['text-center', 'mt-2xl', $style.actionsContainer]"
|
||||||
hoverable
|
|
||||||
data-test-id="easy-ai-workflow-card"
|
|
||||||
@click="openAIWorkflow('empty')"
|
|
||||||
>
|
>
|
||||||
<N8nIcon :class="$style.emptyStateCardIcon" icon="robot" />
|
<N8nCard
|
||||||
<N8nText size="large" class="mt-xs pl-2xs pr-2xs" color="text-dark">
|
:class="$style.emptyStateCard"
|
||||||
{{ i18n.baseText('workflows.empty.easyAI') }}
|
hoverable
|
||||||
</N8nText>
|
data-test-id="new-workflow-card"
|
||||||
</N8nCard>
|
@click="addWorkflow"
|
||||||
|
>
|
||||||
|
<N8nIcon :class="$style.emptyStateCardIcon" icon="file" />
|
||||||
|
<N8nText size="large" class="mt-xs" color="text-dark">
|
||||||
|
{{ i18n.baseText('workflows.empty.startFromScratch') }}
|
||||||
|
</N8nText>
|
||||||
|
</N8nCard>
|
||||||
|
<N8nCard
|
||||||
|
v-if="showEasyAIWorkflowCallout"
|
||||||
|
:class="$style.emptyStateCard"
|
||||||
|
hoverable
|
||||||
|
data-test-id="easy-ai-workflow-card"
|
||||||
|
@click="openAIWorkflow('empty')"
|
||||||
|
>
|
||||||
|
<N8nIcon :class="$style.emptyStateCardIcon" icon="robot" />
|
||||||
|
<N8nText size="large" class="mt-xs pl-2xs pr-2xs" color="text-dark">
|
||||||
|
{{ i18n.baseText('workflows.empty.easyAI') }}
|
||||||
|
</N8nText>
|
||||||
|
</N8nCard>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template #filters="{ setKeyValue }">
|
<template #filters="{ setKeyValue }">
|
||||||
@@ -1657,12 +1695,19 @@ const onNameSubmit = async ({
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template #postamble>
|
<template #postamble>
|
||||||
|
<!-- Empty states for shared section and folders -->
|
||||||
<div
|
<div
|
||||||
v-if="workflowsAndFolders.length === 0 && currentFolder && !hasFilters"
|
v-if="workflowsAndFolders.length === 0 && !hasFilters"
|
||||||
:class="$style['empty-folder-container']"
|
:class="$style['empty-folder-container']"
|
||||||
data-test-id="empty-folder-container"
|
data-test-id="empty-folder-container"
|
||||||
>
|
>
|
||||||
|
<EmptySharedSectionActionBox
|
||||||
|
v-if="projectPages.isSharedSubPage && personalProject"
|
||||||
|
:personal-project="personalProject"
|
||||||
|
resource-type="workflows"
|
||||||
|
/>
|
||||||
<n8n-action-box
|
<n8n-action-box
|
||||||
|
v-else-if="currentFolder"
|
||||||
data-test-id="empty-folder-action-box"
|
data-test-id="empty-folder-action-box"
|
||||||
:heading="
|
:heading="
|
||||||
i18n.baseText('folders.empty.actionbox.title', {
|
i18n.baseText('folders.empty.actionbox.title', {
|
||||||
|
|||||||
Reference in New Issue
Block a user