[Lens] Restore embeddable test coverage (#204986)

## Summary

Fixes #198754

Restore previous removed tests when performing the refactor.
The new tests take advantage of the new architecture to be more modular
and close to the logic modules.

The `data_loader` tests are not just covering the re-render logic but
also some `expression_params` logic, who in the past have proven to be
the source of some bugs: specifically the tests will check that the
params are correctly passed to the params logic and then stored
correctly in the observable.

New mocks take advantage of the plain initializers to build some of the
API, that will make it in sync with the actual implementation for future
maintenance.


### Checklist

Check the PR satisfies following conditions. 

- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Marta Bondyra <4283304+mbondyra@users.noreply.github.com>
This commit is contained in:
Marco Liberati 2025-01-10 10:29:38 +01:00 committed by GitHub
parent 6ed214a69f
commit 41950c22df
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 913 additions and 131 deletions

View file

@ -141,7 +141,7 @@ export function mockDataPlugin(
},
search: createMockSearchService(),
nowProvider: {
get: jest.fn(),
get: jest.fn(() => new Date()),
},
fieldFormats: {
deserialize: jest.fn(),

View file

@ -0,0 +1,414 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { faker } from '@faker-js/faker';
import { loadEmbeddableData } from './data_loader';
import {
createUnifiedSearchApi,
getLensApiMock,
getLensAttributesMock,
getLensInternalApiMock,
makeEmbeddableServices,
} from './mocks';
import { BehaviorSubject, filter, firstValueFrom } from 'rxjs';
import { AggregateQuery, Filter, Query, TimeRange } from '@kbn/es-query';
import { LensDocument } from '../persistence';
import {
GetStateType,
LensApi,
LensEmbeddableStartServices,
LensInternalApi,
LensOverrides,
LensPublicCallbacks,
LensRuntimeState,
} from './types';
import {
HasParentApi,
PublishesTimeRange,
PublishesUnifiedSearch,
PublishingSubject,
ViewMode,
} from '@kbn/presentation-publishing';
import { PublishesSearchSession } from '@kbn/presentation-publishing/interfaces/fetch/publishes_search_session';
import { isObject } from 'lodash';
import { defaultDoc } from '../mocks';
jest.mock('@kbn/interpreter', () => ({
toExpression: jest.fn().mockReturnValue('expression'),
}));
// Mock it for now, later investigate why the real one is not triggering here on tests
jest.mock('@kbn/presentation-publishing', () => {
const original = jest.requireActual('@kbn/presentation-publishing');
const rx = jest.requireActual('rxjs');
return {
...original,
fetch$: jest.fn((api: unknown) => {
const typeApi = api as PublishesTimeRange &
PublishesUnifiedSearch &
HasParentApi<PublishesUnifiedSearch & PublishesSearchSession>;
const emptyObservable = rx.of(undefined);
return rx.merge(
typeApi.timeRange$ ?? emptyObservable,
typeApi.filters$ ?? emptyObservable,
typeApi.query$ ?? emptyObservable,
typeApi.parentApi?.filters$ ?? emptyObservable,
typeApi.parentApi?.query$ ?? emptyObservable,
typeApi.parentApi?.searchSessionId$ ?? emptyObservable,
typeApi.timeRange$ ?? typeApi.parentApi?.timeRange$ ?? emptyObservable,
typeApi.parentApi?.timeslice$ ?? emptyObservable
);
}),
};
});
// In order to listen the reload function, we need to
// monitor the internalApi dispatchRenderStart spy
type ChangeFnType = ({
api,
getState,
parentApi,
internalApi,
services,
}: {
api: LensApi;
internalApi: LensInternalApi;
getState: jest.MockedFunction<GetStateType>;
parentApi: ReturnType<typeof createUnifiedSearchApi> &
LensPublicCallbacks & {
searchSessionId$: BehaviorSubject<string>;
};
services: LensEmbeddableStartServices;
}) => Promise<void | boolean>;
async function expectRerenderOnDataLoder(
changeFn: ChangeFnType,
runtimeState: LensRuntimeState = { attributes: getLensAttributesMock() },
parentApiOverrides?: Partial<
{
filters$: BehaviorSubject<Filter[] | undefined>;
query$: BehaviorSubject<Query | AggregateQuery | undefined>;
timeRange$: BehaviorSubject<TimeRange | undefined>;
} & LensOverrides
>
): Promise<void> {
const parentApi = {
...createUnifiedSearchApi(),
searchSessionId$: new BehaviorSubject<string>(''),
onLoad: jest.fn(),
onBeforeBadgesRender: jest.fn(),
onBrushEnd: jest.fn(),
onFilter: jest.fn(),
onTableRowClick: jest.fn(),
// Make TS happy
removePanel: jest.fn(),
replacePanel: jest.fn(),
getPanelCount: jest.fn(),
children$: new BehaviorSubject({}),
addNewPanel: jest.fn(),
...parentApiOverrides,
};
const api: LensApi = {
...getLensApiMock(),
parentApi,
};
const getState = jest.fn(() => runtimeState);
const internalApi = getLensInternalApiMock();
const services = makeEmbeddableServices(new BehaviorSubject<string>(''), undefined, {
visOverrides: { id: 'lnsXY' },
dataOverrides: { id: 'form_based' },
});
services.documentToExpression = jest.fn().mockResolvedValue({ ast: 'expression_string' });
const { cleanup } = loadEmbeddableData(
faker.string.uuid(),
getState,
api,
parentApi,
internalApi,
services
);
// there's a debounce, so skip to the next tick
jest.advanceTimersByTime(100);
expect(internalApi.dispatchRenderStart).toHaveBeenCalledTimes(1);
// change something
const result = await changeFn({
api,
getState,
parentApi,
internalApi,
services,
});
// fallback to true if undefined is returned
const expectRerender = result ?? true;
// there's a debounce, so skip to the next tick
jest.advanceTimersByTime(200);
// unsubscribe to all observables before checking
cleanup();
// now check if the re-render has been dispatched
expect(internalApi.dispatchRenderStart).toHaveBeenCalledTimes(expectRerender ? 2 : 1);
}
function waitForValue(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
observable: PublishingSubject<any>,
predicate?: (v: NonNullable<unknown>) => boolean
) {
// Wait for the subject to emit the first non-null value
return firstValueFrom(observable.pipe(filter((v) => v != null && (predicate?.(v) ?? true))));
}
describe('Data Loader', () => {
beforeAll(() => {
jest.useFakeTimers();
});
afterAll(() => {
jest.useRealTimers();
});
beforeEach(() => jest.clearAllMocks());
it('should re-render once on filter change', async () => {
await expectRerenderOnDataLoder(async ({ api }) => {
(api.filters$ as BehaviorSubject<Filter[]>).next([
{ meta: { alias: 'test', negate: false, disabled: false } },
]);
});
});
it('should re-render once on search session change', async () => {
await expectRerenderOnDataLoder(async ({ api }) => {
// dispatch a new searchSessionId
(
api.parentApi as unknown as { searchSessionId$: BehaviorSubject<string | undefined> }
).searchSessionId$.next('newSessionId');
});
});
it('should re-render once on attributes change', async () => {
await expectRerenderOnDataLoder(async ({ internalApi }) => {
// trigger a change by changing the title in the attributes
(internalApi.attributes$ as BehaviorSubject<LensDocument | undefined>).next({
...internalApi.attributes$.getValue(),
title: faker.lorem.word(),
});
});
});
it('should re-render when dashboard view/edit mode changes if dynamic actions are set', async () => {
await expectRerenderOnDataLoder(async ({ api, getState }) => {
getState.mockReturnValue({
attributes: getLensAttributesMock(),
enhancements: {
dynamicActions: {
events: [],
},
},
});
// trigger a change by changing the title in the attributes
(api.viewMode as BehaviorSubject<ViewMode | undefined>).next('view');
});
});
it('should not re-render when dashboard view/edit mode changes if dynamic actions are not set', async () => {
await expectRerenderOnDataLoder(async ({ api }) => {
// the default get state does not have dynamic actions
// trigger a change by changing the title in the attributes
(api.viewMode as BehaviorSubject<ViewMode | undefined>).next('view');
// should not re-render
return false;
});
});
it('should pass context to embeddable', async () => {
const query: Query = { language: 'kquery', query: '' };
const filters: Filter[] = [{ meta: { alias: 'test', negate: false, disabled: false } }];
await expectRerenderOnDataLoder(
async ({ internalApi }) => {
await waitForValue(
internalApi.expressionParams$,
(v: unknown) => isObject(v) && 'searchContext' in v
);
const params = internalApi.expressionParams$.getValue()!;
expect(params.searchContext).toEqual(
expect.objectContaining({ query: [query, defaultDoc.state.query], filters })
);
return false;
},
undefined, // use default attributes
createUnifiedSearchApi(query, filters) // customize parentApi
);
});
it('should pass render mode to expression', async () => {
await expectRerenderOnDataLoder(async ({ internalApi }) => {
await waitForValue(
internalApi.expressionParams$,
(v: unknown) => isObject(v) && 'renderMode' in v
);
const params = internalApi.expressionParams$.getValue();
expect(params?.renderMode).toEqual('view');
return false;
});
});
it('should merge external context with query and filters of the saved object', async () => {
const parentApiTimeRange: TimeRange = { from: 'now-15d', to: 'now' };
const parentApiQuery: Query = { language: 'kquery', query: 'external query' };
const parentApiFilters: Filter[] = [
{ meta: { alias: 'external filter', negate: false, disabled: false } },
];
const vizQuery: Query = { language: 'kquery', query: 'saved filter' };
const vizFilters: Filter[] = [
{ meta: { alias: 'test', negate: false, disabled: false, index: 'filter-0' } },
];
let attributes = getLensAttributesMock();
attributes = {
...attributes,
state: {
...attributes.state,
query: vizQuery,
filters: vizFilters,
},
references: [
{ type: 'index-pattern', name: vizFilters[0].meta.index!, id: 'my-index-pattern-id' },
],
};
await expectRerenderOnDataLoder(
async ({ internalApi }) => {
await waitForValue(
internalApi.expressionParams$,
(v: unknown) => isObject(v) && 'searchContext' in v
);
const params = internalApi.expressionParams$.getValue()!;
expect(params.searchContext).toEqual(
expect.objectContaining({
query: [parentApiQuery, vizQuery],
filters: [
...parentApiFilters,
...vizFilters.map(({ meta }) => ({ meta: { ...meta, index: 'injected!' } })),
],
})
);
return false;
},
{ attributes },
createUnifiedSearchApi(parentApiQuery, parentApiFilters, parentApiTimeRange)
);
});
it('should call onload after rerender and onData$ call', async () => {
await expectRerenderOnDataLoder(async ({ parentApi, internalApi, api }) => {
expect(parentApi.onLoad).toHaveBeenLastCalledWith(true);
await waitForValue(
internalApi.expressionParams$,
(v: unknown) => isObject(v) && 'expression' in v && typeof v.expression != null
);
const params = internalApi.expressionParams$.getValue();
// trigger a onData
params?.onData$?.(1);
expect(parentApi.onLoad).toHaveBeenCalledTimes(2);
expect(parentApi.onLoad).toHaveBeenNthCalledWith(2, false, undefined, api.dataLoading);
// turn off the re-render check, that will be performed here
return false;
});
});
it('should initialize dateViews api with deduped list of index patterns', async () => {
await expectRerenderOnDataLoder(
async ({ internalApi }) => {
await waitForValue(
internalApi.dataViews,
(v: NonNullable<unknown>) => Array.isArray(v) && v.length > 0
);
const outputIndexPatterns = internalApi.dataViews.getValue() || [];
expect(outputIndexPatterns.length).toEqual(2);
expect(outputIndexPatterns[0].id).toEqual('123');
expect(outputIndexPatterns[1].id).toEqual('456');
return false;
},
{
attributes: getLensAttributesMock({
references: [
{ type: 'index-pattern', id: '123', name: 'abc' },
{ type: 'index-pattern', id: '123', name: 'def' },
{ type: 'index-pattern', id: '456', name: 'ghi' },
],
}),
}
);
});
it('should override noPadding in the display options if noPadding is set in the embeddable input', async () => {
await expectRerenderOnDataLoder(async ({ internalApi }) => {
await waitForValue(
internalApi.expressionParams$,
(v: unknown) => isObject(v) && 'expression' in v && typeof v.expression != null
);
const params = internalApi.expressionParams$.getValue()!;
expect(params.noPadding).toBeUndefined();
return false;
});
});
it('should reload only once when the attributes or savedObjectId and the search context change at the same time', async () => {
await expectRerenderOnDataLoder(async ({ internalApi, api }) => {
// trigger a change by changing the title in the attributes
(internalApi.attributes$ as BehaviorSubject<LensDocument | undefined>).next({
...internalApi.attributes$.getValue(),
title: faker.lorem.word(),
});
(api.savedObjectId as BehaviorSubject<string | undefined>).next('newSavedObjectId');
});
});
it('should pass over the overrides as variables', async () => {
await expectRerenderOnDataLoder(
async ({ internalApi }) => {
await waitForValue(
internalApi.expressionParams$,
(v: unknown) => isObject(v) && 'variables' in v && typeof v.variables != null
);
const params = internalApi.expressionParams$.getValue()!;
expect(params.variables).toEqual(
expect.objectContaining({
overrides: {
settings: {
onBrushEnd: 'ignore',
},
},
})
);
return false;
},
// send a runtime state with the overrides
{
attributes: getLensAttributesMock(),
overrides: {
settings: {
onBrushEnd: 'ignore',
},
},
}
);
});
});

View file

@ -93,14 +93,7 @@ export function loadEmbeddableData(
updateWarnings,
resetMessages,
updateMessages,
} = buildUserMessagesHelpers(
api,
internalApi,
services,
onBeforeBadgesRender,
services.spaces,
metaInfo
);
} = buildUserMessagesHelpers(api, internalApi, services, onBeforeBadgesRender, metaInfo);
const dispatchBlockingErrorIfAny = () => {
const blockingErrors = getUserMessages(blockingMessageDisplayLocations, {
@ -136,9 +129,7 @@ export function loadEmbeddableData(
internalApi.updateDataLoading(true);
// the component is ready to load
if (apiHasLensComponentCallbacks(parentApi)) {
parentApi.onLoad?.(true);
}
onLoad?.(true);
const currentState = getState();
@ -169,6 +160,7 @@ export function loadEmbeddableData(
internalApi.updateVisualizationContext({
activeData: adapters?.tables?.tables,
});
// data has loaded
internalApi.updateDataLoading(false);
// The third argument here is an observable to let the

View file

@ -149,6 +149,7 @@ export async function getExpressionRendererParams(
onData = noop,
logError,
api,
renderMode,
addUserMessages,
updateBlockingErrors,
searchContext,
@ -199,6 +200,7 @@ export async function getExpressionRendererParams(
syncTooltips,
searchSessionId,
onRender$: onRender,
renderMode,
handleEvent,
onData$: onData,
// Remove ES|QL query from it

View file

@ -0,0 +1,71 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { faker } from '@faker-js/faker';
import { initializeEditApi } from './initialize_edit';
import {
getLensApiMock,
getLensInternalApiMock,
getLensRuntimeStateMock,
makeEmbeddableServices,
} from '../mocks';
import { BehaviorSubject } from 'rxjs';
import { ApplicationStart } from '@kbn/core/public';
import { LensEmbeddableStartServices } from '../types';
function createEditApi(servicesOverrides: Partial<LensEmbeddableStartServices> = {}) {
const internalApi = getLensInternalApiMock();
const runtimeState = getLensRuntimeStateMock();
const api = getLensApiMock();
const services = {
...makeEmbeddableServices(new BehaviorSubject<string>(''), undefined, {
visOverrides: { id: 'lnsXY' },
dataOverrides: { id: 'formBased' },
}),
...servicesOverrides,
};
return initializeEditApi(
faker.string.uuid(),
runtimeState,
() => runtimeState,
internalApi,
api,
api,
api,
() => false, // DSL based
services,
{ getAppContext: () => ({ currentAppId: 'lens' }), viewMode: new BehaviorSubject('edit') }
);
}
describe('edit features', () => {
it('should be editable if visualize library privileges allow it', () => {
const editApi = createEditApi();
expect(editApi.api.isEditingEnabled()).toBe(true);
});
it('should not be editable if visualize library privileges do not allow it', () => {
const editApi = createEditApi({
capabilities: {
visualize: {
// cannot save
save: false,
saveQuery: true,
// cannot see the visualization
show: true,
createShortUrl: true,
},
dashboard: {
// cannot edit in dashboard
showWriteControls: false,
},
} as unknown as ApplicationStart['capabilities'],
});
expect(editApi.api.isEditingEnabled()).toBe(false);
});
});

View file

@ -248,7 +248,12 @@ export function initializeEditApi(
* Check everything here: user/app permissions and the current inline editing state
*/
isEditingEnabled: () => {
return apiHasAppContext(parentApi) && canEdit() && panelManagementApi.isEditingEnabled();
return Boolean(
parentApi &&
apiHasAppContext(parentApi) &&
canEdit() &&
panelManagementApi.isEditingEnabled()
);
},
getEditHref: async () => {
if (!parentApi || !apiHasAppContext(parentApi)) {

View file

@ -53,6 +53,7 @@ export function initializeInternalApi(
// the isNewPanel won't be serialized so it will be always false after the edit panel closes applying the changes
const isNewlyCreated$ = new BehaviorSubject<boolean>(initialState.isNewPanel || false);
const blockingError$ = new BehaviorSubject<Error | undefined>(undefined);
const visualizationContext$ = new BehaviorSubject<VisualizationContext>({
// doc can point to a different set of attributes for the visualization
// i.e. when inline editing or applying a suggestion
@ -78,6 +79,7 @@ export function initializeInternalApi(
renderCount$,
isNewlyCreated$,
dataViews: dataViews$,
blockingError$,
messages$,
validationMessages$,
dispatchError: () => {
@ -103,6 +105,7 @@ export function initializeInternalApi(
messages$.next([]);
validationMessages$.next([]);
},
updateBlockingError: (blockingError: Error | undefined) => blockingError$.next(blockingError),
setAsCreated: () => isNewlyCreated$.next(false),
getDisplayOptions: () => {
const latestAttributes = attributes$.getValue();

View file

@ -63,7 +63,7 @@ export function initializeStateManagement(
// This is the way to communicate to the embeddable panel to render a blocking error with the
// default panel error component - i.e. cannot find a Lens SO type of thing.
// For Lens specific errors, we use a Lens specific error component.
const [blockingError$] = buildObservableVariable<Error | undefined>(undefined);
const [blockingError$] = buildObservableVariable<Error | undefined>(internalApi.blockingError$);
return {
api: {
updateAttributes: internalApi.updateAttributes,

View file

@ -30,99 +30,104 @@ import {
LensRendererProps,
LensRuntimeState,
LensSerializedState,
VisualizationContext,
} from '../types';
import { createMockDatasource, createMockVisualization, makeDefaultServices } from '../../mocks';
import { Datasource, DatasourceMap, Visualization, VisualizationMap } from '../../types';
import { initializeInternalApi } from '../initializers/initialize_internal_api';
const LensApiMock: LensApi = {
// Static props
type: DOC_TYPE,
uuid: faker.string.uuid(),
// Shared Embeddable Observables
panelTitle: new BehaviorSubject<string | undefined>(faker.lorem.words()),
hidePanelTitle: new BehaviorSubject<boolean | undefined>(false),
filters$: new BehaviorSubject<Filter[] | undefined>([]),
query$: new BehaviorSubject<Query | AggregateQuery | undefined>({
query: 'test',
language: 'kuery',
}),
timeRange$: new BehaviorSubject<TimeRange | undefined>({ from: 'now-15m', to: 'now' }),
dataLoading: new BehaviorSubject<boolean | undefined>(false),
// Methods
getSavedVis: jest.fn(),
getFullAttributes: jest.fn(),
canViewUnderlyingData$: new BehaviorSubject<boolean>(false),
loadViewUnderlyingData: jest.fn(),
getViewUnderlyingDataArgs: jest.fn(() => ({
dataViewSpec: { id: 'index-pattern-id' },
timeRange: { from: 'now-7d', to: 'now' },
filters: [],
query: undefined,
columns: [],
})),
isTextBasedLanguage: jest.fn(() => true),
getTextBasedLanguage: jest.fn(),
getInspectorAdapters: jest.fn(() => ({})),
inspect: jest.fn(),
closeInspector: jest.fn(async () => {}),
supportedTriggers: jest.fn(() => []),
canLinkToLibrary: jest.fn(async () => false),
canUnlinkFromLibrary: jest.fn(async () => false),
unlinkFromLibrary: jest.fn(),
checkForDuplicateTitle: jest.fn(),
/** New embeddable api inherited methods */
resetUnsavedChanges: jest.fn(),
serializeState: jest.fn(),
snapshotRuntimeState: jest.fn(),
saveToLibrary: jest.fn(async () => 'saved-id'),
getByValueRuntimeSnapshot: jest.fn(),
onEdit: jest.fn(),
isEditingEnabled: jest.fn(() => true),
getTypeDisplayName: jest.fn(() => 'Lens'),
setPanelTitle: jest.fn(),
setHidePanelTitle: jest.fn(),
phase$: new BehaviorSubject<PhaseEvent | undefined>({
id: faker.string.uuid(),
status: 'rendered',
timeToEvent: 1000,
}),
unsavedChanges: new BehaviorSubject<object | undefined>(undefined),
dataViews: new BehaviorSubject<DataView[] | undefined>(undefined),
libraryId$: new BehaviorSubject<string | undefined>(undefined),
savedObjectId: new BehaviorSubject<string | undefined>(undefined),
adapters$: new BehaviorSubject<Adapters>({}),
updateAttributes: jest.fn(),
updateSavedObjectId: jest.fn(),
updateOverrides: jest.fn(),
getByReferenceState: jest.fn(),
getByValueState: jest.fn(),
getTriggerCompatibleActions: jest.fn(),
blockingError: new BehaviorSubject<Error | undefined>(undefined),
panelDescription: new BehaviorSubject<string | undefined>(undefined),
setPanelDescription: jest.fn(),
viewMode: new BehaviorSubject<ViewMode>('view'),
disabledActionIds: new BehaviorSubject<string[] | undefined>(undefined),
setDisabledActionIds: jest.fn(),
rendered$: new BehaviorSubject<boolean>(false),
searchSessionId$: new BehaviorSubject<string | undefined>(undefined),
};
function getDefaultLensApiMock() {
const LensApiMock: LensApi = {
// Static props
type: DOC_TYPE,
uuid: faker.string.uuid(),
// Shared Embeddable Observables
panelTitle: new BehaviorSubject<string | undefined>(faker.lorem.words()),
hidePanelTitle: new BehaviorSubject<boolean | undefined>(false),
filters$: new BehaviorSubject<Filter[] | undefined>([]),
query$: new BehaviorSubject<Query | AggregateQuery | undefined>({
query: 'test',
language: 'kuery',
}),
timeRange$: new BehaviorSubject<TimeRange | undefined>({ from: 'now-15m', to: 'now' }),
dataLoading: new BehaviorSubject<boolean | undefined>(false),
// Methods
getSavedVis: jest.fn(),
getFullAttributes: jest.fn(),
canViewUnderlyingData$: new BehaviorSubject<boolean>(false),
loadViewUnderlyingData: jest.fn(),
getViewUnderlyingDataArgs: jest.fn(() => ({
dataViewSpec: { id: 'index-pattern-id' },
timeRange: { from: 'now-7d', to: 'now' },
filters: [],
query: undefined,
columns: [],
})),
isTextBasedLanguage: jest.fn(() => true),
getTextBasedLanguage: jest.fn(),
getInspectorAdapters: jest.fn(() => ({})),
inspect: jest.fn(),
closeInspector: jest.fn(async () => {}),
supportedTriggers: jest.fn(() => []),
canLinkToLibrary: jest.fn(async () => false),
canUnlinkFromLibrary: jest.fn(async () => false),
unlinkFromLibrary: jest.fn(),
checkForDuplicateTitle: jest.fn(),
/** New embeddable api inherited methods */
resetUnsavedChanges: jest.fn(),
serializeState: jest.fn(),
snapshotRuntimeState: jest.fn(),
saveToLibrary: jest.fn(async () => 'saved-id'),
getByValueRuntimeSnapshot: jest.fn(),
onEdit: jest.fn(),
isEditingEnabled: jest.fn(() => true),
getTypeDisplayName: jest.fn(() => 'Lens'),
setPanelTitle: jest.fn(),
setHidePanelTitle: jest.fn(),
phase$: new BehaviorSubject<PhaseEvent | undefined>({
id: faker.string.uuid(),
status: 'rendered',
timeToEvent: 1000,
}),
unsavedChanges: new BehaviorSubject<object | undefined>(undefined),
dataViews: new BehaviorSubject<DataView[] | undefined>(undefined),
libraryId$: new BehaviorSubject<string | undefined>(undefined),
savedObjectId: new BehaviorSubject<string | undefined>(undefined),
adapters$: new BehaviorSubject<Adapters>({}),
updateAttributes: jest.fn(),
updateSavedObjectId: jest.fn(),
updateOverrides: jest.fn(),
getByReferenceState: jest.fn(),
getByValueState: jest.fn(),
getTriggerCompatibleActions: jest.fn(),
blockingError: new BehaviorSubject<Error | undefined>(undefined),
panelDescription: new BehaviorSubject<string | undefined>(undefined),
setPanelDescription: jest.fn(),
viewMode: new BehaviorSubject<ViewMode>('view'),
disabledActionIds: new BehaviorSubject<string[] | undefined>(undefined),
setDisabledActionIds: jest.fn(),
rendered$: new BehaviorSubject<boolean>(false),
searchSessionId$: new BehaviorSubject<string | undefined>(undefined),
};
return LensApiMock;
}
const LensSerializedStateMock: LensSerializedState = createEmptyLensState(
'lnsXY',
faker.lorem.words(),
faker.lorem.text(),
{ query: 'test', language: 'kuery' }
);
function getDefaultLensSerializedStateMock() {
const LensSerializedStateMock: LensSerializedState = createEmptyLensState(
'lnsXY',
faker.lorem.words(),
faker.lorem.text(),
{ query: 'test', language: 'kuery' }
);
return LensSerializedStateMock;
}
export function getLensAttributesMock(attributes?: Partial<LensRuntimeState['attributes']>) {
return deepMerge(LensSerializedStateMock.attributes!, attributes ?? {});
return deepMerge(getDefaultLensSerializedStateMock().attributes!, attributes ?? {});
}
export function getLensApiMock(overrides: Partial<LensApi> = {}) {
return {
...LensApiMock,
...getDefaultLensApiMock(),
...overrides,
};
}
@ -130,7 +135,7 @@ export function getLensApiMock(overrides: Partial<LensApi> = {}) {
export function getLensSerializedStateMock(overrides: Partial<LensSerializedState> = {}) {
return {
savedObjectId: faker.string.uuid(),
...LensSerializedStateMock,
...getDefaultLensSerializedStateMock(),
...overrides,
};
}
@ -139,15 +144,15 @@ export function getLensRuntimeStateMock(
overrides: Partial<LensRuntimeState> = {}
): LensRuntimeState {
return {
...(LensSerializedStateMock as LensRuntimeState),
...(getDefaultLensSerializedStateMock() as LensRuntimeState),
...overrides,
};
}
export function getLensComponentProps(overrides: Partial<LensRendererProps> = {}) {
return {
...LensSerializedStateMock,
...LensApiMock,
...getDefaultLensSerializedStateMock(),
...getDefaultLensApiMock(),
...overrides,
};
}
@ -274,38 +279,26 @@ export function getValidExpressionParams(
};
}
const LensInternalApiMock = initializeInternalApi(
getLensRuntimeStateMock(),
{},
makeEmbeddableServices()
);
function getInternalApiWithFunctionWrappers() {
const newApi = initializeInternalApi(getLensRuntimeStateMock(), {}, makeEmbeddableServices());
const fns: Array<keyof LensInternalApi> = (
Object.keys(newApi) as Array<keyof LensInternalApi>
).filter((key) => typeof newApi[key] === 'function');
for (const fn of fns) {
const originalFn = newApi[fn];
// @ts-expect-error
newApi[fn] = jest.fn(originalFn);
}
return newApi;
}
export function getLensInternalApiMock(overrides: Partial<LensInternalApi> = {}): LensInternalApi {
return {
...LensInternalApiMock,
...getInternalApiWithFunctionWrappers(),
...overrides,
};
}
export function getVisualizationContextHelperMock(
attributesOverrides?: Partial<LensRuntimeState['attributes']>,
contextOverrides?: Omit<Partial<VisualizationContext>, 'doc'>
) {
return {
getVisualizationContext: jest.fn(() => ({
activeAttributes: getLensAttributesMock(attributesOverrides),
mergedSearchContext: {},
indexPatterns: {},
indexPatternRefs: [],
activeVisualizationState: undefined,
activeDatasourceState: undefined,
activeData: undefined,
...contextOverrides,
})),
updateVisualizationContext: jest.fn(),
};
}
export function createUnifiedSearchApi(
query: Query | AggregateQuery = {
query: '',

View file

@ -29,7 +29,7 @@ function getDefaultProps({
// provide a valid expression to render
internalApi.updateExpressionParams(getValidExpressionParams());
return {
internalApi: getLensInternalApiMock(internalApiOverrides),
internalApi,
api: getLensApiMock(apiOverrides),
onUnmount: jest.fn(),
};

View file

@ -440,6 +440,8 @@ export type LensInternalApi = Simplify<
updateMessages: (newMessages: UserMessage[]) => void;
validationMessages$: PublishingSubject<UserMessage[]>;
updateValidationMessages: (newMessages: UserMessage[]) => void;
blockingError$: PublishingSubject<Error | undefined>;
updateBlockingError: (newBlockingError: Error | undefined) => void;
resetAllMessages: () => void;
getDisplayOptions: () => VisualizationDisplayOptions;
}

View file

@ -0,0 +1,303 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { buildUserMessagesHelpers } from './api'; // Adjust the import path as necessary
import {
getLensApiMock,
getLensAttributesMock,
getLensInternalApiMock,
makeEmbeddableServices,
} from '../mocks';
import { faker } from '@faker-js/faker';
import type { Datasource, SharingSavedObjectProps, UserMessage, Visualization } from '../../types';
import type { UserMessagesDisplayLocationId } from '../../types';
import { BehaviorSubject } from 'rxjs';
import { EDITOR_MISSING_VIS_TYPE, EDITOR_UNKNOWN_DATASOURCE_TYPE } from '../../user_messages_ids';
const ALL_LOCATIONS: UserMessagesDisplayLocationId[] = [
'toolbar',
'embeddableBadge',
'visualization', // blocks render
'visualizationOnEmbeddable', // blocks render in embeddable only
'visualizationInEditor', // blocks render in editor only
'textBasedLanguagesQueryInput',
'banner',
'dimensionButton',
];
function createUserMessage(
locations: Array<Exclude<UserMessagesDisplayLocationId, 'dimensionButton'>> = ['embeddableBadge'],
severity: UserMessage['severity'] = 'error'
): UserMessage {
return {
uniqueId: faker.string.uuid(),
severity: severity || 'error',
shortMessage: faker.lorem.word(),
longMessage: () => faker.lorem.sentence(),
fixableInEditor: false,
displayLocations: locations.map((location) => ({ id: location })),
};
}
function buildUserMessagesApi(
metaInfo?: SharingSavedObjectProps,
{
visOverrides,
dataOverrides,
}: {
visOverrides?: { id: string } & Partial<Visualization>;
dataOverrides?: { id: string } & Partial<Datasource>;
} = {
visOverrides: { id: 'lnsXY' },
dataOverrides: { id: 'formBased' },
}
) {
const api = getLensApiMock();
const internalApi = getLensInternalApiMock();
const services = makeEmbeddableServices(new BehaviorSubject<string>(''), undefined, {
visOverrides,
dataOverrides,
});
// fill the context with some data
internalApi.updateVisualizationContext({
activeAttributes: getLensAttributesMock({
state: {
datasourceStates: { formBased: { something: {} } },
visualization: { activeId: 'lnsXY', state: {} },
query: { query: '', language: 'kuery' },
filters: [],
},
}),
activeVisualizationState: {},
activeDatasourceState: {},
});
const onBeforeBadgesRender = jest.fn((messages) => messages);
const userMessagesApi = buildUserMessagesHelpers(
api,
internalApi,
services,
onBeforeBadgesRender,
metaInfo
);
return { api, internalApi, userMessagesApi, onBeforeBadgesRender };
}
describe('User Messages API', () => {
describe('resetMessages', () => {
it('should reset the runtime errors', () => {
const { userMessagesApi } = buildUserMessagesApi();
// add runtime messages
const userMessageError = createUserMessage();
const userMessageWarning = createUserMessage(['embeddableBadge'], 'warning');
const userMessageInfo = createUserMessage(['embeddableBadge'], 'info');
userMessagesApi.addUserMessages([userMessageError, userMessageWarning, userMessageInfo]);
expect(userMessagesApi.getUserMessages('embeddableBadge').length).toEqual(3);
userMessagesApi.resetMessages();
expect(userMessagesApi.getUserMessages('embeddableBadge').length).toEqual(0);
});
});
describe('updateValidationErrors', () => {
it('should basically work', () => {
const { userMessagesApi, internalApi } = buildUserMessagesApi();
internalApi.updateValidationMessages = jest.fn();
const messages = Array(3).fill(createUserMessage());
userMessagesApi.updateValidationErrors(messages);
expect(internalApi.updateValidationMessages).toHaveBeenCalledWith(messages);
});
});
describe('updateMessages', () => {
it('should avoid to update duplicate messages', () => {
const { userMessagesApi, internalApi } = buildUserMessagesApi();
// start with these 3 messages
const messages = Array(3)
.fill(1)
.map(() => createUserMessage());
// update the messages
userMessagesApi.updateMessages(messages);
expect(internalApi.updateMessages).toHaveBeenCalledTimes(1);
// now try again with the same messages
userMessagesApi.updateMessages(messages);
expect(internalApi.updateMessages).toHaveBeenCalledTimes(1);
// now try with one extra message
const messagesWithNewEntry = [...messages, createUserMessage()];
userMessagesApi.updateMessages(messagesWithNewEntry);
expect(internalApi.updateMessages).toHaveBeenCalledTimes(2);
});
it('should update the messages if there are new messages', () => {
const { userMessagesApi, internalApi } = buildUserMessagesApi();
// start with these 3 messages
const messages = Array(3).fill(createUserMessage());
// update the messages
userMessagesApi.updateMessages(messages);
expect(internalApi.updateMessages).toHaveBeenCalledWith(messages);
// now try with one extra message
const messagesWithNewEntry = [...messages, createUserMessage()];
userMessagesApi.updateMessages(messagesWithNewEntry);
expect(internalApi.updateMessages).toHaveBeenCalledWith(messagesWithNewEntry);
});
it('should update the messages when changing', () => {
const { userMessagesApi, internalApi } = buildUserMessagesApi();
// start with these 3 messages
const messages = Array(3).fill(createUserMessage());
// update the messages
userMessagesApi.updateMessages(messages);
expect(internalApi.updateMessages).toHaveBeenCalledWith(messages);
// update with new messages
const newMessages = Array(3).fill(createUserMessage());
userMessagesApi.updateMessages(newMessages);
expect(internalApi.updateMessages).toHaveBeenCalledWith(newMessages);
});
});
describe('updateBlockingErrors', () => {
it('should basically work with a regular Error', () => {
const { userMessagesApi, internalApi } = buildUserMessagesApi();
internalApi.updateBlockingError = jest.fn();
const error = new Error('Something went wrong');
userMessagesApi.updateBlockingErrors(error);
expect(internalApi.updateBlockingError).toHaveBeenCalledWith(error);
});
it('should work with user messages too', () => {
const { userMessagesApi, internalApi } = buildUserMessagesApi();
internalApi.updateBlockingError = jest.fn();
const userMessage = createUserMessage();
userMessagesApi.updateBlockingErrors([userMessage]);
expect(internalApi.updateBlockingError).toHaveBeenCalledWith(
new Error(userMessage.shortMessage)
);
});
it('should pick only the first error from a list of user messages', () => {
const { userMessagesApi, internalApi } = buildUserMessagesApi();
internalApi.updateBlockingError = jest.fn();
const userMessage = createUserMessage();
userMessagesApi.updateBlockingErrors([userMessage, createUserMessage(), createUserMessage()]);
expect(internalApi.updateBlockingError).toHaveBeenCalledWith(
new Error(userMessage.shortMessage)
);
});
it('should clear out the error when an empty error is passed', () => {
const { userMessagesApi, internalApi } = buildUserMessagesApi();
internalApi.updateBlockingError = jest.fn();
userMessagesApi.updateBlockingErrors(new Error(''));
expect(internalApi.updateBlockingError).toHaveBeenCalledWith(undefined);
});
});
describe('getUserMessages', () => {
it('should return empty list for no messages', () => {
const { userMessagesApi } = buildUserMessagesApi();
for (const locationId of ALL_LOCATIONS) {
expect(userMessagesApi.getUserMessages(locationId)).toEqual([]);
}
});
it('should return basic validation for missing parts of the config', () => {
const { userMessagesApi, internalApi } = buildUserMessagesApi();
// no doc scenario
internalApi.updateVisualizationContext({
...internalApi.getVisualizationContext(),
activeAttributes: undefined,
});
for (const locationId of ALL_LOCATIONS) {
expect(userMessagesApi.getUserMessages(locationId).map(({ uniqueId }) => uniqueId)).toEqual(
[EDITOR_MISSING_VIS_TYPE, EDITOR_UNKNOWN_DATASOURCE_TYPE]
);
}
});
it('should detect a URL conflict', () => {
const { userMessagesApi } = buildUserMessagesApi({ outcome: 'conflict' });
for (const locationId of ALL_LOCATIONS.filter((id) => id !== 'visualization')) {
expect(userMessagesApi.getUserMessages(locationId)).toEqual([]);
}
expect(userMessagesApi.getUserMessages('visualization')).toEqual(
expect.arrayContaining([expect.objectContaining({ uniqueId: 'url-conflict' })])
);
});
it('should filter messages based on severity criteria', () => {
const { userMessagesApi } = buildUserMessagesApi();
const userMessageError = createUserMessage();
const userMessageWarning = createUserMessage(['embeddableBadge'], 'warning');
const userMessageInfo = createUserMessage(['embeddableBadge'], 'info');
userMessagesApi.addUserMessages([userMessageError, userMessageWarning, userMessageInfo]);
expect(userMessagesApi.getUserMessages('embeddableBadge', { severity: 'error' })).toEqual(
expect.arrayContaining([userMessageError])
);
expect(userMessagesApi.getUserMessages('embeddableBadge', { severity: 'warning' })).toEqual(
expect.arrayContaining([userMessageWarning])
);
expect(userMessagesApi.getUserMessages('embeddableBadge', { severity: 'info' })).toEqual(
expect.arrayContaining([userMessageInfo])
);
});
it('should filter messages based on locationId', () => {
const { userMessagesApi } = buildUserMessagesApi();
const userMessageEmbeddable = createUserMessage(['embeddableBadge']);
const userMessageVisualization = createUserMessage(['visualization']);
const userMessageEmbeddableVisualization = createUserMessage([
'visualization',
'embeddableBadge',
]);
userMessagesApi.addUserMessages([
userMessageEmbeddable,
userMessageVisualization,
userMessageEmbeddableVisualization,
]);
expect(userMessagesApi.getUserMessages('embeddableBadge').length).toEqual(2);
expect(userMessagesApi.getUserMessages('visualization').length).toEqual(2);
expect(userMessagesApi.getUserMessages('visualizationOnEmbeddable').length).toEqual(0);
});
it('should return deeper validation messages from both datasource and visualization', () => {
const vizGetUserMessages = jest.fn();
const datasourceGetUserMessages = jest.fn();
const { userMessagesApi } = buildUserMessagesApi(undefined, {
visOverrides: { id: 'lnsXY', getUserMessages: vizGetUserMessages },
dataOverrides: { id: 'formBased', getUserMessages: datasourceGetUserMessages },
});
// now add a message, then check that it has been called in both the visualization and datasource
const userMessageVisualization = createUserMessage(['visualization']);
userMessagesApi.addUserMessages([userMessageVisualization]);
userMessagesApi.getUserMessages('visualization');
expect(vizGetUserMessages).toHaveBeenCalled();
expect(datasourceGetUserMessages).toHaveBeenCalled();
});
it('should enable consumers to filter the final list of messages', () => {
const { userMessagesApi, onBeforeBadgesRender } = buildUserMessagesApi();
// it should not be called when no messages are avaialble
userMessagesApi.getUserMessages('embeddableBadge');
expect(onBeforeBadgesRender).not.toHaveBeenCalled();
// now add a message, then check that it has been called
const userMessageEmbeddable = createUserMessage(['embeddableBadge']);
userMessagesApi.addUserMessages([userMessageEmbeddable]);
userMessagesApi.getUserMessages('embeddableBadge');
expect(onBeforeBadgesRender).toHaveBeenCalled();
});
});
describe('addUserMessages', () => {
it('should basically work', () => {
const { userMessagesApi } = buildUserMessagesApi();
expect(userMessagesApi.getUserMessages('embeddableBadge').length).toEqual(0);
// now add a message, then check that it has been called
const userMessageEmbeddable = createUserMessage();
userMessagesApi.addUserMessages([userMessageEmbeddable]);
expect(userMessagesApi.getUserMessages('embeddableBadge').length).toEqual(1);
});
});
});

View file

@ -5,9 +5,7 @@
* 2.0.
*/
import { SpacesApi } from '@kbn/spaces-plugin/public';
import { Adapters } from '@kbn/inspector-plugin/common';
import { BehaviorSubject } from 'rxjs';
import {
filterAndSortUserMessages,
getApplicationUserMessages,
@ -99,9 +97,8 @@ function getWarningMessages(
export function buildUserMessagesHelpers(
api: LensApi,
internalApi: LensInternalApi,
{ coreStart, data, visualizationMap, datasourceMap }: LensEmbeddableStartServices,
{ coreStart, data, visualizationMap, datasourceMap, spaces }: LensEmbeddableStartServices,
onBeforeBadgesRender: LensPublicCallbacks['onBeforeBadgesRender'],
spaces?: SpacesApi,
metaInfo?: SharingSavedObjectProps
): {
getUserMessages: UserMessagesGetter;
@ -268,9 +265,9 @@ export function buildUserMessagesHelpers(
addLog(`Blocking error: ${error?.message}`);
}
if (error?.message !== api.blockingError.getValue()?.message) {
if (error?.message !== internalApi.blockingError$.getValue()?.message) {
const finalError = error?.message === '' ? undefined : error;
(api.blockingError as BehaviorSubject<Error | undefined>).next(finalError);
internalApi.updateBlockingError(finalError);
}
},
updateWarnings: () => {