From 1c65e82b38b78da73a038e2e43743311c6a0ef12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Milorad=20FIlipovi=C4=87?= Date: Thu, 8 May 2025 09:24:32 +0200 Subject: [PATCH] feat(editor): Implement 'Shared with you' section in the main navigation (#15140) --- .../frontend/editor-ui/src/api/credentials.ts | 2 + .../frontend/editor-ui/src/api/workflows.ts | 2 + .../src/components/CredentialCard.vue | 1 + .../Folders/EmptySharedSectionActionBox.vue | 46 +++++ .../components/Projects/ProjectHeader.test.ts | 80 ++++++--- .../src/components/Projects/ProjectHeader.vue | 66 +++++-- .../components/Projects/ProjectNavigation.vue | 33 +++- .../src/components/Projects/ProjectTabs.vue | 123 ++++++++----- .../layouts/ResourcesListLayout.vue | 13 +- .../composables/useN8nLocalStorage.test.ts | 4 +- .../src/composables/useN8nLocalStorage.ts | 6 +- .../{useOverview.ts => useProjectPages.ts} | 13 +- packages/frontend/editor-ui/src/constants.ts | 3 + .../src/plugins/i18n/locales/en.json | 8 +- .../editor-ui/src/plugins/icons/index.ts | 2 + .../editor-ui/src/routes/projects.routes.ts | 39 ++++ .../editor-ui/src/stores/credentials.store.ts | 2 + .../editor-ui/src/stores/projects.store.ts | 5 + .../editor-ui/src/stores/workflows.store.ts | 2 + .../editor-ui/src/views/CredentialsView.vue | 21 ++- .../editor-ui/src/views/ExecutionsView.vue | 4 +- .../editor-ui/src/views/WorkflowsView.test.ts | 67 ++++++- .../editor-ui/src/views/WorkflowsView.vue | 169 +++++++++++------- 23 files changed, 537 insertions(+), 174 deletions(-) create mode 100644 packages/frontend/editor-ui/src/components/Folders/EmptySharedSectionActionBox.vue rename packages/frontend/editor-ui/src/composables/{useOverview.ts => useProjectPages.ts} (57%) diff --git a/packages/frontend/editor-ui/src/api/credentials.ts b/packages/frontend/editor-ui/src/api/credentials.ts index cfc8407830..d32dede2b5 100644 --- a/packages/frontend/editor-ui/src/api/credentials.ts +++ b/packages/frontend/editor-ui/src/api/credentials.ts @@ -30,11 +30,13 @@ export async function getAllCredentials( context: IRestApiContext, filter?: object, includeScopes?: boolean, + onlySharedWithMe?: boolean, ): Promise { return await makeRestApiRequest(context, 'GET', '/credentials', { ...(includeScopes ? { includeScopes } : {}), includeData: true, ...(filter ? { filter } : {}), + ...(onlySharedWithMe ? { onlySharedWithMe } : {}), }); } diff --git a/packages/frontend/editor-ui/src/api/workflows.ts b/packages/frontend/editor-ui/src/api/workflows.ts index cea8c39303..f9848f4342 100644 --- a/packages/frontend/editor-ui/src/api/workflows.ts +++ b/packages/frontend/editor-ui/src/api/workflows.ts @@ -47,10 +47,12 @@ export async function getWorkflowsAndFolders( filter?: object, options?: object, includeFolders?: boolean, + onlySharedWithMe?: boolean, ) { return await getFullApiResponse(context, 'GET', '/workflows', { includeScopes: true, includeFolders, + onlySharedWithMe, ...(filter ? { filter } : {}), ...(options ? options : {}), }); diff --git a/packages/frontend/editor-ui/src/components/CredentialCard.vue b/packages/frontend/editor-ui/src/components/CredentialCard.vue index 2ebb9033d0..d4f8c4f0a7 100644 --- a/packages/frontend/editor-ui/src/components/CredentialCard.vue +++ b/packages/frontend/editor-ui/src/components/CredentialCard.vue @@ -163,6 +163,7 @@ function moveResource() { :resource-type="ResourceType.Credential" :resource-type-label="resourceTypeLabel" :personal-project="projectsStore.personalProject" + :show-badge-border="false" /> +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(), { + 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 }, + }); +}; + + + diff --git a/packages/frontend/editor-ui/src/components/Projects/ProjectHeader.test.ts b/packages/frontend/editor-ui/src/components/Projects/ProjectHeader.test.ts index 57f5c2cd62..6fd0f9f20b 100644 --- a/packages/frontend/editor-ui/src/components/Projects/ProjectHeader.test.ts +++ b/packages/frontend/editor-ui/src/components/Projects/ProjectHeader.test.ts @@ -12,7 +12,7 @@ import { VIEWS } from '@/constants'; import userEvent from '@testing-library/user-event'; import { waitFor, within } from '@testing-library/vue'; import { useSettingsStore } from '@/stores/settings.store'; -import { useOverview } from '@/composables/useOverview'; +import { useProjectPages } from '@/composables/useProjectPages'; const mockPush = vi.fn(); vi.mock('vue-router', async () => { @@ -31,9 +31,10 @@ vi.mock('vue-router', async () => { }; }); -vi.mock('@/composables/useOverview', () => ({ - useOverview: vi.fn().mockReturnValue({ +vi.mock('@/composables/useProjectPages', () => ({ + useProjectPages: vi.fn().mockReturnValue({ isOverviewSubPage: false, + isSharedSubPage: false, }), })); @@ -52,7 +53,7 @@ const renderComponent = createComponentRenderer(ProjectHeader, { let route: ReturnType; let projectsStore: ReturnType>; let settingsStore: ReturnType>; -let overview: ReturnType; +let projectPages: ReturnType; describe('ProjectHeader', () => { beforeEach(() => { @@ -60,7 +61,7 @@ describe('ProjectHeader', () => { route = router.useRoute(); projectsStore = mockedStore(useProjectsStore); settingsStore = mockedStore(useSettingsStore); - overview = useOverview(); + projectPages = useProjectPages(); projectsStore.teamProjectsLimit = -1; settingsStore.settings.folders = { enabled: false }; @@ -71,19 +72,20 @@ describe('ProjectHeader', () => { }); 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(); expect(container.querySelector('.fa-home')).not.toBeInTheDocument(); }); 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(); + // We no longer render icon for personal project projectsStore.currentProject = { type: ProjectTypes.Personal } as Project; await rerender({}); - expect(container.querySelector('.fa-user')).toBeVisible(); + expect(container.querySelector('.fa-user')).not.toBeInTheDocument(); const projectName = 'My Project'; projectsStore.currentProject = { name: projectName } as Project; @@ -91,23 +93,55 @@ describe('ProjectHeader', () => { expect(container.querySelector('.fa-layer-group')).toBeVisible(); }); - it('should render the correct title and subtitle', async () => { - const { getByText, queryByText, rerender } = renderComponent(); - const subtitle = 'All the workflows, credentials and executions you have access to'; + it('Overview: should render the correct title and subtitle', async () => { + vi.spyOn(projectPages, 'isOverviewSubPage', 'get').mockReturnValue(true); + const { getByTestId, rerender } = renderComponent(); + const overviewSubtitle = 'All the workflows, credentials and executions you have access to'; - expect(getByText('Overview')).toBeVisible(); - expect(getByText(subtitle)).toBeVisible(); + await rerender({}); + + 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; + 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'; projectsStore.currentProject = { name: projectName } as Project; + 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', () => { @@ -130,9 +164,9 @@ describe('ProjectHeader', () => { renderComponent(); expect(projectTabsSpy).toHaveBeenCalledWith( - { + expect.objectContaining({ 'show-settings': true, - }, + }), null, ); }); @@ -143,9 +177,9 @@ describe('ProjectHeader', () => { renderComponent(); expect(projectTabsSpy).toHaveBeenCalledWith( - { + expect.objectContaining({ 'show-settings': false, - }, + }), null, ); }); @@ -159,9 +193,9 @@ describe('ProjectHeader', () => { renderComponent(); expect(projectTabsSpy).toHaveBeenCalledWith( - { + expect.objectContaining({ 'show-settings': false, - }, + }), null, ); }); diff --git a/packages/frontend/editor-ui/src/components/Projects/ProjectHeader.vue b/packages/frontend/editor-ui/src/components/Projects/ProjectHeader.vue index 0450e200b6..7e58022f8a 100644 --- a/packages/frontend/editor-ui/src/components/Projects/ProjectHeader.vue +++ b/packages/frontend/editor-ui/src/components/Projects/ProjectHeader.vue @@ -13,7 +13,7 @@ import { VIEWS } from '@/constants'; import { useSourceControlStore } from '@/stores/sourceControl.store'; import ProjectCreateResource from '@/components/Projects/ProjectCreateResource.vue'; import { useSettingsStore } from '@/stores/settings.store'; -import { useOverview } from '@/composables/useOverview'; +import { useProjectPages } from '@/composables/useProjectPages'; const route = useRoute(); const router = useRouter(); @@ -21,7 +21,7 @@ const i18n = useI18n(); const projectsStore = useProjectsStore(); const sourceControlStore = useSourceControlStore(); const settingsStore = useSettingsStore(); -const overview = useOverview(); +const projectPages = useProjectPages(); const emit = defineEmits<{ createFolder: []; @@ -39,7 +39,12 @@ const headerIcon = computed((): ProjectIconType => { const projectName = computed(() => { 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) { return i18n.baseText('projects.menu.personal'); } else { @@ -60,6 +65,10 @@ const showSettings = computed( const homeProject = computed(() => projectsStore.currentProject ?? projectsStore.personalProject); +const isPersonalProject = computed(() => { + return homeProject.value?.type === ProjectTypes.Personal; +}); + const showFolders = computed(() => { return ( settingsStore.isFoldersFeatureEnabled && @@ -83,6 +92,7 @@ const createWorkflowButton = computed(() => ({ sourceControlStore.preferences.branchReadOnly || !getResourcePermissions(homeProject.value?.scopes).workflow.create, })); + const menu = computed(() => { const items: UserAction[] = [ { @@ -105,6 +115,12 @@ const menu = computed(() => { return items; }); +const showProjectIcon = computed(() => { + return ( + !projectPages.isOverviewSubPage && !projectPages.isSharedSubPage && !isPersonalProject.value + ); +}); + const actions: Record void> = { [ACTION_TYPES.WORKFLOW]: (projectId: string) => { void router.push({ @@ -129,6 +145,27 @@ const actions: Record void> = { }, } 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 executableAction = actions[action as ActionTypes]; if (!homeProject.value) { @@ -142,19 +179,16 @@ const onSelect = (action: string) => {
- +
- {{ projectName }} + {{ + projectName + }} - {{ - i18n.baseText('projects.header.subtitle') - }} + {{ + subtitle + }}
@@ -181,7 +215,11 @@ const onSelect = (action: string) => {
- +
diff --git a/packages/frontend/editor-ui/src/components/Projects/ProjectNavigation.vue b/packages/frontend/editor-ui/src/components/Projects/ProjectNavigation.vue index d956586383..f9cf19e9dd 100644 --- a/packages/frontend/editor-ui/src/components/Projects/ProjectNavigation.vue +++ b/packages/frontend/editor-ui/src/components/Projects/ProjectNavigation.vue @@ -23,7 +23,6 @@ const settingsStore = useSettingsStore(); const isCreatingProject = computed(() => globalEntityCreation.isCreatingProject.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 home = computed(() => ({ @@ -35,6 +34,15 @@ const home = computed(() => ({ }, })); +const shared = computed(() => ({ + id: 'shared', + label: locale.baseText('projects.menu.shared'), + icon: 'share', + route: { + to: { name: VIEWS.SHARED_WITH_ME }, + }, +})); + const getProjectMenuItem = (project: ProjectListItem) => ({ id: project.id, label: project.name, @@ -74,6 +82,22 @@ const showAddFirstProject = computed( mode="tabs" data-test-id="project-home-menu-item" /> + +
- (); + showExecutions?: boolean; + pageType?: 'overview' | 'shared' | 'project'; +}; + +const props = withDefaults(defineProps(), { + showSettings: false, + showExecutions: true, + pageType: 'project', +}); const locale = useI18n(); const route = useRoute(); const selectedTab = ref(''); + +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 }>, +) => { + return { + label: locale.baseText(label), + value: routes[routeKey].name, + to: routes[routeKey], + }; +}; + +// Generate the tabs configuration const options = computed(() => { - const projectId = route?.params?.projectId; - 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 routes = getRouteConfigs(); const tabs = [ - { - label: locale.baseText('mainSidebar.workflows'), - 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, - }, + createTab('mainSidebar.workflows', 'workflows', routes), + createTab('mainSidebar.credentials', 'credentials', routes), ]; + if (props.showExecutions) { + tabs.push(createTab('mainSidebar.executions', 'executions', routes)); + } + if (props.showSettings) { tabs.push({ label: locale.baseText('projects.settings'), value: VIEWS.PROJECT_SETTINGS, - to: { name: VIEWS.PROJECT_SETTINGS, params: { projectId } }, + to: { name: VIEWS.PROJECT_SETTINGS, params: { projectId: projectId.value } }, }); } return tabs; }); + watch( () => route?.name, () => { - selectedTab.value = route?.name; // Select workflows tab if folders tab is selected selectedTab.value = route.name === VIEWS.PROJECTS_FOLDERS ? VIEWS.PROJECTS_WORKFLOWS : route.name; diff --git a/packages/frontend/editor-ui/src/components/layouts/ResourcesListLayout.vue b/packages/frontend/editor-ui/src/components/layouts/ResourcesListLayout.vue index 180e35c9d6..d34f5e69a8 100644 --- a/packages/frontend/editor-ui/src/components/layouts/ResourcesListLayout.vue +++ b/packages/frontend/editor-ui/src/components/layouts/ResourcesListLayout.vue @@ -297,14 +297,11 @@ watch( }, ); -watch( - () => route?.params?.projectId, - async () => { - await resetFilters(); - await loadPaginationPreferences(); - await props.initialize(); - }, -); +watch([() => route.params?.projectId, () => route.name], async () => { + await resetFilters(); + await loadPaginationPreferences(); + await props.initialize(); +}); // Lifecycle hooks onMounted(async () => { diff --git a/packages/frontend/editor-ui/src/composables/useN8nLocalStorage.test.ts b/packages/frontend/editor-ui/src/composables/useN8nLocalStorage.test.ts index ca410c0181..b28fabe7a6 100644 --- a/packages/frontend/editor-ui/src/composables/useN8nLocalStorage.test.ts +++ b/packages/frontend/editor-ui/src/composables/useN8nLocalStorage.test.ts @@ -8,8 +8,8 @@ const mockOverview = { // Create a shared storage object that persists between calls let mockLocalStorageValue: Record = {}; -vi.mock('@/composables/useOverview', () => ({ - useOverview: vi.fn(() => mockOverview), +vi.mock('@/composables/useProjectPages', () => ({ + useProjectPages: vi.fn(() => mockOverview), })); vi.mock('@vueuse/core', () => ({ diff --git a/packages/frontend/editor-ui/src/composables/useN8nLocalStorage.ts b/packages/frontend/editor-ui/src/composables/useN8nLocalStorage.ts index 1759003af0..8c6c4799a4 100644 --- a/packages/frontend/editor-ui/src/composables/useN8nLocalStorage.ts +++ b/packages/frontend/editor-ui/src/composables/useN8nLocalStorage.ts @@ -1,4 +1,4 @@ -import { useOverview } from '@/composables/useOverview'; +import { useProjectPages } from '@/composables/useProjectPages'; import { LOCAL_STORAGE_WORKFLOW_LIST_PREFERENCES_KEY } from '@/constants'; import { useLocalStorage } from '@vueuse/core'; @@ -22,10 +22,10 @@ export type WorkflowListPreferences = { * Currently only used for workflow list user preferences. */ export function useN8nLocalStorage() { - const overview = useOverview(); + const projectPages = useProjectPages(); const getProjectKey = (projectId?: string) => { - return overview.isOverviewSubPage ? 'home' : projectId; + return projectPages.isOverviewSubPage ? 'home' : projectId; }; const saveProjectPreferencesToLocalStorage = ( diff --git a/packages/frontend/editor-ui/src/composables/useOverview.ts b/packages/frontend/editor-ui/src/composables/useProjectPages.ts similarity index 57% rename from packages/frontend/editor-ui/src/composables/useOverview.ts rename to packages/frontend/editor-ui/src/composables/useProjectPages.ts index 8b8f2c0c15..8b6ff47131 100644 --- a/packages/frontend/editor-ui/src/composables/useOverview.ts +++ b/packages/frontend/editor-ui/src/composables/useProjectPages.ts @@ -2,7 +2,10 @@ import { computed, reactive } from 'vue'; import { useRoute } from 'vue-router'; 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 isOverviewSubPage = computed( @@ -14,7 +17,15 @@ export const useOverview = () => { 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({ isOverviewSubPage, + isSharedSubPage, }); }; diff --git a/packages/frontend/editor-ui/src/constants.ts b/packages/frontend/editor-ui/src/constants.ts index 7dc89eddee..53984ea8cd 100644 --- a/packages/frontend/editor-ui/src/constants.ts +++ b/packages/frontend/editor-ui/src/constants.ts @@ -563,6 +563,9 @@ export const enum VIEWS { FOLDERS = 'Folders', PROJECTS_FOLDERS = 'ProjectsFolders', 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]; diff --git a/packages/frontend/editor-ui/src/plugins/i18n/locales/en.json b/packages/frontend/editor-ui/src/plugins/i18n/locales/en.json index fae49bf2d0..e3e45a71b4 100644 --- a/packages/frontend/editor-ui/src/plugins/i18n/locales/en.json +++ b/packages/frontend/editor-ui/src/plugins/i18n/locales/en.json @@ -2516,6 +2516,8 @@ "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.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": "Back to Personal", "workflows.list.easyAI": "Test the power of AI in n8n with this simple AI Agent Workflow", "workflows.list.error.fetching": "Error fetching workflows", "workflows.shareModal.title": "Share '{name}'", @@ -2746,7 +2748,10 @@ "settings.mfa.title": "Multi-factor Authentication", "settings.mfa.updateConfiguration": "MFA configuration updated", "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.credential": "Create Credential", "projects.header.create.folder": "Create Folder", @@ -2754,6 +2759,7 @@ "projects.create.personal": "Create in personal", "projects.create.team": "Create in project", "projects.menu.overview": "Overview", + "projects.menu.shared": "Shared with you", "projects.menu.title": "Projects", "projects.menu.personal": "Personal", "projects.menu.addFirstProject": "Add project", diff --git a/packages/frontend/editor-ui/src/plugins/icons/index.ts b/packages/frontend/editor-ui/src/plugins/icons/index.ts index 9c74405476..444b2138da 100644 --- a/packages/frontend/editor-ui/src/plugins/icons/index.ts +++ b/packages/frontend/editor-ui/src/plugins/icons/index.ts @@ -132,6 +132,7 @@ import { faSearchPlus, faServer, faScrewdriver, + faShare, faSmile, faSignInAlt, faSignOutAlt, @@ -337,6 +338,7 @@ export const FontAwesomePlugin: Plugin = { addIcon(faSearchPlus); addIcon(faServer); addIcon(faScrewdriver); + addIcon(faShare); addIcon(faSmile); addIcon(faSignInAlt); addIcon(faSignOutAlt); diff --git a/packages/frontend/editor-ui/src/routes/projects.routes.ts b/packages/frontend/editor-ui/src/routes/projects.routes.ts index c216f94c8d..83cabc8724 100644 --- a/packages/frontend/editor-ui/src/routes/projects.routes.ts +++ b/packages/frontend/editor-ui/src/routes/projects.routes.ts @@ -160,6 +160,45 @@ export const projectsRoutes: RouteRecordRaw[] = [ 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', redirect: '/home/workflows', diff --git a/packages/frontend/editor-ui/src/stores/credentials.store.ts b/packages/frontend/editor-ui/src/stores/credentials.store.ts index 567ad61c4d..c781f34a1b 100644 --- a/packages/frontend/editor-ui/src/stores/credentials.store.ts +++ b/packages/frontend/editor-ui/src/stores/credentials.store.ts @@ -262,6 +262,7 @@ export const useCredentialsStore = defineStore(STORES.CREDENTIALS, () => { const fetchAllCredentials = async ( projectId?: string, includeScopes = true, + onlySharedWithMe = false, ): Promise => { const filter = { projectId, @@ -271,6 +272,7 @@ export const useCredentialsStore = defineStore(STORES.CREDENTIALS, () => { rootStore.restApiContext, isEmpty(filter) ? undefined : filter, includeScopes, + onlySharedWithMe, ); setCredentials(credentials); return credentials; diff --git a/packages/frontend/editor-ui/src/stores/projects.store.ts b/packages/frontend/editor-ui/src/stores/projects.store.ts index 088cb21ba0..bd4ec9ecfd 100644 --- a/packages/frontend/editor-ui/src/stores/projects.store.ts +++ b/packages/frontend/editor-ui/src/stores/projects.store.ts @@ -199,6 +199,11 @@ export const useProjectsStore = defineStore(STORES.PROJECTS, () => { setCurrentProject(null); } + if (newRoute?.path?.includes('shared')) { + projectNavActiveId.value = 'shared'; + setCurrentProject(null); + } + if (newRoute?.path?.includes('workflow/')) { if (currentProjectId.value) { projectNavActiveId.value = currentProjectId.value; diff --git a/packages/frontend/editor-ui/src/stores/workflows.store.ts b/packages/frontend/editor-ui/src/stores/workflows.store.ts index 9827f14f2e..837c531210 100644 --- a/packages/frontend/editor-ui/src/stores/workflows.store.ts +++ b/packages/frontend/editor-ui/src/stores/workflows.store.ts @@ -550,6 +550,7 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => { parentFolderId?: string; } = {}, includeFolders: boolean = false, + onlySharedWithMe: boolean = false, ): Promise { const filter = { ...filters, projectId }; const options = { @@ -563,6 +564,7 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => { Object.keys(filter).length ? filter : undefined, Object.keys(options).length ? options : undefined, includeFolders ? includeFolders : undefined, + onlySharedWithMe ? onlySharedWithMe : undefined, ); totalWorkflowCount.value = count; // Also set fetched workflows to store diff --git a/packages/frontend/editor-ui/src/views/CredentialsView.vue b/packages/frontend/editor-ui/src/views/CredentialsView.vue index c2ddc61b22..5589fa366d 100644 --- a/packages/frontend/editor-ui/src/views/CredentialsView.vue +++ b/packages/frontend/editor-ui/src/views/CredentialsView.vue @@ -7,7 +7,7 @@ import ResourcesListLayout, { import ProjectHeader from '@/components/Projects/ProjectHeader.vue'; import { useDocumentTitle } from '@/composables/useDocumentTitle'; import { useI18n } from '@/composables/useI18n'; -import { useOverview } from '@/composables/useOverview'; +import { useProjectPages } from '@/composables/useProjectPages'; import { useTelemetry } from '@/composables/useTelemetry'; import { CREDENTIAL_EDIT_MODAL_KEY, @@ -28,6 +28,7 @@ import { useSettingsStore } from '@/stores/settings.store'; import { useSourceControlStore } from '@/stores/sourceControl.store'; import { listenForModalChanges, useUIStore } from '@/stores/ui.store'; import { useUsersStore } from '@/stores/users.store'; +import type { Project } from '@/types/projects.types'; import { isCredentialsResource } from '@/utils/typeGuards'; import { N8nCheckbox } from '@n8n/design-system'; import { pickBy } from 'lodash-es'; @@ -54,7 +55,7 @@ const route = useRoute(); const router = useRouter(); const telemetry = useTelemetry(); const i18n = useI18n(); -const overview = useOverview(); +const overview = useProjectPages(); type Filters = BaseFilters & { type?: string[]; setupNeeded?: boolean }; const updateFilter = (state: Filters) => { @@ -111,6 +112,10 @@ const projectPermissions = computed(() => ), ); +const personalProject = computed(() => { + return projectsStore.personalProject; +}); + const setRouteCredentialId = (credentialId?: string) => { void router.replace({ params: { credentialId }, query: route.query }); }; @@ -182,7 +187,11 @@ const initialize = async () => { useSettingsStore().isEnterpriseFeatureEnabled[EnterpriseEditionFeature.Variables]; const loadPromises = [ - credentialsStore.fetchAllCredentials(route?.params?.projectId as string | undefined), + credentialsStore.fetchAllCredentials( + route?.params?.projectId as string | undefined, + true, + overview.isSharedSubPage, + ), credentialsStore.fetchCredentialTypes(false), externalSecretsStore.fetchAllSecrets(), nodeTypesStore.loadNodeTypesIfNotLoaded(), @@ -304,7 +313,13 @@ onMounted(() => {