[Expressions] Partial results example plugin (#113001) (#113143)

* Update mapColumn expression function implementation to support partial results
* Add partial results example plugin
# Conflicts:
#	.github/CODEOWNERS
This commit is contained in:
Michael Dokolin 2021-09-27 21:18:44 +02:00 committed by GitHub
parent 3398a48720
commit 47a4c3de2a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 484 additions and 56 deletions

View file

@ -0,0 +1,9 @@
## Partial Results Example
The partial results is a feature of the expressions plugin allowing to emit intermediate execution results over time.
This example plugin demonstrates:
1. An expression function emitting a datatable with intermediate results (`getEvents`).
2. An expression function emitting an infinite number of results (`countEvent`).
3. A combination of those two functions using the `mapColumn` function that continuously updates the resulting table.

View file

@ -0,0 +1,12 @@
{
"id": "paertialResultsExample",
"version": "0.1.0",
"kibanaVersion": "kibana",
"ui": true,
"owner": {
"name": "App Services",
"githubTeam": "kibana-app-services"
},
"description": "A plugin demonstrating partial results in the expressions plugin",
"requiredPlugins": ["developerExamples", "expressions"]
}

View file

@ -0,0 +1,90 @@
/*
* 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 React, { useContext, useEffect, useState } from 'react';
import { pluck } from 'rxjs/operators';
import {
EuiBasicTable,
EuiCallOut,
EuiCodeBlock,
EuiPage,
EuiPageBody,
EuiPageContent,
EuiPageContentBody,
EuiPageHeader,
EuiPageHeaderSection,
EuiSpacer,
EuiText,
EuiTitle,
} from '@elastic/eui';
import type { Datatable } from 'src/plugins/expressions';
import { ExpressionsContext } from './expressions_context';
const expression = `getEvents
| mapColumn name="Count" expression={
countEvent {pluck "event"}
}
`;
export function App() {
const expressions = useContext(ExpressionsContext);
const [datatable, setDatatable] = useState<Datatable>();
useEffect(() => {
const subscription = expressions
?.execute<null, Datatable>(expression, null)
.getData()
.pipe(pluck('result'))
.subscribe((value) => setDatatable(value as Datatable));
return () => subscription?.unsubscribe();
}, [expressions]);
return (
<EuiPage>
<EuiPageBody style={{ maxWidth: 1200, margin: '0 auto' }}>
<EuiPageHeader>
<EuiPageHeaderSection>
<EuiTitle size="l">
<h1>Partial Results Demo</h1>
</EuiTitle>
</EuiPageHeaderSection>
</EuiPageHeader>
<EuiPageContent>
<EuiPageContentBody style={{ maxWidth: 800, margin: '0 auto' }}>
<EuiText data-test-subj="example-help">
<p>
This example listens for the window events and adds them to the table along with a
trigger counter.
</p>
</EuiText>
<EuiSpacer size={'m'} />
<EuiCodeBlock>{expression}</EuiCodeBlock>
<EuiSpacer size={'m'} />
{datatable ? (
<EuiBasicTable
textOnly={true}
data-test-subj={'example-table'}
columns={datatable.columns?.map(({ id: field, name }) => ({
field,
name,
'data-test-subj': `example-column-${field.toLowerCase()}`,
}))}
items={datatable.rows ?? []}
/>
) : (
<EuiCallOut color="success">
<p>Click or press any key.</p>
</EuiCallOut>
)}
</EuiPageContentBody>
</EuiPageContent>
</EuiPageBody>
</EuiPage>
);
}

View file

@ -0,0 +1,12 @@
/*
* 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 { createContext } from 'react';
import type { ExpressionsServiceStart } from 'src/plugins/expressions';
export const ExpressionsContext = createContext<ExpressionsServiceStart | undefined>(undefined);

View file

@ -0,0 +1,10 @@
/*
* 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.
*/
export * from './app';
export * from './expressions_context';

View file

@ -0,0 +1,40 @@
/*
* 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 { Observable, fromEvent } from 'rxjs';
import { scan, startWith } from 'rxjs/operators';
import type { ExpressionFunctionDefinition } from 'src/plugins/expressions';
export interface CountEventArguments {
event: string;
}
export const countEvent: ExpressionFunctionDefinition<
'countEvent',
null,
CountEventArguments,
Observable<number>
> = {
name: 'countEvent',
type: 'number',
help: 'Subscribes for an event and counts a number of triggers.',
args: {
event: {
aliases: ['_'],
types: ['string'],
help: 'The event name.',
required: true,
},
},
fn(input, { event }) {
return fromEvent(window, event).pipe(
scan((count) => count + 1, 1),
startWith(1)
);
},
};

View file

@ -0,0 +1,51 @@
/*
* 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 { Observable, fromEvent, merge } from 'rxjs';
import { distinct, map, pluck, scan, take } from 'rxjs/operators';
import type { Datatable, ExpressionFunctionDefinition } from 'src/plugins/expressions';
const EVENTS: Array<keyof WindowEventMap> = [
'mousedown',
'mouseup',
'click',
'keydown',
'keyup',
'keypress',
];
export const getEvents: ExpressionFunctionDefinition<
'getEvents',
null,
{},
Observable<Datatable>
> = {
name: 'getEvents',
type: 'datatable',
help: 'Listens for the window events and returns a table with the triggered ones.',
args: {},
fn() {
return merge(...EVENTS.map((event) => fromEvent(window, event))).pipe(
pluck('type'),
distinct(),
take(EVENTS.length),
scan((events, event) => [...events, event], [] as string[]),
map((events) => ({
type: 'datatable',
columns: [
{
id: 'event',
meta: { type: 'string' },
name: 'Event',
},
],
rows: Array.from(events).map((event) => ({ event })),
}))
);
},
};

View file

@ -0,0 +1,11 @@
/*
* 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.
*/
export * from './count_event';
export * from './get_events';
export * from './pluck';

View file

@ -0,0 +1,32 @@
/*
* 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 type { Datatable, ExpressionFunctionDefinition } from 'src/plugins/expressions';
export interface PluckArguments {
key: string;
}
export const pluck: ExpressionFunctionDefinition<'pluck', Datatable, PluckArguments, unknown> = {
name: 'pluck',
inputTypes: ['datatable'],
help: 'Takes a cell from the first table row.',
args: {
key: {
aliases: ['_'],
types: ['string'],
help: 'The column id.',
required: true,
},
},
fn({ rows }, { key }) {
const [{ [key]: value }] = rows;
return value;
},
};

View file

@ -0,0 +1,12 @@
/*
* 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 { PartialResultsExamplePlugin } from './plugin';
export function plugin() {
return new PartialResultsExamplePlugin();
}

View file

@ -0,0 +1,57 @@
/*
* 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 React from 'react';
import ReactDOM from 'react-dom';
import type { ExpressionsService, ExpressionsServiceSetup } from 'src/plugins/expressions';
import { AppMountParameters, AppNavLinkStatus, CoreSetup, Plugin } from '../../../src/core/public';
import type { DeveloperExamplesSetup } from '../../developer_examples/public';
import { App, ExpressionsContext } from './app';
import { countEvent, getEvents, pluck } from './functions';
interface SetupDeps {
developerExamples: DeveloperExamplesSetup;
expressions: ExpressionsServiceSetup;
}
export class PartialResultsExamplePlugin implements Plugin<void, void, SetupDeps> {
private expressions?: ExpressionsService;
setup({ application }: CoreSetup, { expressions, developerExamples }: SetupDeps) {
this.expressions = expressions.fork();
this.expressions.registerFunction(countEvent);
this.expressions.registerFunction(getEvents);
this.expressions.registerFunction(pluck);
application.register({
id: 'partialResultsExample',
title: 'Partial Results Example',
navLinkStatus: AppNavLinkStatus.hidden,
mount: async ({ element }: AppMountParameters) => {
ReactDOM.render(
<ExpressionsContext.Provider value={this.expressions}>
<App />
</ExpressionsContext.Provider>,
element
);
return () => ReactDOM.unmountComponentAtNode(element);
},
});
developerExamples.register({
appId: 'partialResultsExample',
title: 'Partial Results Example',
description: 'Learn how to use partial results in the expressions plugin.',
});
}
start() {
return {};
}
stop() {}
}

View file

@ -0,0 +1,19 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./target",
"skipLibCheck": true
},
"include": [
"index.ts",
"public/**/*.ts",
"public/**/*.tsx",
"../../typings/**/*"
],
"exclude": [],
"references": [
{ "path": "../../src/core/tsconfig.json" },
{ "path": "../developer_examples/tsconfig.json" },
{ "path": "../../src/plugins/expressions/tsconfig.json" },
]
}

View file

@ -6,11 +6,11 @@
* Side Public License, v 1.
*/
import { Observable, defer, of, zip } from 'rxjs';
import { map } from 'rxjs/operators';
import { Observable, combineLatest, defer } from 'rxjs';
import { defaultIfEmpty, map } from 'rxjs/operators';
import { i18n } from '@kbn/i18n';
import { ExpressionFunctionDefinition } from '../types';
import { Datatable, DatatableColumn, DatatableColumnType, getType } from '../../expression_types';
import { Datatable, DatatableColumnType, getType } from '../../expression_types';
export interface MapColumnArguments {
id?: string | null;
@ -81,64 +81,59 @@ export const mapColumn: ExpressionFunctionDefinition<
},
},
fn(input, args) {
const metaColumn = args.copyMetaFrom
? input.columns.find(({ id }) => id === args.copyMetaFrom)
: undefined;
const existingColumnIndex = input.columns.findIndex(({ id, name }) =>
args.id ? id === args.id : name === args.name
);
const id = input.columns[existingColumnIndex]?.id ?? args.id ?? args.name;
const columnIndex = existingColumnIndex === -1 ? input.columns.length : existingColumnIndex;
const id = input.columns[columnIndex]?.id ?? args.id ?? args.name;
return defer(() => {
const rows$ = input.rows.length
? zip(
...input.rows.map((row) =>
args
.expression({
type: 'datatable',
columns: [...input.columns],
rows: [row],
})
.pipe(map((value) => ({ ...row, [id]: value })))
return defer(() =>
combineLatest(
input.rows.map((row) =>
args
.expression({
type: 'datatable',
columns: [...input.columns],
rows: [row],
})
.pipe(
map((value) => ({ ...row, [id]: value })),
defaultIfEmpty(row)
)
)
: of([]);
return rows$.pipe<Datatable>(
map((rows) => {
let type: DatatableColumnType = 'null';
if (rows.length) {
for (const row of rows) {
const rowType = getType(row[id]);
if (rowType !== 'null') {
type = rowType;
break;
}
}
}
const newColumn: DatatableColumn = {
id,
name: args.name,
meta: { type, params: { id: type } },
};
if (args.copyMetaFrom) {
const metaSourceFrom = input.columns.find(
({ id: columnId }) => columnId === args.copyMetaFrom
);
newColumn.meta = { ...newColumn.meta, ...(metaSourceFrom?.meta ?? {}) };
)
)
).pipe(
defaultIfEmpty([] as Datatable['rows']),
map((rows) => {
let type: DatatableColumnType = 'null';
for (const row of rows) {
const rowType = getType(row[id]);
if (rowType !== 'null') {
type = rowType;
break;
}
}
const columns = [...input.columns];
if (existingColumnIndex === -1) {
columns.push(newColumn);
} else {
columns[existingColumnIndex] = newColumn;
}
const columns = [...input.columns];
columns[columnIndex] = {
id,
name: args.name,
meta: {
type,
params: { id: type },
...(metaColumn?.meta ?? {}),
},
};
return {
columns,
rows,
type: 'datatable',
};
})
);
});
return {
columns,
rows,
type: 'datatable',
};
})
);
},
};

View file

@ -12,8 +12,9 @@ import { Datatable } from '../../../expression_types';
import { mapColumn, MapColumnArguments } from '../map_column';
import { emptyTable, functionWrapper, testTable, tableWithNulls } from './utils';
const pricePlusTwo = (datatable: Datatable) =>
of(typeof datatable.rows[0].price === 'number' ? datatable.rows[0].price + 2 : null);
const pricePlusTwo = jest.fn((datatable: Datatable) =>
of(typeof datatable.rows[0].price === 'number' ? datatable.rows[0].price + 2 : null)
);
describe('mapColumn', () => {
const fn = functionWrapper(mapColumn);
@ -266,4 +267,33 @@ describe('mapColumn', () => {
]);
});
});
it('supports partial results', () => {
testScheduler.run(({ expectObservable, cold }) => {
pricePlusTwo.mockReturnValueOnce(cold('ab|', { a: 1000, b: 2000 }));
expectObservable(
runFn(testTable, {
id: 'pricePlusTwo',
name: 'pricePlusTwo',
expression: pricePlusTwo,
})
).toBe('01|', [
expect.objectContaining({
rows: expect.arrayContaining([
expect.objectContaining({
pricePlusTwo: 1000,
}),
]),
}),
expect.objectContaining({
rows: expect.arrayContaining([
expect.objectContaining({
pricePlusTwo: 2000,
}),
]),
}),
]);
});
});
});

View file

@ -32,6 +32,7 @@ export default async function ({ readConfigFile }) {
require.resolve('./expressions_explorer'),
require.resolve('./index_pattern_field_editor_example'),
require.resolve('./field_formats'),
require.resolve('./partial_results'),
],
services: {
...functionalConfig.get('services'),

View file

@ -0,0 +1,47 @@
/*
* 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 expect from '@kbn/expect';
import { FtrProviderContext } from 'test/functional/ftr_provider_context';
// eslint-disable-next-line import/no-default-export
export default function ({ getService, getPageObjects }: FtrProviderContext) {
const testSubjects = getService('testSubjects');
const PageObjects = getPageObjects(['common']);
describe('Partial Results Example', function () {
before(async () => {
this.tags('ciGroup2');
await PageObjects.common.navigateToApp('partialResultsExample');
const element = await testSubjects.find('example-help');
await element.click();
await element.click();
await element.click();
});
it('should trace mouse events', async () => {
const events = await Promise.all(
(
await testSubjects.findAll('example-column-event')
).map((wrapper) => wrapper.getVisibleText())
);
expect(events).to.eql(['mousedown', 'mouseup', 'click']);
});
it('should keep track of the events number', async () => {
const counters = await Promise.all(
(
await testSubjects.findAll('example-column-count')
).map((wrapper) => wrapper.getVisibleText())
);
expect(counters).to.eql(['3', '3', '3']);
});
});
}