feat(editor): Implement 'Shared with you' section in the main navigation (#15140)

This commit is contained in:
Milorad FIlipović
2025-05-08 09:24:32 +02:00
committed by GitHub
parent abdbe50907
commit 1c65e82b38
23 changed files with 537 additions and 174 deletions

View File

@@ -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 } : {}),
}); });
} }

View File

@@ -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 : {}),
}); });

View File

@@ -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"

View File

@@ -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>

View File

@@ -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,
); );
}); });

View File

@@ -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>

View File

@@ -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"

View File

@@ -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;

View File

@@ -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 () => {

View File

@@ -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', () => ({

View File

@@ -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 = (

View File

@@ -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,
}); });
}; };

View File

@@ -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];

View File

@@ -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",

View File

@@ -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);

View File

@@ -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',

View File

@@ -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;

View File

@@ -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;

View File

@@ -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

View File

@@ -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="

View File

@@ -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);

View File

@@ -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),
); );
}); });

View File

@@ -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', {