Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Gracefully disable favorites if profile is not available #204397

Merged
merged 18 commits into from
Dec 30, 2024
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<Metadata extends object | void = void>
extends GetFavoritesResponseServer {
Expand All @@ -29,6 +31,7 @@ export interface FavoritesClientPublic<Metadata extends object | void = void> {
addFavorite(params: AddFavoriteRequest<Metadata>): Promise<AddFavoriteResponse>;
removeFavorite(params: { id: string }): Promise<RemoveFavoriteResponse>;

isAvailable(): Promise<boolean>;
getFavoriteType(): string;
reportAddFavoriteClick(): void;
reportRemoveFavoriteClick(): void;
Expand All @@ -40,21 +43,37 @@ export class FavoritesClient<Metadata extends object | void = void>
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<boolean> {
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<GetFavoritesResponse<Metadata>> {
await this.ifAvailablePreCheck();
return this.deps.http.get(`/internal/content_management/favorites/${this.favoriteObjectType}`);
}

public async addFavorite(params: AddFavoriteRequest<Metadata>): Promise<AddFavoriteResponse> {
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 }
);
}

public async removeFavorite({ id }: { id: string }): Promise<RemoveFavoriteResponse> {
await this.ifAvailablePreCheck();
return this.deps.http.post(
`/internal/content_management/favorites/${this.favoriteObjectType}/${id}/unfavorite`
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,5 +23,6 @@
"@kbn/content-management-favorites-server",
"@kbn/i18n-react",
"@kbn/usage-collection-plugin",
"@kbn/core-user-profile-browser",
]
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ describe('convertUserProfileAPI', () => {
beforeEach(() => {
source = {
userProfile$: of(null),
enabled$: of(false),
getCurrent: jest.fn(),
bulkGet: jest.fn(),
suggest: jest.fn(),
Expand All @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { UserProfileData } from '@kbn/core-user-profile-common';
export const getDefaultUserProfileImplementation = (): CoreUserProfileDelegateContract => {
return {
userProfile$: of(null),
enabled$: of(false),
getCurrent: <D extends UserProfileData>() =>
Promise.resolve(null as unknown as GetUserProfileResponse<D>),
bulkGet: () => Promise.resolve([]),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ const createSetupMock = () => {
const createStartMock = () => {
const mock: jest.Mocked<UserProfileServiceStart> = {
getUserProfile$: jest.fn().mockReturnValue(of(null)),
getEnabled$: jest.fn().mockReturnValue(of(false)),
getCurrent: jest.fn(),
bulkGet: jest.fn(),
suggest: jest.fn(),
Expand All @@ -49,6 +50,7 @@ const createInternalSetupMock = () => {
const createInternalStartMock = () => {
const mock: jest.Mocked<InternalUserProfileServiceStart> = {
getUserProfile$: jest.fn().mockReturnValue(of(null)),
getEnabled$: jest.fn().mockReturnValue(of(false)),
getCurrent: jest.fn(),
bulkGet: jest.fn(),
suggest: jest.fn(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<UserProfileService, 'getUserProfile$'> & {
export type CoreUserProfileDelegateContract = Omit<
UserProfileService,
'getUserProfile$' | 'getEnabled$'
> & {
userProfile$: Observable<UserProfileData | null>;
enabled$: Observable<boolean>;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Exposing enabled$ from core

};
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,13 @@ import type {

export interface UserProfileService {
/**
* Retrieve an observable emitting when the user profile is loaded.
* Retrieve an observable emitting current user profile data.
Dosant marked this conversation as resolved.
Show resolved Hide resolved
*/
getUserProfile$(): Observable<UserProfileData | null>;

/** Flag to indicate if the current user has a user profile. Anonymous users don't have user profiles. */
getEnabled$(): Observable<boolean>;

/**
* 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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
Expand All @@ -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<boolean>(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',
Expand All @@ -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',
Expand All @@ -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'`,
Expand All @@ -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',
Expand All @@ -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',
Expand All @@ -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',
Expand All @@ -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',
Expand Down
Loading