diff --git a/packages/core/chrome/core-chrome-browser-internal/src/chrome_service.tsx b/packages/core/chrome/core-chrome-browser-internal/src/chrome_service.tsx index e425ef7059572..0b575e4a0f215 100644 --- a/packages/core/chrome/core-chrome-browser-internal/src/chrome_service.tsx +++ b/packages/core/chrome/core-chrome-browser-internal/src/chrome_service.tsx @@ -225,6 +225,11 @@ export class ChromeService { projectNavigation.setProjectHome(homeHref); }; + const setProjectsUrl = (projectsUrl: string) => { + validateChromeStyle(); + projectNavigation.setProjectsUrl(projectsUrl); + }; + const isIE = () => { const ua = window.navigator.userAgent; const msie = ua.indexOf('MSIE '); // IE 10 or older @@ -317,6 +322,7 @@ export class ChromeService { loadingCount$={http.getLoadingCount$()} headerBanner$={headerBanner$.pipe(takeUntil(this.stop$))} homeHref$={projectNavigation.getProjectHome$()} + projectsUrl$={projectNavigation.getProjectsUrl$()} docLinks={docLinks} kibanaVersion={injectedMetadata.getKibanaVersion()} prependBasePath={http.basePath.prepend} @@ -444,6 +450,7 @@ export class ChromeService { getChromeStyle$: () => chromeStyle$.pipe(takeUntil(this.stop$)), project: { setHome: setProjectHome, + setProjectsUrl, setNavigation: setProjectNavigation, setSideNavComponent: setProjectSideNavComponent, setBreadcrumbs: setProjectBreadcrumbs, diff --git a/packages/core/chrome/core-chrome-browser-internal/src/project_navigation/project_navigation_service.ts b/packages/core/chrome/core-chrome-browser-internal/src/project_navigation/project_navigation_service.ts index 32f876e6b35a1..50bf609dde0da 100644 --- a/packages/core/chrome/core-chrome-browser-internal/src/project_navigation/project_navigation_service.ts +++ b/packages/core/chrome/core-chrome-browser-internal/src/project_navigation/project_navigation_service.ts @@ -35,6 +35,7 @@ export class ProjectNavigationService { current: SideNavComponent | null; }>({ current: null }); private projectHome$ = new BehaviorSubject(undefined); + private projectsUrl$ = new BehaviorSubject(undefined); private projectNavigation$ = new BehaviorSubject(undefined); private activeNodes$ = new BehaviorSubject([]); private projectNavigationNavTreeFlattened: Record = {}; @@ -66,6 +67,12 @@ export class ProjectNavigationService { getProjectHome$: () => { return this.projectHome$.asObservable(); }, + setProjectsUrl: (projectsUrl: string) => { + this.projectsUrl$.next(projectsUrl); + }, + getProjectsUrl$: () => { + return this.projectsUrl$.asObservable(); + }, setProjectNavigation: (projectNavigation: ChromeProjectNavigation) => { this.projectNavigation$.next(projectNavigation); this.projectNavigationNavTreeFlattened = flattenNav(projectNavigation.navigationTree); diff --git a/packages/core/chrome/core-chrome-browser-internal/src/types.ts b/packages/core/chrome/core-chrome-browser-internal/src/types.ts index 43ae77dee434a..1eea86ad4090d 100644 --- a/packages/core/chrome/core-chrome-browser-internal/src/types.ts +++ b/packages/core/chrome/core-chrome-browser-internal/src/types.ts @@ -44,6 +44,12 @@ export interface InternalChromeStart extends ChromeStart { */ setHome(homeHref: string): void; + /** + * Sets the cloud's projects page. + * @param projectsUrl + */ + setProjectsUrl(projectsUrl: string): void; + /** * Sets the project navigation config to be used for rendering project navigation. * It is used for default project sidenav, project breadcrumbs, tracking active deep link. diff --git a/packages/core/chrome/core-chrome-browser-internal/src/ui/project/header.test.tsx b/packages/core/chrome/core-chrome-browser-internal/src/ui/project/header.test.tsx index 52099c100cb9a..63db10979d026 100644 --- a/packages/core/chrome/core-chrome-browser-internal/src/ui/project/header.test.tsx +++ b/packages/core/chrome/core-chrome-browser-internal/src/ui/project/header.test.tsx @@ -28,6 +28,7 @@ describe('Header', () => { helpSupportUrl$: Rx.of('app/help'), helpMenuLinks$: Rx.of([]), homeHref$: Rx.of('app/home'), + projectsUrl$: Rx.of('/projects/'), kibanaVersion: '8.9', loadingCount$: Rx.of(0), navControlsLeft$: Rx.of([]), @@ -71,4 +72,15 @@ describe('Header', () => { await toggleNav(); await toggleNav(); }); + + it('displays the link to projects', async () => { + render( + + Hello, world! + + ); + + const projectsLink = await screen.getByTestId('projectsLink'); + expect(projectsLink).toHaveAttribute('href', '/projects/'); + }); }); diff --git a/packages/core/chrome/core-chrome-browser-internal/src/ui/project/header.tsx b/packages/core/chrome/core-chrome-browser-internal/src/ui/project/header.tsx index 45061e193de38..8786b9223a2a9 100644 --- a/packages/core/chrome/core-chrome-browser-internal/src/ui/project/header.tsx +++ b/packages/core/chrome/core-chrome-browser-internal/src/ui/project/header.tsx @@ -78,8 +78,8 @@ const headerStrings = { }), }, cloud: { - linkToDeployments: i18n.translate('core.ui.primaryNav.cloud.linkToDeployments', { - defaultMessage: 'My deployments', + linkToProjects: i18n.translate('core.ui.primaryNav.cloud.linkToProjects', { + defaultMessage: 'Projects', }), }, nav: { @@ -100,6 +100,7 @@ export interface Props { helpSupportUrl$: Observable; helpMenuLinks$: Observable; homeHref$: Observable; + projectsUrl$: Observable; kibanaVersion: string; application: InternalApplicationStart; loadingCount$: ReturnType; @@ -175,6 +176,7 @@ export const ProjectHeader = ({ const [isOpen, setIsOpen] = useLocalStorage(LOCAL_STORAGE_IS_OPEN_KEY, true); const toggleCollapsibleNavRef = createRef void }>(); const headerActionMenuMounter = useHeaderActionMenuMounter(observables.actionMenu$); + const projectsUrl = useObservable(observables.projectsUrl$); return ( <> @@ -233,8 +235,8 @@ export const ProjectHeader = ({ - - {headerStrings.cloud.linkToDeployments} + + {headerStrings.cloud.linkToProjects} diff --git a/packages/core/chrome/core-chrome-browser-mocks/src/chrome_service.mock.ts b/packages/core/chrome/core-chrome-browser-mocks/src/chrome_service.mock.ts index f1ce84f2f9be1..884426a7f037a 100644 --- a/packages/core/chrome/core-chrome-browser-mocks/src/chrome_service.mock.ts +++ b/packages/core/chrome/core-chrome-browser-mocks/src/chrome_service.mock.ts @@ -69,6 +69,7 @@ const createStartContractMock = () => { setChromeStyle: jest.fn(), project: { setHome: jest.fn(), + setProjectsUrl: jest.fn(), setNavigation: jest.fn(), setSideNavComponent: jest.fn(), setBreadcrumbs: jest.fn(), diff --git a/test/plugin_functional/test_suites/core_plugins/rendering.ts b/test/plugin_functional/test_suites/core_plugins/rendering.ts index d150ef4e70612..357ab6db19ad3 100644 --- a/test/plugin_functional/test_suites/core_plugins/rendering.ts +++ b/test/plugin_functional/test_suites/core_plugins/rendering.ts @@ -223,6 +223,7 @@ export default function ({ getService }: PluginFunctionalProviderContext) { 'xpack.cloud.profile_url (string)', 'xpack.cloud.performance_url (string)', 'xpack.cloud.users_and_roles_url (string)', + 'xpack.cloud.projects_url (any)', // It's a string (any because schema.conditional) // can't be used to infer urls or customer id from the outside 'xpack.cloud.serverless.project_id (string)', 'xpack.discoverEnhanced.actions.exploreDataInChart.enabled (boolean)', diff --git a/x-pack/plugins/cloud/public/plugin.test.ts b/x-pack/plugins/cloud/public/plugin.test.ts index e0287222df888..f7cf7cb527580 100644 --- a/x-pack/plugins/cloud/public/plugin.test.ts +++ b/x-pack/plugins/cloud/public/plugin.test.ts @@ -15,6 +15,7 @@ const baseConfig = { deployment_url: '/abc123', profile_url: '/user/settings/', organization_url: '/account/', + projects_url: '/projects/', }; describe('Cloud Plugin', () => { @@ -60,6 +61,11 @@ describe('Cloud Plugin', () => { expect(setup.deploymentUrl).toBe('https://cloud.elastic.co/abc123'); }); + it('exposes projectsUrl', () => { + const { setup } = setupPlugin(); + expect(setup.projectsUrl).toBe('https://cloud.elastic.co/projects/'); + }); + it('exposes snapshotsUrl', () => { const { setup } = setupPlugin(); expect(setup.snapshotsUrl).toBe('https://cloud.elastic.co/abc123/elasticsearch/snapshots/'); diff --git a/x-pack/plugins/cloud/public/plugin.tsx b/x-pack/plugins/cloud/public/plugin.tsx index a12d090b0c9c4..60e6d7a1af08f 100644 --- a/x-pack/plugins/cloud/public/plugin.tsx +++ b/x-pack/plugins/cloud/public/plugin.tsx @@ -22,6 +22,7 @@ export interface CloudConfigType { base_url?: string; profile_url?: string; deployment_url?: string; + projects_url?: string; billing_url?: string; organization_url?: string; users_and_roles_url?: string; @@ -41,6 +42,7 @@ interface CloudUrls { snapshotsUrl?: string; performanceUrl?: string; usersAndRolesUrl?: string; + projectsUrl?: string; } export class CloudPlugin implements Plugin { @@ -121,6 +123,7 @@ export class CloudPlugin implements Plugin { organizationUrl, performanceUrl, usersAndRolesUrl, + projectsUrl, } = this.getCloudUrls(); let decodedId: DecodedCloudId | undefined; @@ -136,6 +139,7 @@ export class CloudPlugin implements Plugin { deploymentUrl, profileUrl, organizationUrl, + projectsUrl, elasticsearchUrl: decodedId?.elasticsearchUrl, kibanaUrl: decodedId?.kibanaUrl, isServerlessEnabled: this.isServerlessEnabled, @@ -158,6 +162,7 @@ export class CloudPlugin implements Plugin { base_url: baseUrl, performance_url: performanceUrl, users_and_roles_url: usersAndRolesUrl, + projects_url: projectsUrl, } = this.config; const fullCloudDeploymentUrl = getFullCloudUrl(baseUrl, deploymentUrl); @@ -166,6 +171,7 @@ export class CloudPlugin implements Plugin { const fullCloudOrganizationUrl = getFullCloudUrl(baseUrl, organizationUrl); const fullCloudPerformanceUrl = getFullCloudUrl(baseUrl, performanceUrl); const fullCloudUsersAndRolesUrl = getFullCloudUrl(baseUrl, usersAndRolesUrl); + const fullCloudProjectsUrl = getFullCloudUrl(baseUrl, projectsUrl); const fullCloudSnapshotsUrl = `${fullCloudDeploymentUrl}/${CLOUD_SNAPSHOTS_PATH}`; return { @@ -176,6 +182,7 @@ export class CloudPlugin implements Plugin { snapshotsUrl: fullCloudSnapshotsUrl, performanceUrl: fullCloudPerformanceUrl, usersAndRolesUrl: fullCloudUsersAndRolesUrl, + projectsUrl: fullCloudProjectsUrl, }; } } diff --git a/x-pack/plugins/cloud/public/types.ts b/x-pack/plugins/cloud/public/types.ts index df4512f185ea1..f6dfc71c6dfb2 100644 --- a/x-pack/plugins/cloud/public/types.ts +++ b/x-pack/plugins/cloud/public/types.ts @@ -44,6 +44,10 @@ export interface CloudStart { * The full URL to the users and roles page on Elastic Cloud. Undefined if not running on Cloud. */ usersAndRolesUrl?: string; + /** + * The full URL to the serverless projects page on Elastic Cloud. Undefined if not running in Serverless. + */ + projectsUrl?: string; /** * The full URL to the elasticsearch cluster. */ @@ -90,6 +94,10 @@ export interface CloudSetup { * The full URL to the deployment management page on Elastic Cloud. Undefined if not running on Cloud. */ deploymentUrl?: string; + /** + * The full URL to the serverless projects page on Elastic Cloud. Undefined if not running in Serverless. + */ + projectsUrl?: string; /** * The full URL to the user profile page on Elastic Cloud. Undefined if not running on Cloud. */ diff --git a/x-pack/plugins/cloud/server/config.ts b/x-pack/plugins/cloud/server/config.ts index c13c0b255e7f2..1efd9b50fc421 100644 --- a/x-pack/plugins/cloud/server/config.ts +++ b/x-pack/plugins/cloud/server/config.ts @@ -29,6 +29,12 @@ const configSchema = schema.object({ users_and_roles_url: schema.maybe(schema.string()), organization_url: schema.maybe(schema.string()), profile_url: schema.maybe(schema.string()), + projects_url: schema.conditional( + schema.contextRef('serverless'), + true, + schema.string({ defaultValue: '/projects/' }), + schema.never() + ), trial_end_date: schema.maybe(schema.string()), is_elastic_staff_owned: schema.maybe(schema.boolean()), serverless: schema.maybe( @@ -51,6 +57,7 @@ export const config: PluginConfigDescriptor = { performance_url: true, organization_url: true, profile_url: true, + projects_url: true, trial_end_date: true, is_elastic_staff_owned: true, serverless: { diff --git a/x-pack/plugins/serverless/kibana.jsonc b/x-pack/plugins/serverless/kibana.jsonc index 5a724f7641a6a..35b21a5fc39b5 100644 --- a/x-pack/plugins/serverless/kibana.jsonc +++ b/x-pack/plugins/serverless/kibana.jsonc @@ -15,6 +15,7 @@ "requiredPlugins": [ "kibanaReact", "management", + "cloud" ], "optionalPlugins": [], "requiredBundles": [] diff --git a/x-pack/plugins/serverless/public/plugin.tsx b/x-pack/plugins/serverless/public/plugin.tsx index e7b88fea56ffb..d49447e4d36dd 100644 --- a/x-pack/plugins/serverless/public/plugin.tsx +++ b/x-pack/plugins/serverless/public/plugin.tsx @@ -65,6 +65,9 @@ export class ServerlessPlugin // Casting the "chrome.projects" service to an "internal" type: this is intentional to obscure the property from Typescript. const { project } = core.chrome as InternalChromeStart; + if (dependencies.cloud.projectsUrl) { + project.setProjectsUrl(dependencies.cloud.projectsUrl); + } return { setSideNavComponent: (sideNavigationComponent) => diff --git a/x-pack/plugins/serverless/public/types.ts b/x-pack/plugins/serverless/public/types.ts index 685e8757f9a98..1de2e2fd75e1a 100644 --- a/x-pack/plugins/serverless/public/types.ts +++ b/x-pack/plugins/serverless/public/types.ts @@ -13,6 +13,7 @@ import type { ChromeProjectNavigationNode, } from '@kbn/core-chrome-browser'; import type { ManagementSetup, ManagementStart } from '@kbn/management-plugin/public'; +import type { CloudSetup, CloudStart } from '@kbn/cloud-plugin/public'; import type { Observable } from 'rxjs'; // eslint-disable-next-line @typescript-eslint/no-empty-interface @@ -31,8 +32,10 @@ export interface ServerlessPluginStart { export interface ServerlessPluginSetupDependencies { management: ManagementSetup; + cloud: CloudSetup; } export interface ServerlessPluginStartDependencies { management: ManagementStart; + cloud: CloudStart; } diff --git a/x-pack/plugins/serverless/tsconfig.json b/x-pack/plugins/serverless/tsconfig.json index c4cf602f928c7..88f7b5af1636c 100644 --- a/x-pack/plugins/serverless/tsconfig.json +++ b/x-pack/plugins/serverless/tsconfig.json @@ -24,5 +24,6 @@ "@kbn/core-chrome-browser", "@kbn/core-chrome-browser-internal", "@kbn/i18n-react", + "@kbn/cloud-plugin", ] } diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 567157a2bdbeb..de18f8b327343 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -1033,7 +1033,6 @@ "core.ui.overlays.banner.attentionTitle": "Attention", "core.ui.overlays.banner.closeButtonLabel": "Fermer", "core.ui.primaryNav.addData": "Ajouter des intégrations", - "core.ui.primaryNav.cloud.linkToDeployments": "Mes déploiements", "core.ui.primaryNav.goToHome.ariaLabel": "Accéder à la page d’accueil", "core.ui.primaryNav.pinnedLinksAriaLabel": "Liens épinglés", "core.ui.primaryNav.screenReaderLabel": "Principale", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 934e36fec297c..a9e61ce100c09 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -1047,7 +1047,6 @@ "core.ui.overlays.banner.attentionTitle": "注意", "core.ui.overlays.banner.closeButtonLabel": "閉じる", "core.ui.primaryNav.addData": "統合の追加", - "core.ui.primaryNav.cloud.linkToDeployments": "マイデプロイ", "core.ui.primaryNav.goToHome.ariaLabel": "ホームページに移動", "core.ui.primaryNav.pinnedLinksAriaLabel": "ピン留めされたリンク", "core.ui.primaryNav.screenReaderLabel": "プライマリ", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index fe4771e5cbd82..7ff8263ebe95d 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -1047,7 +1047,6 @@ "core.ui.overlays.banner.attentionTitle": "注意", "core.ui.overlays.banner.closeButtonLabel": "关闭", "core.ui.primaryNav.addData": "添加集成", - "core.ui.primaryNav.cloud.linkToDeployments": "我的部署", "core.ui.primaryNav.goToHome.ariaLabel": "前往主页", "core.ui.primaryNav.pinnedLinksAriaLabel": "置顶链接", "core.ui.primaryNav.screenReaderLabel": "主分片",