[Dashboard] [Controls] Add unmapped runtime field support to options list (#144947)

Closes https://github.com/elastic/kibana/issues/144931

## Summary

This PR adds options list support for runtime fields that are added to
**data views** - note that runtime fields that are added as part of an
**index** (i.e. they are part of the mapping) already worked as
expected.


https://user-images.githubusercontent.com/8698078/201154529-588c7ecd-3a6a-4c51-a05e-97d9bacd7c18.mov


### How to Test
- Create some data view runtime fields of various types as described in
the
[documentation](https://www.elastic.co/guide/en/kibana/master/managing-data-views.html#runtime-fields).
For example, in the sample Logs data view, you could create the
following:
     - **Keyword:** 
        ```javascript
        emit(doc['referer'].value.splitOnToken('/')[2].replace(' ', ''))
        ```
- **Keyword with partial data** (to test the `exists` options list
functionality):
        ```javascript
        if(doc['geo.dest'].value == 'US') {
            emit('Party in the USA')
        }
        ```
     - **Boolean:** 
        ```javascript
        emit(doc['geo.src'].value == doc['geo.dest'].value)
        ```
     - **IP:** 
        ```javascript
        emit(doc['clientip'].value)
        ```
     - **Composite:** 
          ``` javascript
          if(doc['geo.dest'].value == doc['geo.src'].value) {
              emit('english', 'equals');
              emit('spanish', 'es igual');
              emit('french', 'équivaut à');
              return;
          }
            
          emit('english', 'does not equal');
          emit('spanish', 'no es igual');
          emit('french', 'n\'est pas égal');
          ```
- Create options list controls on these new fields
- Ensure that they work as expected, including chaining and validation.

**Flaky Test Runner**

<a
href="https://buildkite.com/elastic/kibana-flaky-test-suite-runner/builds/1548"><img
src="https://user-images.githubusercontent.com/8698078/201746854-1ab14130-86c5-46db-82c3-2cb6433c56a9.png"/></a>

### Checklist

- [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
- [x] This was checked for [cross-browser
compatibility](https://www.elastic.co/support/matrix#matrix_browsers)

### For maintainers

- [ ] This was checked for breaking API changes and was [labeled
appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)
This commit is contained in:
Hannah Mudge 2022-11-14 15:55:17 -07:00 committed by GitHub
parent 24df1db3a5
commit 3190eddd5c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 163 additions and 86 deletions

View file

@ -7,7 +7,7 @@
*/
import type { Filter, Query, BoolQuery, TimeRange } from '@kbn/es-query';
import { FieldSpec, DataView } from '@kbn/data-views-plugin/common';
import { FieldSpec, DataView, RuntimeFieldSpec } from '@kbn/data-views-plugin/common';
import { DataControlInput } from '../types';
@ -57,9 +57,11 @@ export type OptionsListRequest = Omit<
* The Options list request body is sent to the serverside Options List route and is used to create the ES query.
*/
export interface OptionsListRequestBody {
runtimeFieldMap?: Record<string, RuntimeFieldSpec>;
filters?: Array<{ bool: BoolQuery }>;
selectedOptions?: string[];
runPastTimeout?: boolean;
parentFieldName?: string;
textFieldName?: string;
searchString?: string;
fieldSpec?: FieldSpec;

View file

@ -52,7 +52,7 @@ import {
} from '../../types';
import { CONTROL_WIDTH_OPTIONS } from './editor_constants';
import { pluginServices } from '../../services';
import { loadFieldRegistryFromDataViewId } from './data_control_editor_tools';
import { getDataControlFieldRegistry } from './data_control_editor_tools';
interface EditControlProps {
embeddable?: ControlEmbeddable<DataControlInput>;
isCreate: boolean;
@ -116,10 +116,10 @@ export const ControlEditor = ({
useEffect(() => {
(async () => {
if (state.selectedDataView?.id) {
setFieldRegistry(await loadFieldRegistryFromDataViewId(state.selectedDataView.id));
setFieldRegistry(await getDataControlFieldRegistry(await get(state.selectedDataView.id)));
}
})();
}, [state.selectedDataView]);
}, [state.selectedDataView?.id, get]);
useMount(() => {
let mounted = true;

View file

@ -6,13 +6,20 @@
* Side Public License, v 1.
*/
import { memoize } from 'lodash';
import { IFieldSubTypeMulti } from '@kbn/es-query';
import { DataView } from '@kbn/data-views-plugin/common';
import { pluginServices } from '../../services';
import { DataControlFieldRegistry, IEditableControlFactory } from '../../types';
const dataControlFieldRegistryCache: { [key: string]: DataControlFieldRegistry } = {};
export const getDataControlFieldRegistry = memoize(
async (dataView: DataView) => {
return await loadFieldRegistryFromDataView(dataView);
},
(dataView: DataView) => [dataView.id, JSON.stringify(dataView.fields.getAll())].join('|')
);
const doubleLinkFields = (dataView: DataView) => {
// double link the parent-child relationship specifically for case-sensitivity support for options lists
@ -22,6 +29,7 @@ const doubleLinkFields = (dataView: DataView) => {
if (!fieldRegistry[field.name]) {
fieldRegistry[field.name] = { field, compatibleControlTypes: [] };
}
const parentFieldName = (field.subType as IFieldSubTypeMulti)?.multi?.parent;
if (parentFieldName) {
fieldRegistry[field.name].parentFieldName = parentFieldName;
@ -36,20 +44,13 @@ const doubleLinkFields = (dataView: DataView) => {
return fieldRegistry;
};
export const loadFieldRegistryFromDataViewId = async (
dataViewId: string
const loadFieldRegistryFromDataView = async (
dataView: DataView
): Promise<DataControlFieldRegistry> => {
if (dataControlFieldRegistryCache[dataViewId]) {
return dataControlFieldRegistryCache[dataViewId];
}
const {
dataViews,
controls: { getControlTypes, getControlFactory },
} = pluginServices.getServices();
const dataView = await dataViews.get(dataViewId);
const newFieldRegistry: DataControlFieldRegistry = doubleLinkFields(dataView);
const controlFactories = getControlTypes().map(
(controlType) => getControlFactory(controlType) as IEditableControlFactory
);
@ -64,7 +65,6 @@ export const loadFieldRegistryFromDataViewId = async (
delete newFieldRegistry[dataViewField.name];
}
});
dataControlFieldRegistryCache[dataViewId] = newFieldRegistry;
return newFieldRegistry;
};

View file

@ -43,7 +43,7 @@ import { ControlEmbeddable, ControlInput, ControlOutput, DataControlInput } from
import { CreateControlButton, CreateControlButtonTypes } from '../editor/create_control';
import { CreateTimeSliderControlButton } from '../editor/create_time_slider_control';
import { TIME_SLIDER_CONTROL } from '../../time_slider';
import { loadFieldRegistryFromDataViewId } from '../editor/data_control_editor_tools';
import { getDataControlFieldRegistry } from '../editor/data_control_editor_tools';
let flyoutRef: OverlayRef | undefined;
export const setFlyoutRef = (newRef: OverlayRef | undefined) => {
@ -102,7 +102,8 @@ export class ControlGroupContainer extends Container<
fieldName: string;
title?: string;
}) {
const fieldRegistry = await loadFieldRegistryFromDataViewId(dataViewId);
const dataView = await pluginServices.getServices().dataViews.get(dataViewId);
const fieldRegistry = await getDataControlFieldRegistry(dataView);
const field = fieldRegistry[fieldName];
return this.addNewEmbeddable(field.compatibleControlTypes[0], {
id: uuid,

View file

@ -89,6 +89,7 @@ class OptionsListService implements ControlsOptionsListService {
fieldName: field.name,
fieldSpec: field,
textFieldName: (field as OptionsListField).textFieldName,
runtimeFieldMap: dataView.toSpec().runtimeFieldMap,
};
};

View file

@ -85,8 +85,7 @@ export const setupOptionsListSuggestionsRoute = (
/**
* Build ES Query
*/
const { runPastTimeout, filters, fieldName } = request;
const { runPastTimeout, filters, fieldName, runtimeFieldMap } = request;
const { terminateAfter, timeout } = getAutocompleteSettings();
const timeoutSettings = runPastTimeout
? {}
@ -124,6 +123,9 @@ export const setupOptionsListSuggestionsRoute = (
},
},
},
runtime_mappings: {
...runtimeFieldMap,
},
};
/**

View file

@ -22,47 +22,43 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
const dashboardAddPanel = getService('dashboardAddPanel');
const dashboardPanelActions = getService('dashboardPanelActions');
const { dashboardControls, timePicker, console, common, dashboard, header } = getPageObjects([
'dashboardControls',
'timePicker',
'dashboard',
'console',
'common',
'header',
]);
const { dashboardControls, timePicker, console, common, dashboard, header, settings } =
getPageObjects([
'dashboardControls',
'timePicker',
'dashboard',
'settings',
'console',
'common',
'header',
]);
const DASHBOARD_NAME = 'Test Options List Control';
describe('Dashboard options list integration', () => {
const newDocuments: Array<{ index: string; id: string }> = [];
const addDocument = async (index: string, document: string) => {
await console.enterRequest('\nPOST ' + index + '/_doc/ \n{\n ' + document);
await console.clickPlay();
const returnToDashboard = async () => {
await common.navigateToApp('dashboard');
await header.waitUntilLoadingHasFinished();
const response = JSON.parse(await console.getResponse());
newDocuments.push({ index, id: response._id });
await elasticChart.setNewChartUiDebugFlag();
await dashboard.loadSavedDashboard(DASHBOARD_NAME);
if (await dashboard.getIsInViewMode()) {
await dashboard.switchToEditMode();
}
await dashboard.waitForRenderComplete();
};
before(async () => {
await security.testUser.setRoles(['kibana_admin', 'test_logstash_reader', 'animals']);
/* start by adding some incomplete data so that we can test `exists` query */
await common.navigateToApp('console');
await console.collapseHelp();
await console.clearTextArea();
await addDocument(
'animals-cats-2018-01-01',
'"@timestamp": "2018-01-01T16:00:00.000Z", \n"name": "Rosie", \n"sound": "hiss"'
);
/* then, create our testing dashboard */
await common.navigateToApp('dashboard');
await dashboard.gotoDashboardLandingPage();
await dashboard.clickNewDashboard();
await timePicker.setDefaultDataRange();
await elasticChart.setNewChartUiDebugFlag();
await dashboard.saveDashboard(DASHBOARD_NAME, { exitFromEditMode: false });
await dashboard.saveDashboard(DASHBOARD_NAME, {
exitFromEditMode: false,
storeTimeWithDashboard: true,
});
});
describe('Options List Control Editor selects relevant data views', async () => {
@ -392,44 +388,131 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
expect(await pieChart.getPieSliceCount()).to.be(2);
await dashboard.clearUnsavedChanges();
});
});
describe('test exists query', async () => {
before(async () => {
await dashboardControls.deleteAllControls();
await dashboardControls.createControl({
controlType: OPTIONS_LIST_CONTROL,
dataViewTitle: 'animals-*',
fieldName: 'animal.keyword',
title: 'Animal',
});
controlId = (await dashboardControls.getAllControlIds())[0];
describe('test data view runtime field', async () => {
const FIELD_NAME = 'testRuntimeField';
const FIELD_VALUES = ['G', 'H', 'B', 'R', 'M'];
before(async () => {
await common.navigateToApp('settings');
await settings.clickKibanaIndexPatterns();
await settings.clickIndexPatternByName('animals-*');
await settings.addRuntimeField(
FIELD_NAME,
'keyword',
`emit(doc['sound.keyword'].value.substring(0, 1).toUpperCase())`
);
await header.waitUntilLoadingHasFinished();
await returnToDashboard();
await dashboardControls.deleteAllControls();
});
it('can create options list control on runtime field', async () => {
await dashboardControls.createControl({
controlType: OPTIONS_LIST_CONTROL,
fieldName: FIELD_NAME,
dataViewTitle: 'animals-*',
});
expect(await dashboardControls.getControlsCount()).to.be(1);
});
it('creating exists query has expected results', async () => {
expect((await pieChart.getPieChartValues())[0]).to.be(6);
await dashboardControls.optionsListOpenPopover(controlId);
await dashboardControls.optionsListPopoverSelectOption('exists');
await dashboardControls.optionsListEnsurePopoverIsClosed(controlId);
await dashboard.waitForRenderComplete();
it('new control has expected suggestions', async () => {
controlId = (await dashboardControls.getAllControlIds())[0];
await ensureAvailableOptionsEql(FIELD_VALUES);
});
expect(await pieChart.getPieSliceCount()).to.be(5);
expect((await pieChart.getPieChartValues())[0]).to.be(5);
});
it('negating exists query has expected results', async () => {
await dashboardControls.optionsListOpenPopover(controlId);
await dashboardControls.optionsListPopoverSetIncludeSelections(false);
await dashboardControls.optionsListEnsurePopoverIsClosed(controlId);
await dashboard.waitForRenderComplete();
expect(await pieChart.getPieSliceCount()).to.be(1);
expect((await pieChart.getPieChartValues())[0]).to.be(1);
});
it('making selection has expected results', async () => {
await dashboardControls.optionsListOpenPopover(controlId);
await dashboardControls.optionsListPopoverSelectOption('B');
await dashboardControls.optionsListEnsurePopoverIsClosed(controlId);
expect(await pieChart.getPieChartLabels()).to.eql(['bark', 'bow ow ow']);
});
after(async () => {
await dashboardControls.deleteAllControls();
await dashboard.clickQuickSave();
await header.waitUntilLoadingHasFinished();
await common.navigateToApp('settings');
await settings.clickKibanaIndexPatterns();
await settings.clickIndexPatternByName('animals-*');
await settings.filterField('testRuntimeField');
await testSubjects.click('deleteField');
await settings.confirmDelete();
});
});
describe('test exists query', async () => {
const newDocuments: Array<{ index: string; id: string }> = [];
const addDocument = async (index: string, document: string) => {
await console.enterRequest('\nPOST ' + index + '/_doc/ \n{\n ' + document);
await console.clickPlay();
await header.waitUntilLoadingHasFinished();
const response = JSON.parse(await console.getResponse());
newDocuments.push({ index, id: response._id });
};
before(async () => {
await common.navigateToApp('console');
await console.collapseHelp();
await console.clearTextArea();
await addDocument(
'animals-cats-2018-01-01',
'"@timestamp": "2018-01-01T16:00:00.000Z", \n"name": "Rosie", \n"sound": "hiss"'
);
await returnToDashboard();
await dashboardControls.createControl({
controlType: OPTIONS_LIST_CONTROL,
dataViewTitle: 'animals-*',
fieldName: 'animal.keyword',
title: 'Animal',
});
controlId = (await dashboardControls.getAllControlIds())[0];
await header.waitUntilLoadingHasFinished();
await dashboard.waitForRenderComplete();
});
it('creating exists query has expected results', async () => {
expect((await pieChart.getPieChartValues())[0]).to.be(6);
await dashboardControls.optionsListOpenPopover(controlId);
await dashboardControls.optionsListPopoverSelectOption('exists');
await dashboardControls.optionsListEnsurePopoverIsClosed(controlId);
await dashboard.waitForRenderComplete();
expect(await pieChart.getPieSliceCount()).to.be(5);
expect((await pieChart.getPieChartValues())[0]).to.be(5);
});
it('negating exists query has expected results', async () => {
await dashboardControls.optionsListOpenPopover(controlId);
await dashboardControls.optionsListPopoverSetIncludeSelections(false);
await dashboardControls.optionsListEnsurePopoverIsClosed(controlId);
await dashboard.waitForRenderComplete();
expect(await pieChart.getPieSliceCount()).to.be(1);
expect((await pieChart.getPieChartValues())[0]).to.be(1);
});
after(async () => {
await common.navigateToApp('console');
await console.clearTextArea();
for (const { index, id } of newDocuments) {
await console.enterRequest(`\nDELETE /${index}/_doc/${id}`);
await console.clickPlay();
await header.waitUntilLoadingHasFinished();
}
await returnToDashboard();
await dashboardControls.deleteAllControls();
});
});
describe('Options List dashboard validation', async () => {
before(async () => {
await dashboardControls.createControl({
controlType: OPTIONS_LIST_CONTROL,
dataViewTitle: 'animals-*',
@ -437,11 +520,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
title: 'Animal Sounds',
});
controlId = (await dashboardControls.getAllControlIds())[0];
});
});
describe('Options List dashboard validation', async () => {
before(async () => {
await dashboardControls.optionsListOpenPopover(controlId);
await dashboardControls.optionsListPopoverSelectOption('meow');
await dashboardControls.optionsListPopoverSelectOption('bark');
@ -528,14 +607,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
});
after(async () => {
await common.navigateToApp('console');
await console.collapseHelp();
await console.clearTextArea();
for (const { index, id } of newDocuments) {
await console.enterRequest(`\nDELETE /${index}/_doc/${id}`);
await console.clickPlay();
await header.waitUntilLoadingHasFinished();
}
await security.testUser.restoreDefaults();
});
});