[controls][embeddable rebuild] Update initializeDataControl to handle error cases (#186407)

PR updates initializeDataControl to handle error cases like DataView not
found and field not found.

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Nathan Reese 2024-06-20 11:17:08 -06:00 committed by GitHub
parent 4b15e4db1b
commit f4f927b3d0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 271 additions and 83 deletions

View file

@ -0,0 +1,13 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
module.exports = {
preset: '@kbn/test',
rootDir: '../..',
roots: ['<rootDir>/examples/controls_example'],
};

View file

@ -0,0 +1,155 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { coreMock } from '@kbn/core/public/mocks';
import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks';
import type { DataView } from '@kbn/data-views-plugin/public';
import { first, skip } from 'rxjs';
import { ControlGroupApi } from '../control_group/types';
import { initializeDataControl } from './initialize_data_control';
describe('initializeDataControl', () => {
const dataControlState = {
dataViewId: 'myDataViewId',
fieldName: 'myFieldName',
};
const editorStateManager = {};
const controlGroupApi = {} as unknown as ControlGroupApi;
const mockDataViews = dataViewPluginMocks.createStartContract();
// @ts-ignore
mockDataViews.get = async (id: string): Promise<DataView> => {
if (id !== 'myDataViewId') {
throw new Error(`Simulated error: no data view found for id ${id}`);
}
return {
id,
getFieldByName: (fieldName: string) => {
return [
{
displayName: 'My field name',
name: 'myFieldName',
type: 'string',
},
].find((field) => fieldName === field.name);
},
} as unknown as DataView;
};
const services = {
core: coreMock.createStart(),
dataViews: mockDataViews,
};
describe('dataViewId subscription', () => {
describe('no blocking errors', () => {
let dataControl: undefined | ReturnType<typeof initializeDataControl>;
beforeAll((done) => {
dataControl = initializeDataControl(
'myControlId',
'myControlType',
dataControlState,
editorStateManager,
controlGroupApi,
services
);
dataControl.api.defaultPanelTitle!.pipe(skip(1), first()).subscribe(() => {
done();
});
});
test('should set data view', () => {
const dataViews = dataControl!.api.dataViews.value;
expect(dataViews).not.toBeUndefined();
expect(dataViews!.length).toBe(1);
expect(dataViews![0].id).toBe('myDataViewId');
});
test('should set default panel title', () => {
const defaultPanelTitle = dataControl!.api.defaultPanelTitle!.value;
expect(defaultPanelTitle).not.toBeUndefined();
expect(defaultPanelTitle).toBe('My field name');
});
});
describe('data view does not exist', () => {
let dataControl: undefined | ReturnType<typeof initializeDataControl>;
beforeAll((done) => {
dataControl = initializeDataControl(
'myControlId',
'myControlType',
{
...dataControlState,
dataViewId: 'notGonnaFindMeDataViewId',
},
editorStateManager,
controlGroupApi,
services
);
dataControl.api.dataViews.pipe(skip(1), first()).subscribe(() => {
done();
});
});
test('should set blocking error', () => {
const error = dataControl!.api.blockingError.value;
expect(error).not.toBeUndefined();
expect(error!.message).toBe(
'Simulated error: no data view found for id notGonnaFindMeDataViewId'
);
});
test('should clear blocking error when valid data view id provided', (done) => {
dataControl!.api.dataViews.pipe(skip(1), first()).subscribe((dataView) => {
expect(dataView).not.toBeUndefined();
expect(dataControl!.api.blockingError.value).toBeUndefined();
done();
});
dataControl!.stateManager.dataViewId.next('myDataViewId');
});
});
describe('field does not exist', () => {
let dataControl: undefined | ReturnType<typeof initializeDataControl>;
beforeAll((done) => {
dataControl = initializeDataControl(
'myControlId',
'myControlType',
{
...dataControlState,
fieldName: 'notGonnaFindMeFieldName',
},
editorStateManager,
controlGroupApi,
services
);
dataControl.api.defaultPanelTitle!.pipe(skip(1), first()).subscribe(() => {
done();
});
});
test('should set blocking error', () => {
const error = dataControl!.api.blockingError.value;
expect(error).not.toBeUndefined();
expect(error!.message).toBe('Could not locate field: notGonnaFindMeFieldName');
});
test('should clear blocking error when valid field name provided', (done) => {
dataControl!.api
.defaultPanelTitle!.pipe(skip(1), first())
.subscribe((defaultPanelTitle) => {
expect(defaultPanelTitle).toBe('My field name');
expect(dataControl!.api.blockingError.value).toBeUndefined();
done();
});
dataControl!.stateManager.fieldName.next('myFieldName');
});
});
});
});

View file

@ -6,7 +6,7 @@
* Side Public License, v 1.
*/
import { BehaviorSubject, combineLatestWith, switchMap } from 'rxjs';
import { BehaviorSubject, combineLatest, switchMap } from 'rxjs';
import { CoreStart } from '@kbn/core-lifecycle-browser';
import { DataView, DATA_VIEW_SAVED_OBJECT_TYPE } from '@kbn/data-views-plugin/common';
@ -15,6 +15,7 @@ import { Filter } from '@kbn/es-query';
import { SerializedPanelState } from '@kbn/presentation-containers';
import { StateComparators } from '@kbn/presentation-publishing';
import { i18n } from '@kbn/i18n';
import { ControlGroupApi } from '../control_group/types';
import { initializeDefaultControlApi } from '../initialize_default_control_api';
import { ControlApiInitialization, ControlStateManager, DefaultControlState } from '../types';
@ -32,17 +33,13 @@ export const initializeDataControl = <EditorState extends object = {}>(
dataViews: DataViewsPublicPluginStart;
}
): {
dataControlApi: ControlApiInitialization<DataControlApi>;
dataControlComparators: StateComparators<DefaultDataControlState>;
dataControlStateManager: ControlStateManager<DefaultDataControlState>;
serializeDataControl: () => SerializedPanelState<DefaultControlState>;
api: ControlApiInitialization<DataControlApi>;
cleanup: () => void;
comparators: StateComparators<DefaultDataControlState>;
stateManager: ControlStateManager<DefaultDataControlState>;
serialize: () => SerializedPanelState<DefaultControlState>;
} => {
const {
defaultControlApi,
defaultControlComparators,
defaultControlStateManager,
serializeDefaultControl,
} = initializeDefaultControlApi(state);
const defaultControl = initializeDefaultControlApi(state);
const panelTitle = new BehaviorSubject<string | undefined>(state.title);
const defaultPanelTitle = new BehaviorSubject<string | undefined>(undefined);
@ -51,42 +48,66 @@ export const initializeDataControl = <EditorState extends object = {}>(
const dataViews = new BehaviorSubject<DataView[] | undefined>(undefined);
const filters = new BehaviorSubject<Filter[] | undefined>(undefined);
const dataControlComparators: StateComparators<DefaultDataControlState> = {
...defaultControlComparators,
title: [panelTitle, (value: string | undefined) => panelTitle.next(value)],
dataViewId: [dataViewId, (value: string) => dataViewId.next(value)],
fieldName: [fieldName, (value: string) => fieldName.next(value)],
};
const stateManager: ControlStateManager<DefaultDataControlState> = {
...defaultControlStateManager,
...defaultControl.stateManager,
dataViewId,
fieldName,
title: panelTitle,
};
/**
* Fetch the data view + field whenever the selected data view ID or field name changes; use the
* fetched field spec to set the default panel title, which is always equal to either the field
* name or the field's display name.
*/
dataViewId
function clearBlockingError() {
if (defaultControl.api.blockingError.value) {
defaultControl.api.setBlockingError(undefined);
}
}
const dataViewIdSubscription = dataViewId
.pipe(
combineLatestWith(fieldName),
switchMap(async ([currentDataViewId, currentFieldName]) => {
defaultControlApi.setDataLoading(true);
const dataView = await services.dataViews.get(currentDataViewId);
const field = dataView.getFieldByName(currentFieldName);
defaultControlApi.setDataLoading(false);
return { dataView, field };
switchMap(async (currentDataViewId) => {
let dataView: DataView | undefined;
try {
dataView = await services.dataViews.get(currentDataViewId);
return { dataView };
} catch (error) {
return { error };
}
})
)
.subscribe(async ({ dataView, field }) => {
if (!dataView || !field) return;
dataViews.next([dataView]);
defaultPanelTitle.next(field.displayName || field.name);
.subscribe(({ dataView, error }) => {
if (error) {
defaultControl.api.setBlockingError(error);
} else {
clearBlockingError();
}
dataViews.next(dataView ? [dataView] : undefined);
});
const fieldNameSubscription = combineLatest([dataViews, fieldName]).subscribe(
([nextDataViews, nextFieldName]) => {
const dataView = nextDataViews
? nextDataViews.find(({ id }) => dataViewId.value === id)
: undefined;
if (!dataView) {
return;
}
const field = dataView.getFieldByName(nextFieldName);
if (!field) {
defaultControl.api.setBlockingError(
new Error(
i18n.translate('controlsExamples.errors.fieldNotFound', {
defaultMessage: 'Could not locate field: {fieldName}',
values: { fieldName: nextFieldName },
})
)
);
} else {
clearBlockingError();
}
defaultPanelTitle.next(field ? field.displayName || field.name : nextFieldName);
}
);
const onEdit = async () => {
openDataControlEditor<DefaultDataControlState & EditorState>(
{ ...stateManager, ...editorStateManager } as ControlStateManager<
@ -99,8 +120,8 @@ export const initializeDataControl = <EditorState extends object = {}>(
);
};
const dataControlApi: ControlApiInitialization<DataControlApi> = {
...defaultControlApi,
const api: ControlApiInitialization<DataControlApi> = {
...defaultControl.api,
panelTitle,
defaultPanelTitle,
dataViews,
@ -113,13 +134,22 @@ export const initializeDataControl = <EditorState extends object = {}>(
};
return {
dataControlApi,
dataControlComparators,
dataControlStateManager: stateManager,
serializeDataControl: () => {
api,
cleanup: () => {
dataViewIdSubscription.unsubscribe();
fieldNameSubscription.unsubscribe();
},
comparators: {
...defaultControl.comparators,
title: [panelTitle, (value: string | undefined) => panelTitle.next(value)],
dataViewId: [dataViewId, (value: string) => dataViewId.next(value)],
fieldName: [fieldName, (value: string) => fieldName.next(value)],
},
stateManager,
serialize: () => {
return {
rawState: {
...serializeDefaultControl().rawState,
...defaultControl.serialize().rawState,
dataViewId: dataViewId.getValue(),
fieldName: fieldName.getValue(),
title: panelTitle.getValue(),

View file

@ -86,12 +86,7 @@ export const getSearchControlFactory = ({
);
const editorStateManager = { searchTechnique };
const {
dataControlApi,
dataControlComparators,
dataControlStateManager,
serializeDataControl,
} = initializeDataControl<Pick<SearchControlState, 'searchTechnique'>>(
const dataControl = initializeDataControl<Pick<SearchControlState, 'searchTechnique'>>(
uuid,
SEARCH_CONTROL_TYPE,
initialState,
@ -105,13 +100,13 @@ export const getSearchControlFactory = ({
const api = buildApi(
{
...dataControlApi,
...dataControl.api,
getTypeDisplayName: () =>
i18n.translate('controlsExamples.searchControl.displayName', {
defaultMessage: 'Search',
}),
serializeState: () => {
const { rawState: dataControlState, references } = serializeDataControl();
const { rawState: dataControlState, references } = dataControl.serialize();
return {
rawState: {
...dataControlState,
@ -126,7 +121,7 @@ export const getSearchControlFactory = ({
},
},
{
...dataControlComparators,
...dataControl.comparators,
searchTechnique: [
searchTechnique,
(newTechnique: SearchControlTechniques | undefined) =>
@ -146,8 +141,8 @@ export const getSearchControlFactory = ({
const onSearchStringChanged = combineLatest([searchString, searchTechnique])
.pipe(debounceTime(200), distinctUntilChanged(deepEqual))
.subscribe(([newSearchString, currentSearchTechnnique]) => {
const currentDataView = dataControlApi.dataViews.getValue()?.[0];
const currentField = dataControlStateManager.fieldName.getValue();
const currentDataView = dataControl.api.dataViews.getValue()?.[0];
const currentField = dataControl.stateManager.fieldName.getValue();
if (currentDataView && currentField) {
if (newSearchString) {
@ -179,8 +174,8 @@ export const getSearchControlFactory = ({
* clear the previous search string.
*/
const onFieldChanged = combineLatest([
dataControlStateManager.fieldName,
dataControlStateManager.dataViewId,
dataControl.stateManager.fieldName,
dataControl.stateManager.dataViewId,
])
.pipe(distinctUntilChanged(deepEqual))
.subscribe(() => {
@ -199,6 +194,7 @@ export const getSearchControlFactory = ({
useEffect(() => {
return () => {
// cleanup on unmount
dataControl.cleanup();
onSearchStringChanged.unsubscribe();
onFieldChanged.unsubscribe();
};

View file

@ -24,40 +24,34 @@ export type ControlApi = ControlApiInitialization<DefaultControlApi>;
export const initializeDefaultControlApi = (
state: DefaultControlState
): {
defaultControlApi: ControlApi;
defaultControlStateManager: ControlStateManager<DefaultControlState>;
defaultControlComparators: StateComparators<DefaultControlState>;
serializeDefaultControl: () => SerializedPanelState<DefaultControlState>;
api: ControlApi;
stateManager: ControlStateManager<DefaultControlState>;
comparators: StateComparators<DefaultControlState>;
serialize: () => SerializedPanelState<DefaultControlState>;
} => {
const dataLoading = new BehaviorSubject<boolean | undefined>(false);
const blockingError = new BehaviorSubject<Error | undefined>(undefined);
const grow = new BehaviorSubject<boolean | undefined>(state.grow);
const width = new BehaviorSubject<ControlWidth | undefined>(state.width);
const defaultControlApi: ControlApi = {
grow,
width,
dataLoading,
blockingError,
setBlockingError: (error) => blockingError.next(error),
setDataLoading: (loading) => dataLoading.next(loading),
};
const defaultControlStateManager: ControlStateManager<DefaultControlState> = {
grow,
width,
};
const defaultControlComparators: StateComparators<DefaultControlState> = {
grow: [grow, (newGrow: boolean | undefined) => grow.next(newGrow)],
width: [width, (newWidth: ControlWidth | undefined) => width.next(newWidth)],
};
return {
defaultControlApi,
defaultControlComparators,
defaultControlStateManager,
serializeDefaultControl: () => {
api: {
grow,
width,
dataLoading,
blockingError,
setBlockingError: (error) => blockingError.next(error),
setDataLoading: (loading) => dataLoading.next(loading),
},
comparators: {
grow: [grow, (newGrow: boolean | undefined) => grow.next(newGrow)],
width: [width, (newWidth: ControlWidth | undefined) => width.next(newWidth)],
},
stateManager: {
grow,
width,
},
serialize: () => {
return { rawState: { grow: grow.getValue(), width: width.getValue() }, references: [] };
},
};