From 3e78cee770c16c22db17ed017ddbe05e85351fe6 Mon Sep 17 00:00:00 2001 From: Anton Dosov Date: Mon, 30 Dec 2024 16:35:51 +0100 Subject: [PATCH] Gracefully disable favorites if profile is not available (#204397) ## Summary When a user profile is not available, the favorites (starred) service can't be used. On UI user profile can be not available if security is disabled or for an anonymous user. This PR improves the handling of starred features for rare cases when a profile is missing: - No unnecessary `GET favorites` requests that would fail with error and add noise to console/networks - No unhandled errors are thrown - Starred tab in esql is hidden - The Dashboard Starred tab isn't flickering on each attempt to fetch favorites For this needed to expose `userProfile.enabled$` from core, also created https://github.com/elastic/kibana/issues/204570 ### Testing ``` node scripts/functional_tests_server.js --config test/functional/apps/dashboard/group4/config.ts localhost:5620 ``` another way is by configuring an anonymous user https://www.elastic.co/guide/en/elasticsearch/reference/current/anonymous-access.html --- .../favorites_public/src/favorites_client.ts | 23 +++++++- .../favorites/favorites_public/tsconfig.json | 1 + .../src/__jest__/tests.helpers.tsx | 2 +- .../table_list_view_table/src/mocks.tsx | 2 +- .../table_list_view_table/src/services.tsx | 4 +- .../src/table_list_view_table.tsx | 9 ++- .../__snapshots__/error_toast.test.tsx.snap | 1 + .../toasts_service.test.tsx.snap | 1 + .../flyout_service.test.tsx.snap | 3 + .../__snapshots__/modal_service.test.tsx.snap | 11 ++++ .../src/utils/convert_api.test.ts | 7 +++ .../src/utils/convert_api.ts | 1 + .../src/utils/default_implementation.ts | 1 + .../src/user_profile_service.mock.ts | 2 + .../src/api_provider.ts | 6 +- .../core-user-profile-browser/src/service.ts | 5 +- .../esql_starred_queries_service.test.tsx | 57 +++++++++---------- .../esql_starred_queries_service.tsx | 5 ++ .../history_starred_queries.test.tsx | 29 +++++++++- .../editor_footer/history_starred_queries.tsx | 28 ++++++--- .../dashboard_listing/dashboard_listing.tsx | 1 + .../user_profile/user_profile_api_client.ts | 4 -- .../plugin_types_public/tsconfig.json | 1 - 23 files changed, 150 insertions(+), 54 deletions(-) diff --git a/packages/content-management/favorites/favorites_public/src/favorites_client.ts b/packages/content-management/favorites/favorites_public/src/favorites_client.ts index 84c44db5fd64c..c8a9fa5502553 100644 --- a/packages/content-management/favorites/favorites_public/src/favorites_client.ts +++ b/packages/content-management/favorites/favorites_public/src/favorites_client.ts @@ -9,11 +9,13 @@ import type { HttpStart } from '@kbn/core-http-browser'; import type { UsageCollectionStart } from '@kbn/usage-collection-plugin/public'; +import type { UserProfileServiceStart } from '@kbn/core-user-profile-browser'; import type { - GetFavoritesResponse as GetFavoritesResponseServer, AddFavoriteResponse, + GetFavoritesResponse as GetFavoritesResponseServer, RemoveFavoriteResponse, } from '@kbn/content-management-favorites-server'; +import { firstValueFrom } from 'rxjs'; export interface GetFavoritesResponse extends GetFavoritesResponseServer { @@ -29,6 +31,7 @@ export interface FavoritesClientPublic { addFavorite(params: AddFavoriteRequest): Promise; removeFavorite(params: { id: string }): Promise; + isAvailable(): Promise; getFavoriteType(): string; reportAddFavoriteClick(): void; reportRemoveFavoriteClick(): void; @@ -40,14 +43,29 @@ export class FavoritesClient constructor( private readonly appName: string, private readonly favoriteObjectType: string, - private readonly deps: { http: HttpStart; usageCollection?: UsageCollectionStart } + private readonly deps: { + http: HttpStart; + userProfile: UserProfileServiceStart; + usageCollection?: UsageCollectionStart; + } ) {} + public async isAvailable(): Promise { + return firstValueFrom(this.deps.userProfile.getEnabled$()); + } + + private async ifAvailablePreCheck() { + if (!(await this.isAvailable())) + throw new Error('Favorites service is not available for current user'); + } + public async getFavorites(): Promise> { + await this.ifAvailablePreCheck(); return this.deps.http.get(`/internal/content_management/favorites/${this.favoriteObjectType}`); } public async addFavorite(params: AddFavoriteRequest): Promise { + await this.ifAvailablePreCheck(); return this.deps.http.post( `/internal/content_management/favorites/${this.favoriteObjectType}/${params.id}/favorite`, { body: 'metadata' in params ? JSON.stringify({ metadata: params.metadata }) : undefined } @@ -55,6 +73,7 @@ export class FavoritesClient } public async removeFavorite({ id }: { id: string }): Promise { + await this.ifAvailablePreCheck(); return this.deps.http.post( `/internal/content_management/favorites/${this.favoriteObjectType}/${id}/unfavorite` ); diff --git a/packages/content-management/favorites/favorites_public/tsconfig.json b/packages/content-management/favorites/favorites_public/tsconfig.json index 3057c828fc3da..aa36c38cebab3 100644 --- a/packages/content-management/favorites/favorites_public/tsconfig.json +++ b/packages/content-management/favorites/favorites_public/tsconfig.json @@ -23,5 +23,6 @@ "@kbn/content-management-favorites-server", "@kbn/i18n-react", "@kbn/usage-collection-plugin", + "@kbn/core-user-profile-browser", ] } diff --git a/packages/content-management/table_list_view_table/src/__jest__/tests.helpers.tsx b/packages/content-management/table_list_view_table/src/__jest__/tests.helpers.tsx index cf28019f820d0..7dcff19fdecb5 100644 --- a/packages/content-management/table_list_view_table/src/__jest__/tests.helpers.tsx +++ b/packages/content-management/table_list_view_table/src/__jest__/tests.helpers.tsx @@ -29,7 +29,7 @@ export const getMockServices = (overrides?: Partial '', getTagIdsFromReferences: () => [], isTaggingEnabled: () => true, - isFavoritesEnabled: () => false, + isFavoritesEnabled: () => Promise.resolve(false), bulkGetUserProfiles: async () => [], getUserProfile: async () => ({ uid: '', enabled: true, data: {}, user: { username: '' } }), isKibanaVersioningEnabled: false, diff --git a/packages/content-management/table_list_view_table/src/mocks.tsx b/packages/content-management/table_list_view_table/src/mocks.tsx index 6f3ffda91ddf6..1cd7bbcef07a7 100644 --- a/packages/content-management/table_list_view_table/src/mocks.tsx +++ b/packages/content-management/table_list_view_table/src/mocks.tsx @@ -75,7 +75,7 @@ export const getStoryServices = (params: Params, action: ActionFn = () => {}) => getTagManagementUrl: () => '', getTagIdsFromReferences: () => [], isTaggingEnabled: () => true, - isFavoritesEnabled: () => false, + isFavoritesEnabled: () => Promise.resolve(false), isKibanaVersioningEnabled: false, ...params, }; diff --git a/packages/content-management/table_list_view_table/src/services.tsx b/packages/content-management/table_list_view_table/src/services.tsx index 9db14069107e8..7ae90d29a74ba 100644 --- a/packages/content-management/table_list_view_table/src/services.tsx +++ b/packages/content-management/table_list_view_table/src/services.tsx @@ -73,7 +73,7 @@ export interface Services { /** Predicate to indicate if tagging features is enabled */ isTaggingEnabled: () => boolean; /** Predicate to indicate if favorites features is enabled */ - isFavoritesEnabled: () => boolean; + isFavoritesEnabled: () => Promise; /** Predicate function to indicate if some of the saved object references are tags */ itemHasTags: (references: SavedObjectsReference[]) => boolean; /** Handler to return the url to navigate to the kibana tags management */ @@ -288,7 +288,7 @@ export const TableListViewKibanaProvider: FC< currentAppId$={application.currentAppId$} navigateToUrl={application.navigateToUrl} isTaggingEnabled={() => Boolean(savedObjectsTagging)} - isFavoritesEnabled={() => Boolean(services.favorites)} + isFavoritesEnabled={async () => services.favorites?.isAvailable() ?? false} getTagList={getTagList} TagList={TagList} itemHasTags={itemHasTags} diff --git a/packages/content-management/table_list_view_table/src/table_list_view_table.tsx b/packages/content-management/table_list_view_table/src/table_list_view_table.tsx index 011f00256d979..7a5356f75eb2b 100644 --- a/packages/content-management/table_list_view_table/src/table_list_view_table.tsx +++ b/packages/content-management/table_list_view_table/src/table_list_view_table.tsx @@ -9,6 +9,7 @@ import React, { useReducer, useCallback, useEffect, useRef, useMemo } from 'react'; import useDebounce from 'react-use/lib/useDebounce'; +import useAsync from 'react-use/lib/useAsync'; import { EuiBasicTableColumn, EuiButton, @@ -379,6 +380,8 @@ function TableListViewTableComp({ isKibanaVersioningEnabled, } = useServices(); + const favoritesEnabled = useAsync(isFavoritesEnabled, [])?.value ?? false; + const openContentEditor = useOpenContentEditor(); const contentInsightsServices = useContentInsightsServices(); @@ -621,7 +624,7 @@ function TableListViewTableComp({ } }} searchTerm={searchQuery.text} - isFavoritesEnabled={isFavoritesEnabled()} + isFavoritesEnabled={favoritesEnabled} /> ); }, @@ -754,7 +757,7 @@ function TableListViewTableComp({ tableItemsRowActions, inspectItem, entityName, - isFavoritesEnabled, + favoritesEnabled, isKibanaVersioningEnabled, ]); @@ -1218,7 +1221,7 @@ function TableListViewTableComp({ addOrRemoveExcludeTagFilter={addOrRemoveExcludeTagFilter} clearTagSelection={clearTagSelection} createdByEnabled={createdByEnabled} - favoritesEnabled={isFavoritesEnabled()} + favoritesEnabled={favoritesEnabled} /> {/* Delete modal */} diff --git a/packages/core/notifications/core-notifications-browser-internal/src/toasts/__snapshots__/error_toast.test.tsx.snap b/packages/core/notifications/core-notifications-browser-internal/src/toasts/__snapshots__/error_toast.test.tsx.snap index 73b165848162e..2838bd2e7e1a1 100644 --- a/packages/core/notifications/core-notifications-browser-internal/src/toasts/__snapshots__/error_toast.test.tsx.snap +++ b/packages/core/notifications/core-notifications-browser-internal/src/toasts/__snapshots__/error_toast.test.tsx.snap @@ -33,6 +33,7 @@ exports[`renders matching snapshot 1`] = ` Object { "bulkGet": [MockFunction], "getCurrent": [MockFunction], + "getEnabled$": [MockFunction], "getUserProfile$": [MockFunction], "partialUpdate": [MockFunction], "suggest": [MockFunction], diff --git a/packages/core/notifications/core-notifications-browser-internal/src/toasts/__snapshots__/toasts_service.test.tsx.snap b/packages/core/notifications/core-notifications-browser-internal/src/toasts/__snapshots__/toasts_service.test.tsx.snap index 4a2e14cf9fcec..5e3e3eb8f42e6 100644 --- a/packages/core/notifications/core-notifications-browser-internal/src/toasts/__snapshots__/toasts_service.test.tsx.snap +++ b/packages/core/notifications/core-notifications-browser-internal/src/toasts/__snapshots__/toasts_service.test.tsx.snap @@ -35,6 +35,7 @@ Array [ Object { "bulkGet": [MockFunction], "getCurrent": [MockFunction], + "getEnabled$": [MockFunction], "getUserProfile$": [MockFunction], "partialUpdate": [MockFunction], "suggest": [MockFunction], diff --git a/packages/core/overlays/core-overlays-browser-internal/src/flyout/__snapshots__/flyout_service.test.tsx.snap b/packages/core/overlays/core-overlays-browser-internal/src/flyout/__snapshots__/flyout_service.test.tsx.snap index 192c892b0128f..faa75edaa3374 100644 --- a/packages/core/overlays/core-overlays-browser-internal/src/flyout/__snapshots__/flyout_service.test.tsx.snap +++ b/packages/core/overlays/core-overlays-browser-internal/src/flyout/__snapshots__/flyout_service.test.tsx.snap @@ -43,6 +43,7 @@ Array [ Object { "bulkGet": [MockFunction], "getCurrent": [MockFunction], + "getEnabled$": [MockFunction], "getUserProfile$": [MockFunction], "partialUpdate": [MockFunction], "suggest": [MockFunction], @@ -183,6 +184,7 @@ Array [ Object { "bulkGet": [MockFunction], "getCurrent": [MockFunction], + "getEnabled$": [MockFunction], "getUserProfile$": [MockFunction], "partialUpdate": [MockFunction], "suggest": [MockFunction], @@ -316,6 +318,7 @@ Array [ Object { "bulkGet": [MockFunction], "getCurrent": [MockFunction], + "getEnabled$": [MockFunction], "getUserProfile$": [MockFunction], "partialUpdate": [MockFunction], "suggest": [MockFunction], diff --git a/packages/core/overlays/core-overlays-browser-internal/src/modal/__snapshots__/modal_service.test.tsx.snap b/packages/core/overlays/core-overlays-browser-internal/src/modal/__snapshots__/modal_service.test.tsx.snap index c0e94fc9fb2d6..2a4c80431669a 100644 --- a/packages/core/overlays/core-overlays-browser-internal/src/modal/__snapshots__/modal_service.test.tsx.snap +++ b/packages/core/overlays/core-overlays-browser-internal/src/modal/__snapshots__/modal_service.test.tsx.snap @@ -125,6 +125,7 @@ Array [ Object { "bulkGet": [MockFunction], "getCurrent": [MockFunction], + "getEnabled$": [MockFunction], "getUserProfile$": [MockFunction], "partialUpdate": [MockFunction], "suggest": [MockFunction], @@ -424,6 +425,7 @@ Array [ Object { "bulkGet": [MockFunction], "getCurrent": [MockFunction], + "getEnabled$": [MockFunction], "getUserProfile$": [MockFunction], "partialUpdate": [MockFunction], "suggest": [MockFunction], @@ -788,6 +790,7 @@ Array [ Object { "bulkGet": [MockFunction], "getCurrent": [MockFunction], + "getEnabled$": [MockFunction], "getUserProfile$": [MockFunction], "partialUpdate": [MockFunction], "suggest": [MockFunction], @@ -1071,6 +1074,7 @@ Array [ Object { "bulkGet": [MockFunction], "getCurrent": [MockFunction], + "getEnabled$": [MockFunction], "getUserProfile$": [MockFunction], "partialUpdate": [MockFunction], "suggest": [MockFunction], @@ -1359,6 +1363,7 @@ Array [ Object { "bulkGet": [MockFunction], "getCurrent": [MockFunction], + "getEnabled$": [MockFunction], "getUserProfile$": [MockFunction], "partialUpdate": [MockFunction], "suggest": [MockFunction], @@ -1642,6 +1647,7 @@ Array [ Object { "bulkGet": [MockFunction], "getCurrent": [MockFunction], + "getEnabled$": [MockFunction], "getUserProfile$": [MockFunction], "partialUpdate": [MockFunction], "suggest": [MockFunction], @@ -1698,6 +1704,7 @@ Array [ Object { "bulkGet": [MockFunction], "getCurrent": [MockFunction], + "getEnabled$": [MockFunction], "getUserProfile$": [MockFunction], "partialUpdate": [MockFunction], "suggest": [MockFunction], @@ -1869,6 +1876,7 @@ Array [ Object { "bulkGet": [MockFunction], "getCurrent": [MockFunction], + "getEnabled$": [MockFunction], "getUserProfile$": [MockFunction], "partialUpdate": [MockFunction], "suggest": [MockFunction], @@ -2002,6 +2010,7 @@ Array [ Object { "bulkGet": [MockFunction], "getCurrent": [MockFunction], + "getEnabled$": [MockFunction], "getUserProfile$": [MockFunction], "partialUpdate": [MockFunction], "suggest": [MockFunction], @@ -2140,6 +2149,7 @@ Array [ Object { "bulkGet": [MockFunction], "getCurrent": [MockFunction], + "getEnabled$": [MockFunction], "getUserProfile$": [MockFunction], "partialUpdate": [MockFunction], "suggest": [MockFunction], @@ -2273,6 +2283,7 @@ Array [ Object { "bulkGet": [MockFunction], "getCurrent": [MockFunction], + "getEnabled$": [MockFunction], "getUserProfile$": [MockFunction], "partialUpdate": [MockFunction], "suggest": [MockFunction], diff --git a/packages/core/user-profile/core-user-profile-browser-internal/src/utils/convert_api.test.ts b/packages/core/user-profile/core-user-profile-browser-internal/src/utils/convert_api.test.ts index 5783a8f40d9cf..cf8e79007ceb3 100644 --- a/packages/core/user-profile/core-user-profile-browser-internal/src/utils/convert_api.test.ts +++ b/packages/core/user-profile/core-user-profile-browser-internal/src/utils/convert_api.test.ts @@ -19,6 +19,7 @@ describe('convertUserProfileAPI', () => { beforeEach(() => { source = { userProfile$: of(null), + enabled$: of(false), getCurrent: jest.fn(), bulkGet: jest.fn(), suggest: jest.fn(), @@ -34,6 +35,12 @@ describe('convertUserProfileAPI', () => { }); }); + describe('getEnabled$', () => { + it('returns the observable from the source', () => { + expect(output.getEnabled$()).toBe(source.enabled$); + }); + }); + describe('getCurrent', () => { it('calls the API from the source with the correct parameters', () => { output.getCurrent(); diff --git a/packages/core/user-profile/core-user-profile-browser-internal/src/utils/convert_api.ts b/packages/core/user-profile/core-user-profile-browser-internal/src/utils/convert_api.ts index a307ca2ec9460..462ae99c32b11 100644 --- a/packages/core/user-profile/core-user-profile-browser-internal/src/utils/convert_api.ts +++ b/packages/core/user-profile/core-user-profile-browser-internal/src/utils/convert_api.ts @@ -15,6 +15,7 @@ export const convertUserProfileAPI = ( ): InternalUserProfileServiceStart => { return { getUserProfile$: () => delegate.userProfile$, + getEnabled$: () => delegate.enabled$, getCurrent: delegate.getCurrent.bind(delegate), bulkGet: delegate.bulkGet.bind(delegate), suggest: delegate.suggest.bind(delegate), diff --git a/packages/core/user-profile/core-user-profile-browser-internal/src/utils/default_implementation.ts b/packages/core/user-profile/core-user-profile-browser-internal/src/utils/default_implementation.ts index 19febd2d17e71..2a1a1e650c4fd 100644 --- a/packages/core/user-profile/core-user-profile-browser-internal/src/utils/default_implementation.ts +++ b/packages/core/user-profile/core-user-profile-browser-internal/src/utils/default_implementation.ts @@ -17,6 +17,7 @@ import { UserProfileData } from '@kbn/core-user-profile-common'; export const getDefaultUserProfileImplementation = (): CoreUserProfileDelegateContract => { return { userProfile$: of(null), + enabled$: of(false), getCurrent: () => Promise.resolve(null as unknown as GetUserProfileResponse), bulkGet: () => Promise.resolve([]), diff --git a/packages/core/user-profile/core-user-profile-browser-mocks/src/user_profile_service.mock.ts b/packages/core/user-profile/core-user-profile-browser-mocks/src/user_profile_service.mock.ts index 8f86d8ac6555f..ba9842d55b765 100644 --- a/packages/core/user-profile/core-user-profile-browser-mocks/src/user_profile_service.mock.ts +++ b/packages/core/user-profile/core-user-profile-browser-mocks/src/user_profile_service.mock.ts @@ -28,6 +28,7 @@ const createSetupMock = () => { const createStartMock = () => { const mock: jest.Mocked = { getUserProfile$: jest.fn().mockReturnValue(of(null)), + getEnabled$: jest.fn().mockReturnValue(of(false)), getCurrent: jest.fn(), bulkGet: jest.fn(), suggest: jest.fn(), @@ -49,6 +50,7 @@ const createInternalSetupMock = () => { const createInternalStartMock = () => { const mock: jest.Mocked = { getUserProfile$: jest.fn().mockReturnValue(of(null)), + getEnabled$: jest.fn().mockReturnValue(of(false)), getCurrent: jest.fn(), bulkGet: jest.fn(), suggest: jest.fn(), diff --git a/packages/core/user-profile/core-user-profile-browser/src/api_provider.ts b/packages/core/user-profile/core-user-profile-browser/src/api_provider.ts index 0206af155a4cf..841a00d0d561e 100644 --- a/packages/core/user-profile/core-user-profile-browser/src/api_provider.ts +++ b/packages/core/user-profile/core-user-profile-browser/src/api_provider.ts @@ -11,6 +11,10 @@ import type { Observable } from 'rxjs'; import type { UserProfileData } from '@kbn/core-user-profile-common'; import type { UserProfileService } from './service'; -export type CoreUserProfileDelegateContract = Omit & { +export type CoreUserProfileDelegateContract = Omit< + UserProfileService, + 'getUserProfile$' | 'getEnabled$' +> & { userProfile$: Observable; + enabled$: Observable; }; diff --git a/packages/core/user-profile/core-user-profile-browser/src/service.ts b/packages/core/user-profile/core-user-profile-browser/src/service.ts index 766b1ad9d5cc8..d39fc84a57f0f 100644 --- a/packages/core/user-profile/core-user-profile-browser/src/service.ts +++ b/packages/core/user-profile/core-user-profile-browser/src/service.ts @@ -17,10 +17,13 @@ import type { export interface UserProfileService { /** - * Retrieve an observable emitting when the user profile is loaded. + * Retrieve an observable emitting the current user profile data. */ getUserProfile$(): Observable; + /** Flag to indicate if the current user has a user profile. Anonymous users don't have user profiles. */ + getEnabled$(): Observable; + /** * Retrieves the user profile of the current user. If the profile isn't available, e.g. for the anonymous users or * users authenticated via authenticating proxies, the `null` value is returned. diff --git a/src/platform/packages/private/kbn-esql-editor/src/editor_footer/esql_starred_queries_service.test.tsx b/src/platform/packages/private/kbn-esql-editor/src/editor_footer/esql_starred_queries_service.test.tsx index fca4d95c6f6cb..558705048f5ef 100644 --- a/src/platform/packages/private/kbn-esql-editor/src/editor_footer/esql_starred_queries_service.test.tsx +++ b/src/platform/packages/private/kbn-esql-editor/src/editor_footer/esql_starred_queries_service.test.tsx @@ -10,6 +10,7 @@ import { EsqlStarredQueriesService } from './esql_starred_queries_service'; import { coreMock } from '@kbn/core/public/mocks'; import type { Storage } from '@kbn/kibana-utils-plugin/public'; +import { BehaviorSubject } from 'rxjs'; class LocalStorageMock { public store: Record; @@ -34,20 +35,36 @@ describe('EsqlStarredQueriesService', () => { const core = coreMock.createStart(); const storage = new LocalStorageMock({}) as unknown as Storage; - it('should initialize', async () => { + const isUserProfileEnabled$ = new BehaviorSubject(true); + jest.spyOn(core.userProfile, 'getEnabled$').mockImplementation(() => isUserProfileEnabled$); + + beforeEach(() => { + isUserProfileEnabled$.next(true); + }); + + const initialize = async () => { const service = await EsqlStarredQueriesService.initialize({ http: core.http, + userProfile: core.userProfile, storage, }); + return service!; + }; + + it('should return null if favorites service not available', async () => { + isUserProfileEnabled$.next(false); + const service = await initialize(); + expect(service).toBeNull(); + }); + + it('should initialize', async () => { + const service = await initialize(); expect(service).toBeDefined(); expect(service.queries$.value).toEqual([]); }); it('should add a new starred query', async () => { - const service = await EsqlStarredQueriesService.initialize({ - http: core.http, - storage, - }); + const service = await initialize(); const query = { queryString: 'SELECT * FROM test', timeRan: '2021-09-01T00:00:00Z', @@ -66,10 +83,7 @@ describe('EsqlStarredQueriesService', () => { }); it('should not add the same query twice', async () => { - const service = await EsqlStarredQueriesService.initialize({ - http: core.http, - storage, - }); + const service = await initialize(); const query = { queryString: 'SELECT * FROM test', timeRan: '2021-09-01T00:00:00Z', @@ -94,10 +108,7 @@ describe('EsqlStarredQueriesService', () => { }); it('should add the query trimmed', async () => { - const service = await EsqlStarredQueriesService.initialize({ - http: core.http, - storage, - }); + const service = await initialize(); const query = { queryString: `SELECT * FROM test | WHERE field != 'value'`, @@ -118,10 +129,7 @@ describe('EsqlStarredQueriesService', () => { }); it('should remove a query', async () => { - const service = await EsqlStarredQueriesService.initialize({ - http: core.http, - storage, - }); + const service = await initialize(); const query = { queryString: `SELECT * FROM test | WHERE field != 'value'`, timeRan: '2021-09-01T00:00:00Z', @@ -144,10 +152,7 @@ describe('EsqlStarredQueriesService', () => { }); it('should return the button correctly', async () => { - const service = await EsqlStarredQueriesService.initialize({ - http: core.http, - storage, - }); + const service = await initialize(); const query = { queryString: 'SELECT * FROM test', timeRan: '2021-09-01T00:00:00Z', @@ -162,10 +167,7 @@ describe('EsqlStarredQueriesService', () => { }); it('should display the modal when the Remove button is clicked', async () => { - const service = await EsqlStarredQueriesService.initialize({ - http: core.http, - storage, - }); + const service = await initialize(); const query = { queryString: 'SELECT * FROM test', timeRan: '2021-09-01T00:00:00Z', @@ -183,10 +185,7 @@ describe('EsqlStarredQueriesService', () => { it('should NOT display the modal when Remove the button is clicked but the user has dismissed the modal permanently', async () => { storage.set('esqlEditor.starredQueriesDiscard', true); - const service = await EsqlStarredQueriesService.initialize({ - http: core.http, - storage, - }); + const service = await initialize(); const query = { queryString: 'SELECT * FROM test', timeRan: '2021-09-01T00:00:00Z', diff --git a/src/platform/packages/private/kbn-esql-editor/src/editor_footer/esql_starred_queries_service.tsx b/src/platform/packages/private/kbn-esql-editor/src/editor_footer/esql_starred_queries_service.tsx index 80ef716cfd4b0..ad271ad588acc 100644 --- a/src/platform/packages/private/kbn-esql-editor/src/editor_footer/esql_starred_queries_service.tsx +++ b/src/platform/packages/private/kbn-esql-editor/src/editor_footer/esql_starred_queries_service.tsx @@ -43,6 +43,7 @@ export interface StarredQueryItem extends QueryHistoryItem { interface EsqlStarredQueriesServices { http: CoreStart['http']; + userProfile: CoreStart['userProfile']; storage: Storage; usageCollection?: UsageCollectionStart; } @@ -81,9 +82,13 @@ export class EsqlStarredQueriesService { static async initialize(services: EsqlStarredQueriesServices) { const client = new FavoritesClient('esql_editor', 'esql_query', { http: services.http, + userProfile: services.userProfile, usageCollection: services.usageCollection, }); + const isAvailable = await client.isAvailable(); + if (!isAvailable) return null; + const { favoriteMetadata } = (await client?.getFavorites()) || {}; const retrievedQueries: StarredQueryItem[] = []; diff --git a/src/platform/packages/private/kbn-esql-editor/src/editor_footer/history_starred_queries.test.tsx b/src/platform/packages/private/kbn-esql-editor/src/editor_footer/history_starred_queries.test.tsx index 9e0d586622c31..ba00e7eae6359 100644 --- a/src/platform/packages/private/kbn-esql-editor/src/editor_footer/history_starred_queries.test.tsx +++ b/src/platform/packages/private/kbn-esql-editor/src/editor_footer/history_starred_queries.test.tsx @@ -10,13 +10,14 @@ import React from 'react'; import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; import { coreMock } from '@kbn/core/public/mocks'; -import { render, screen } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; import { QueryHistoryAction, getTableColumns, QueryColumn, HistoryAndStarredQueriesTabs, } from './history_starred_queries'; +import { of } from 'rxjs'; jest.mock('../history_local_storage', () => { const module = jest.requireActual('../history_local_storage'); @@ -218,6 +219,7 @@ describe('Starred and History queries components', () => { const services = { core: coreMock.createStart(), }; + it('should render two tabs', () => { render( @@ -271,5 +273,30 @@ describe('Starred and History queries components', () => { 'Showing 0 queries (max 100)' ); }); + + it('should hide starred tab if starred service failed to initialize', async () => { + jest.spyOn(services.core.userProfile, 'getEnabled$').mockImplementation(() => of(false)); + + render( + + + + ); + + // initial render two tabs are shown + expect(screen.getByTestId('history-queries-tab')).toBeInTheDocument(); + expect(screen.getByTestId('history-queries-tab')).toHaveTextContent('Recent'); + expect(screen.getByTestId('starred-queries-tab')).toBeInTheDocument(); + expect(screen.getByTestId('starred-queries-tab')).toHaveTextContent('Starred'); + + await waitFor(() => { + expect(screen.queryByText('starred-queries-tab')).not.toBeInTheDocument(); + }); + }); }); }); diff --git a/src/platform/packages/private/kbn-esql-editor/src/editor_footer/history_starred_queries.tsx b/src/platform/packages/private/kbn-esql-editor/src/editor_footer/history_starred_queries.tsx index c24d0a0b1817b..64039a6063b5f 100644 --- a/src/platform/packages/private/kbn-esql-editor/src/editor_footer/history_starred_queries.tsx +++ b/src/platform/packages/private/kbn-esql-editor/src/editor_footer/history_starred_queries.tsx @@ -470,22 +470,30 @@ export function HistoryAndStarredQueriesTabs({ const kibana = useKibana(); const { core, usageCollection, storage } = kibana.services; - const [starredQueriesService, setStarredQueriesService] = useState(); + const [starredQueriesService, setStarredQueriesService] = useState< + EsqlStarredQueriesService | null | undefined + >(); const [starredQueries, setStarredQueries] = useState([]); useEffect(() => { const initializeService = async () => { const starredService = await EsqlStarredQueriesService.initialize({ http: core.http, + userProfile: core.userProfile, usageCollection, storage, }); - setStarredQueriesService(starredService); + + if (starredService) { + setStarredQueriesService(starredService); + } else { + setStarredQueriesService(null); + } }; if (!starredQueriesService) { initializeService(); } - }, [core.http, starredQueriesService, storage, usageCollection]); + }, [core.http, core.userProfile, starredQueriesService, storage, usageCollection]); starredQueriesService?.queries$.subscribe((nextQueries) => { if (nextQueries.length !== starredQueries.length) { @@ -495,7 +503,11 @@ export function HistoryAndStarredQueriesTabs({ const { euiTheme } = useEuiTheme(); const tabs = useMemo(() => { - return [ + // use typed helper instead of .filter directly to remove falsy values from result type + function filterMissing(array: Array): T[] { + return array.filter((item): item is T => item !== undefined); + } + return filterMissing([ { id: 'history-queries-tab', name: i18n.translate('esqlEditor.query.historyQueriesTabLabel', { @@ -513,11 +525,11 @@ export function HistoryAndStarredQueriesTabs({ tableCaption={i18n.translate('esqlEditor.query.querieshistoryTable', { defaultMessage: 'Queries history table', })} - starredQueriesService={starredQueriesService} + starredQueriesService={starredQueriesService ?? undefined} /> ), }, - { + starredQueriesService !== null && { id: 'starred-queries-tab', dataTestSubj: 'starred-queries-tab', name: i18n.translate('esqlEditor.query.starredQueriesTabLabel', { @@ -539,12 +551,12 @@ export function HistoryAndStarredQueriesTabs({ tableCaption={i18n.translate('esqlEditor.query.starredQueriesTable', { defaultMessage: 'Starred queries table', })} - starredQueriesService={starredQueriesService} + starredQueriesService={starredQueriesService ?? undefined} isStarredTab={true} /> ), }, - ]; + ]); }, [ containerCSS, containerWidth, diff --git a/src/platform/plugins/shared/dashboard/public/dashboard_listing/dashboard_listing.tsx b/src/platform/plugins/shared/dashboard/public/dashboard_listing/dashboard_listing.tsx index 7a8e5a40bb274..b50f977672675 100644 --- a/src/platform/plugins/shared/dashboard/public/dashboard_listing/dashboard_listing.tsx +++ b/src/platform/plugins/shared/dashboard/public/dashboard_listing/dashboard_listing.tsx @@ -54,6 +54,7 @@ export const DashboardListing = ({ return new FavoritesClient(DASHBOARD_APP_ID, DASHBOARD_CONTENT_ID, { http: coreServices.http, usageCollection: usageCollectionService, + userProfile: coreServices.userProfile, }); }, []); diff --git a/x-pack/packages/security/plugin_types_public/src/user_profile/user_profile_api_client.ts b/x-pack/packages/security/plugin_types_public/src/user_profile/user_profile_api_client.ts index 862bcdccf942e..4118d1b189793 100644 --- a/x-pack/packages/security/plugin_types_public/src/user_profile/user_profile_api_client.ts +++ b/x-pack/packages/security/plugin_types_public/src/user_profile/user_profile_api_client.ts @@ -8,7 +8,6 @@ import type { Observable } from 'rxjs'; import type { CoreUserProfileDelegateContract } from '@kbn/core-user-profile-browser'; -import type { UserProfileData } from '@kbn/core-user-profile-common'; export type { GetUserProfileResponse, @@ -18,13 +17,10 @@ export type { } from '@kbn/core-user-profile-browser'; export type UserProfileAPIClient = CoreUserProfileDelegateContract & { - readonly userProfile$: Observable; /** * Indicates if the user profile data has been loaded from the server. * Useful to distinguish between the case when the user profile data is `null` because the HTTP * request has not finished or because there is no user profile data for the current user. */ readonly userProfileLoaded$: Observable; - /** Flag to indicate if the current user has a user profile. Anonymous users don't have user profiles. */ - readonly enabled$: Observable; }; diff --git a/x-pack/packages/security/plugin_types_public/tsconfig.json b/x-pack/packages/security/plugin_types_public/tsconfig.json index 6779851e86367..c65330102c02d 100644 --- a/x-pack/packages/security/plugin_types_public/tsconfig.json +++ b/x-pack/packages/security/plugin_types_public/tsconfig.json @@ -11,7 +11,6 @@ ], "kbn_references": [ "@kbn/core-user-profile-browser", - "@kbn/core-user-profile-common", "@kbn/security-plugin-types-common", "@kbn/core-security-common", ]