decouple url drilldown action from Embeddable framework (#175930)

Part of https://github.com/elastic/kibana/issues/175138 and prerequisite
for https://github.com/elastic/kibana/issues/174960

PR decouples Url drilldown action from Embeddable framework by migrating
to sets of composable interfaces.

### test instructions
1. Create panel and add "Url drilldown"
2. Verify `context` variables are populated with the same values when
they were grabbed from embeddable.input and embeddable.output
<img width="600" alt="Screenshot 2024-01-31 at 2 11 20 PM"
src="150f7a61-911f-4fb6-bf85-5c0481865e4b">

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Nathan Reese 2024-02-05 09:33:41 -07:00 committed by GitHub
parent d17fa4bf4e
commit ca98633754
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 229 additions and 385 deletions

View file

@ -7,6 +7,7 @@
*/
import { i18n } from '@kbn/i18n';
import type { EmbeddableApiContext } from '@kbn/presentation-publishing';
import type { Datatable } from '@kbn/expressions-plugin/common';
import { Trigger } from '.';
@ -22,10 +23,7 @@ export const rowClickTrigger: Trigger = {
}),
};
export interface RowClickContext {
// Need to make this unknown to prevent circular dependencies.
// Apps using this property will need to cast to `IEmbeddable`.
embeddable?: unknown;
export type RowClickContext = Partial<EmbeddableApiContext> & {
data: {
/**
* Row index, starting from 0, where user clicked.
@ -40,4 +38,4 @@ export interface RowClickContext {
*/
columns?: string[];
};
}
};

View file

@ -18,5 +18,6 @@
"kbn_references": [
"@kbn/i18n",
"@kbn/expressions-plugin",
"@kbn/presentation-publishing",
]
}

View file

@ -686,4 +686,12 @@ export class DashboardContainer
}
if (resetChangedPanelCount) this.reactEmbeddableChildren.next(currentChildren);
};
public getFilters() {
return this.getInput().filters;
}
public getQuery(): Query | undefined {
return this.getInput().query;
}
}

View file

@ -13,7 +13,14 @@ import { i18n } from '@kbn/i18n';
import { PhaseEvent, PhaseEventType } from '@kbn/presentation-publishing';
import deepEqual from 'fast-deep-equal';
import { isNil } from 'lodash';
import { BehaviorSubject, map, Subscription, distinct } from 'rxjs';
import {
BehaviorSubject,
map,
Subscription,
distinct,
combineLatest,
distinctUntilChanged,
} from 'rxjs';
import { embeddableStart } from '../../../kibana_services';
import { isFilterableEmbeddable } from '../../filterable_embeddable';
import {
@ -31,7 +38,7 @@ import {
import { canLinkLegacyEmbeddable, linkLegacyEmbeddable } from './link_legacy_embeddable';
import { canUnlinkLegacyEmbeddable, unlinkLegacyEmbeddable } from './unlink_legacy_embeddable';
export type CommonLegacyInput = EmbeddableInput & { timeRange: TimeRange };
export type CommonLegacyInput = EmbeddableInput & { savedObjectId?: string; timeRange: TimeRange };
export type CommonLegacyOutput = EmbeddableOutput & { indexPatterns: DataView[] };
export type CommonLegacyEmbeddable = IEmbeddable<CommonLegacyInput, CommonLegacyOutput>;
@ -135,6 +142,25 @@ export const legacyEmbeddableToApi = (
const defaultPanelTitle = outputKeyToSubject<string>('defaultTitle');
const disabledActionIds = inputKeyToSubject<string[] | undefined>('disabledActions');
function getSavedObjectId(input: { savedObjectId?: string }, output: { savedObjectId?: string }) {
return output.savedObjectId ?? input.savedObjectId;
}
const savedObjectId = new BehaviorSubject<string | undefined>(
getSavedObjectId(embeddable.getInput(), embeddable.getOutput())
);
subscriptions.add(
combineLatest([embeddable.getInput$(), embeddable.getOutput$()])
.pipe(
map(([input, output]) => {
return getSavedObjectId(input, output);
}),
distinctUntilChanged()
)
.subscribe((nextSavedObjectId) => {
savedObjectId.next(nextSavedObjectId);
})
);
const blockingError = new BehaviorSubject<ErrorLike | undefined>(undefined);
subscriptions.add(
embeddable.getOutput$().subscribe({
@ -238,6 +264,8 @@ export const legacyEmbeddableToApi = (
canUnlinkFromLibrary: () => canUnlinkLegacyEmbeddable(embeddable),
unlinkFromLibrary: () => unlinkLegacyEmbeddable(embeddable),
savedObjectId,
},
destroyAPI: () => {
subscriptions.unsubscribe();

View file

@ -145,6 +145,7 @@ export abstract class Embeddable<
getFallbackTimeRange: this.getFallbackTimeRange,
canUnlinkFromLibrary: this.canUnlinkFromLibrary,
isCompatibleWithLocalUnifiedSearch: this.isCompatibleWithLocalUnifiedSearch,
savedObjectId: this.savedObjectId,
} = api);
setTimeout(() => {
@ -186,6 +187,7 @@ export abstract class Embeddable<
public canUnlinkFromLibrary: LegacyEmbeddableAPI['canUnlinkFromLibrary'];
public getFallbackTimeRange: LegacyEmbeddableAPI['getFallbackTimeRange'];
public isCompatibleWithLocalUnifiedSearch: LegacyEmbeddableAPI['isCompatibleWithLocalUnifiedSearch'];
public savedObjectId: LegacyEmbeddableAPI['savedObjectId'];
public getEditHref(): string | undefined {
return this.getOutput().editUrl ?? undefined;

View file

@ -24,6 +24,7 @@ import {
PublishesWritablePanelDescription,
PublishesWritablePanelTitle,
PublishesPhaseEvents,
PublishesSavedObjectId,
} from '@kbn/presentation-publishing';
import { Observable } from 'rxjs';
import { EmbeddableInput } from '../../../common/types';
@ -54,7 +55,8 @@ export type LegacyEmbeddableAPI = HasType &
PublishesWritablePanelDescription &
Partial<CanLinkToLibrary & CanUnlinkFromLibrary> &
HasParentApi<DefaultPresentationPanelApi['parentApi']> &
EmbeddableHasTimeRange;
EmbeddableHasTimeRange &
PublishesSavedObjectId;
export interface EmbeddableAppContext {
/**

View file

@ -7,6 +7,7 @@
*/
import { i18n } from '@kbn/i18n';
import type { EmbeddableApiContext } from '@kbn/presentation-publishing';
import { Datatable, DatatableColumnMeta } from '@kbn/expressions-plugin/common';
import { Trigger, RowClickContext } from '@kbn/ui-actions-plugin/public';
import { BooleanRelation } from '@kbn/es-query';
@ -16,8 +17,7 @@ export interface EmbeddableContext<T extends IEmbeddable = IEmbeddable> {
embeddable: T;
}
export interface ValueClickContext<T extends IEmbeddable = IEmbeddable> {
embeddable?: T;
export type ValueClickContext = Partial<EmbeddableApiContext> & {
data: {
data: Array<{
table: Pick<Datatable, 'rows' | 'columns'>;
@ -28,10 +28,9 @@ export interface ValueClickContext<T extends IEmbeddable = IEmbeddable> {
timeFieldName?: string;
negate?: boolean;
};
}
};
export interface MultiValueClickContext<T extends IEmbeddable = IEmbeddable> {
embeddable?: T;
export type MultiValueClickContext = Partial<EmbeddableApiContext> & {
data: {
data: Array<{
table: Pick<Datatable, 'rows' | 'columns'>;
@ -44,31 +43,29 @@ export interface MultiValueClickContext<T extends IEmbeddable = IEmbeddable> {
timeFieldName?: string;
negate?: boolean;
};
}
};
export interface CellValueContext<T extends IEmbeddable = IEmbeddable> {
embeddable: T;
export type CellValueContext = Partial<EmbeddableApiContext> & {
data: Array<{
value?: any;
eventId?: string;
columnMeta?: DatatableColumnMeta;
}>;
}
};
export interface RangeSelectContext<T extends IEmbeddable = IEmbeddable> {
embeddable?: T;
export type RangeSelectContext = Partial<EmbeddableApiContext> & {
data: {
table: Datatable;
column: number;
range: number[];
timeFieldName?: string;
};
}
};
export type ChartActionContext<T extends IEmbeddable = IEmbeddable> =
| ValueClickContext<T>
| MultiValueClickContext<T>
| RangeSelectContext<T>
export type ChartActionContext =
| ValueClickContext
| MultiValueClickContext
| RangeSelectContext
| RowClickContext;
export const CONTEXT_MENU_TRIGGER = 'CONTEXT_MENU_TRIGGER';

View file

@ -11,18 +11,11 @@ import { UiActionsEnhancedDrilldownDefinition as Drilldown } from '@kbn/ui-actio
import { APPLY_FILTER_TRIGGER } from '@kbn/data-plugin/public';
import { ApplyGlobalFilterActionContext } from '@kbn/unified-search-plugin/public';
import { StartDependencies as Start } from '../../plugin';
import { ActionContext, Config, CollectConfigProps } from './types';
import type { ActionApi, ActionContext, Config, CollectConfigProps } from './types';
import { CollectConfigContainer } from './collect_config_container';
import { SAMPLE_DASHBOARD_TO_DISCOVER_DRILLDOWN } from './constants';
import { txtGoToDiscover } from './i18n';
const isOutputWithIndexPatterns = (
output: unknown
): output is { indexPatterns: Array<{ id: string }> } => {
if (!output || typeof output !== 'object') return false;
return Array.isArray((output as any).indexPatterns);
};
export interface Params {
start: StartServicesGetter<Pick<Start, 'data' | 'discover'>>;
}
@ -67,10 +60,10 @@ export class DashboardToDiscoverDrilldown
let indexPatternId =
!!config.customIndexPattern && !!config.indexPatternId ? config.indexPatternId : '';
if (!indexPatternId && !!context.embeddable) {
const output = context.embeddable!.getOutput();
if (isOutputWithIndexPatterns(output) && output.indexPatterns.length > 0) {
indexPatternId = output.indexPatterns[0].id;
if (!indexPatternId) {
const dataViews = (context?.embeddable as ActionApi).dataViews?.value;
if (dataViews?.[0].id) {
indexPatternId = dataViews[0].id;
}
}

View file

@ -5,11 +5,13 @@
* 2.0.
*/
import type { PublishesDataViews } from '@kbn/presentation-publishing';
import { CollectConfigProps as CollectConfigPropsBase } from '@kbn/kibana-utils-plugin/public';
import { ApplyGlobalFilterActionContext } from '@kbn/unified-search-plugin/public';
import { IEmbeddable } from '@kbn/embeddable-plugin/public';
export type ActionContext = ApplyGlobalFilterActionContext & { embeddable: IEmbeddable };
export type ActionApi = Partial<PublishesDataViews>;
export type ActionContext = ApplyGlobalFilterActionContext & { embeddable: ActionApi };
export type Config = {
/**

View file

@ -29,5 +29,6 @@
"@kbn/i18n",
"@kbn/unified-search-plugin",
"@kbn/utility-types",
"@kbn/presentation-publishing",
]
}

View file

@ -6,8 +6,6 @@
*/
import { DatatableColumnType } from '@kbn/expressions-plugin/common';
import type { Query, Filter, TimeRange } from '@kbn/es-query';
import { Embeddable, EmbeddableInput, EmbeddableOutput } from '@kbn/embeddable-plugin/public';
export const createPoint = ({
field,
@ -154,21 +152,3 @@ export const rowClickData = {
'e0719f1a-04fb-4036-a63c-c25deac3f011',
],
};
interface TestInput extends EmbeddableInput {
savedObjectId?: string;
query?: Query;
filters?: Filter[];
timeRange?: TimeRange;
}
interface TestOutput extends EmbeddableOutput {
indexPatterns?: Array<{ id: string }>;
}
export class TestEmbeddable extends Embeddable<TestInput, TestOutput> {
type = 'test';
destroy() {}
reload() {}
}

View file

@ -5,17 +5,18 @@
* 2.0.
*/
import { BehaviorSubject } from 'rxjs';
import { IExternalUrl } from '@kbn/core/public';
import { UrlDrilldown, ActionContext, Config } from './url_drilldown';
import { UrlDrilldown, Config } from './url_drilldown';
import {
IEmbeddable,
ValueClickContext,
VALUE_CLICK_TRIGGER,
SELECT_RANGE_TRIGGER,
CONTEXT_MENU_TRIGGER,
} from '@kbn/embeddable-plugin/public';
import { DatatableColumnType } from '@kbn/expressions-plugin/common';
import { of } from '@kbn/kibana-utils-plugin/common';
import { createPoint, rowClickData, TestEmbeddable } from './test/data';
import { createPoint, rowClickData } from './test/data';
import { ROW_CLICK_TRIGGER } from '@kbn/ui-actions-plugin/public';
import { settingsServiceMock } from '@kbn/core-ui-settings-browser-mocks';
import { themeServiceMock } from '@kbn/core-theme-browser-mocks';
@ -55,14 +56,13 @@ const mockDataPoints = [
},
];
const mockEmbeddable = {
getInput: () => ({
filters: [],
timeRange: { from: 'now-15m', to: 'now' },
query: { query: 'test', language: 'kuery' },
}),
getOutput: () => ({}),
} as unknown as IEmbeddable;
const mockEmbeddableApi = {
parentApi: {
localFilters: new BehaviorSubject([]),
localQuery: new BehaviorSubject({ query: 'test', language: 'kuery' }),
localTimeRange: new BehaviorSubject({ from: 'now-15m', to: 'now' }),
},
};
const mockNavigateToUrl = jest.fn(() => Promise.resolve());
@ -110,7 +110,7 @@ describe('UrlDrilldown', () => {
encodeUrl: true,
};
const context: ActionContext = {
const context: ValueClickContext = {
data: {
data: mockDataPoints,
},
@ -128,11 +128,11 @@ describe('UrlDrilldown', () => {
encodeUrl: true,
};
const context: ActionContext = {
const context: ValueClickContext = {
data: {
data: mockDataPoints,
},
embeddable: mockEmbeddable,
embeddable: mockEmbeddableApi,
};
const result = urlDrilldown.isCompatible(config, context);
@ -148,11 +148,11 @@ describe('UrlDrilldown', () => {
encodeUrl: true,
};
const context: ActionContext = {
const context: ValueClickContext = {
data: {
data: mockDataPoints,
},
embeddable: mockEmbeddable,
embeddable: mockEmbeddableApi,
};
await expect(urlDrilldown.isCompatible(config, context)).resolves.toBe(false);
@ -169,11 +169,11 @@ describe('UrlDrilldown', () => {
encodeUrl: true,
};
const context: ActionContext = {
const context: ValueClickContext = {
data: {
data: mockDataPoints,
},
embeddable: mockEmbeddable,
embeddable: mockEmbeddableApi,
};
const result1 = await drilldown1.isCompatible(config, context);
@ -198,11 +198,11 @@ describe('UrlDrilldown', () => {
encodeUrl: true,
};
const context: ActionContext = {
const context: ValueClickContext = {
data: {
data: mockDataPoints,
},
embeddable: mockEmbeddable,
embeddable: mockEmbeddableApi,
};
const url = await urlDrilldown.getHref(config, context);
@ -221,11 +221,11 @@ describe('UrlDrilldown', () => {
encodeUrl: true,
};
const context: ActionContext = {
const context: ValueClickContext = {
data: {
data: mockDataPoints,
},
embeddable: mockEmbeddable,
embeddable: mockEmbeddableApi,
};
await expect(urlDrilldown.getHref(config, context)).rejects.toThrowError();
@ -244,11 +244,11 @@ describe('UrlDrilldown', () => {
encodeUrl: true,
};
const context: ActionContext = {
const context: ValueClickContext = {
data: {
data: mockDataPoints,
},
embeddable: mockEmbeddable,
embeddable: mockEmbeddableApi,
};
const url = await drilldown1.getHref(config, context);
@ -272,16 +272,12 @@ describe('UrlDrilldown', () => {
});
describe('variables', () => {
const embeddable1 = new TestEmbeddable(
{
id: 'test',
title: 'The Title',
savedObjectId: 'SAVED_OBJECT_IDxx',
},
{
indexPatterns: [{ id: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' }],
}
);
const embeddable1 = {
dataViews: new BehaviorSubject([{ id: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' }]),
panelTitle: new BehaviorSubject('The Title'),
savedObjectId: new BehaviorSubject('SAVED_OBJECT_IDxx'),
uuid: 'test',
};
const data = {
data: [
createPoint({ field: 'field0', value: 'value0' }),
@ -290,18 +286,13 @@ describe('UrlDrilldown', () => {
],
};
const embeddable2 = new TestEmbeddable(
{
id: 'the-id',
query: {
language: 'C++',
query: 'std::cout << 123;',
},
timeRange: {
from: 'FROM',
to: 'TO',
},
filters: [
const embeddable2 = {
dataViews: new BehaviorSubject([
{ id: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' },
{ id: 'yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy' },
]),
parentApi: {
localFilters: new BehaviorSubject([
{
meta: {
alias: 'asdf',
@ -309,17 +300,17 @@ describe('UrlDrilldown', () => {
negate: false,
},
},
],
savedObjectId: 'SAVED_OBJECT_ID',
]),
localQuery: new BehaviorSubject({
language: 'C++',
query: 'std::cout << 123;',
}),
localTimeRange: new BehaviorSubject({ from: 'FROM', to: 'TO' }),
},
{
title: 'The Title',
indexPatterns: [
{ id: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' },
{ id: 'yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy' },
],
}
);
panelTitle: new BehaviorSubject('The Title'),
savedObjectId: new BehaviorSubject('SAVED_OBJECT_ID'),
uuid: 'the-id',
};
describe('getRuntimeVariables()', () => {
test('builds runtime variables for VALUE_CLICK_TRIGGER trigger', () => {
@ -497,11 +488,11 @@ describe('UrlDrilldown', () => {
describe('encoding', () => {
const urlDrilldown = createDrilldown();
const context: ActionContext = {
const context: ValueClickContext = {
data: {
data: mockDataPoints,
},
embeddable: mockEmbeddable,
embeddable: mockEmbeddableApi,
};
test('encodes URL by default', async () => {

View file

@ -7,17 +7,15 @@
import React from 'react';
import { IExternalUrl, ThemeServiceStart } from '@kbn/core/public';
import type { EmbeddableApiContext } from '@kbn/presentation-publishing';
import {
ChartActionContext,
CONTEXT_MENU_TRIGGER,
IEmbeddable,
EmbeddableInput,
SELECT_RANGE_TRIGGER,
VALUE_CLICK_TRIGGER,
} from '@kbn/embeddable-plugin/public';
import { IMAGE_CLICK_TRIGGER } from '@kbn/image-embeddable-plugin/public';
import { ActionExecutionContext, ROW_CLICK_TRIGGER } from '@kbn/ui-actions-plugin/public';
import type { Query, Filter, TimeRange } from '@kbn/es-query';
import type { CollectConfigProps as CollectConfigPropsBase } from '@kbn/kibana-utils-plugin/public';
import { UrlTemplateEditorVariable, KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
import {
@ -36,15 +34,6 @@ import { getEventVariableList, getEventScopeValues } from './variables/event_var
import { getContextVariableList, getContextScopeValues } from './variables/context_variables';
import { getGlobalVariableList } from './variables/global_variables';
interface EmbeddableQueryInput extends EmbeddableInput {
query?: Query;
filters?: Filter[];
timeRange?: TimeRange;
}
/** @internal */
export type EmbeddableWithQueryInput = IEmbeddable<EmbeddableQueryInput>;
interface UrlDrilldownDeps {
externalUrl: IExternalUrl;
getGlobalScope: () => UrlDrilldownGlobalScope;
@ -55,7 +44,6 @@ interface UrlDrilldownDeps {
theme: () => ThemeServiceStart;
}
export type ActionContext = ChartActionContext<EmbeddableWithQueryInput>;
export type Config = UrlDrilldownConfig;
export type UrlTrigger =
| typeof VALUE_CLICK_TRIGGER
@ -64,14 +52,13 @@ export type UrlTrigger =
| typeof CONTEXT_MENU_TRIGGER
| typeof IMAGE_CLICK_TRIGGER;
export interface ActionFactoryContext extends BaseActionFactoryContext {
embeddable?: EmbeddableWithQueryInput;
}
export type ActionFactoryContext = Partial<EmbeddableApiContext> & BaseActionFactoryContext;
export type CollectConfigProps = CollectConfigPropsBase<Config, ActionFactoryContext>;
const URL_DRILLDOWN = 'URL_DRILLDOWN';
export class UrlDrilldown implements Drilldown<Config, ActionContext, ActionFactoryContext> {
export class UrlDrilldown implements Drilldown<Config, ChartActionContext, ActionFactoryContext> {
public readonly id = URL_DRILLDOWN;
constructor(private readonly deps: UrlDrilldownDeps) {}
@ -85,7 +72,7 @@ export class UrlDrilldown implements Drilldown<Config, ActionContext, ActionFact
public readonly actionMenuItem: React.FC<{
config: Omit<SerializedAction<UrlDrilldownConfig>, 'factoryId'>;
context: ActionContext | ActionExecutionContext<ActionContext>;
context: ChartActionContext | ActionExecutionContext<ChartActionContext>;
}> = ({ config, context }) => {
const [title, setTitle] = React.useState(config.name);
React.useEffect(() => {
@ -152,7 +139,7 @@ export class UrlDrilldown implements Drilldown<Config, ActionContext, ActionFact
return !!config.url.template;
};
public readonly isCompatible = async (config: Config, context: ActionContext) => {
public readonly isCompatible = async (config: Config, context: ChartActionContext) => {
const scope = this.getRuntimeVariables(context);
const { isValid, error } = await urlDrilldownValidateUrlTemplate(config.url, scope);
@ -173,7 +160,7 @@ export class UrlDrilldown implements Drilldown<Config, ActionContext, ActionFact
return true;
};
private async buildUrl(config: Config, context: ActionContext): Promise<string> {
private async buildUrl(config: Config, context: ChartActionContext): Promise<string> {
const doEncode = config.encodeUrl ?? true;
const url = await urlDrilldownCompileUrl(
config.url.template,
@ -183,7 +170,10 @@ export class UrlDrilldown implements Drilldown<Config, ActionContext, ActionFact
return url;
}
public readonly getHref = async (config: Config, context: ActionContext): Promise<string> => {
public readonly getHref = async (
config: Config,
context: ChartActionContext
): Promise<string> => {
const url = await this.buildUrl(config, context);
const validUrl = this.deps.externalUrl.validateUrl(url);
if (!validUrl) {
@ -195,7 +185,7 @@ export class UrlDrilldown implements Drilldown<Config, ActionContext, ActionFact
return url;
};
public readonly execute = async (config: Config, context: ActionContext) => {
public readonly execute = async (config: Config, context: ChartActionContext) => {
const url = await this.getHref(config, context);
if (config.openInNewTab) {
window.open(url, '_blank', 'noopener');
@ -204,7 +194,7 @@ export class UrlDrilldown implements Drilldown<Config, ActionContext, ActionFact
}
};
public readonly getRuntimeVariables = (context: ActionContext) => {
public readonly getRuntimeVariables = (context: ChartActionContext) => {
return {
event: getEventScopeValues(context),
context: getContextScopeValues(context),

View file

@ -5,134 +5,21 @@
* 2.0.
*/
import { BehaviorSubject } from 'rxjs';
import { getContextScopeValues } from './context_variables';
import { TestEmbeddable } from '../test/data';
describe('getContextScopeValues()', () => {
test('returns only ID for empty embeddable', () => {
const embeddable = new TestEmbeddable(
{
id: 'test',
},
{}
);
const vars = getContextScopeValues({ embeddable });
expect(vars).toEqual({
panel: {
id: 'test',
},
test('excludes undefined values', () => {
const embeddableApi = {};
expect(getContextScopeValues({ embeddable: embeddableApi })).toEqual({
panel: {},
});
});
test('returns title as specified in input', () => {
const embeddable = new TestEmbeddable(
{
id: 'test',
title: 'title1',
},
{}
);
const vars = getContextScopeValues({ embeddable });
expect(vars).toEqual({
panel: {
id: 'test',
title: 'title1',
},
});
});
test('returns output title if input and output titles are specified', () => {
const embeddable = new TestEmbeddable(
{
id: 'test',
title: 'title1',
},
{
title: 'title2',
}
);
const vars = getContextScopeValues({ embeddable });
expect(vars).toEqual({
panel: {
id: 'test',
title: 'title2',
},
});
});
test('returns title from output if title in input is missing', () => {
const embeddable = new TestEmbeddable(
{
id: 'test',
},
{
title: 'title2',
}
);
const vars = getContextScopeValues({ embeddable });
expect(vars).toEqual({
panel: {
id: 'test',
title: 'title2',
},
});
});
test('returns saved object ID from output', () => {
const embeddable = new TestEmbeddable(
{
id: 'test',
savedObjectId: '5678',
},
{
savedObjectId: '1234',
}
);
const vars = getContextScopeValues({ embeddable });
expect(vars).toEqual({
panel: {
id: 'test',
savedObjectId: '1234',
},
});
});
test('returns saved object ID from input if it is not set on output', () => {
const embeddable = new TestEmbeddable(
{
id: 'test',
savedObjectId: '5678',
},
{}
);
const vars = getContextScopeValues({ embeddable });
expect(vars).toEqual({
panel: {
id: 'test',
savedObjectId: '5678',
},
});
});
test('returns query, timeRange and filters from input', () => {
const embeddable = new TestEmbeddable(
{
id: 'test',
query: {
language: 'C++',
query: 'std::cout << 123;',
},
timeRange: {
from: 'FROM',
to: 'TO',
},
filters: [
test('returns values when provided', () => {
const embeddableApi = {
parentApi: {
localFilters: new BehaviorSubject([
{
meta: {
alias: 'asdf',
@ -140,13 +27,18 @@ describe('getContextScopeValues()', () => {
negate: false,
},
},
],
]),
localQuery: new BehaviorSubject({
language: 'C++',
query: 'std::cout << 123;',
}),
localTimeRange: new BehaviorSubject({ from: 'FROM', to: 'TO' }),
},
{}
);
const vars = getContextScopeValues({ embeddable });
expect(vars).toEqual({
panelTitle: new BehaviorSubject('title1'),
savedObjectId: new BehaviorSubject('1234'),
uuid: 'test',
};
expect(getContextScopeValues({ embeddable: embeddableApi })).toEqual({
panel: {
id: 'test',
query: {
@ -166,46 +58,32 @@ describe('getContextScopeValues()', () => {
},
},
],
savedObjectId: '1234',
title: 'title1',
},
});
});
test('returns a single index pattern from output', () => {
const embeddable = new TestEmbeddable(
{
id: 'test',
},
{
indexPatterns: [{ id: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' }],
}
);
const vars = getContextScopeValues({ embeddable });
expect(vars).toEqual({
const embeddableApi = {
dataViews: new BehaviorSubject([{ id: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' }]),
};
expect(getContextScopeValues({ embeddable: embeddableApi })).toEqual({
panel: {
id: 'test',
indexPatternId: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx',
},
});
});
test('returns multiple index patterns from output', () => {
const embeddable = new TestEmbeddable(
{
id: 'test',
},
{
indexPatterns: [
{ id: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' },
{ id: 'yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy' },
],
}
);
const vars = getContextScopeValues({ embeddable });
expect(vars).toEqual({
const embeddableApi = {
dataViews: new BehaviorSubject([
{ id: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' },
{ id: 'yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy' },
]),
};
expect(getContextScopeValues({ embeddable: embeddableApi })).toEqual({
panel: {
id: 'test',
indexPatternIds: [
'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx',
'yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy',

View file

@ -8,26 +8,33 @@
import { i18n } from '@kbn/i18n';
import { monaco } from '@kbn/monaco';
import { getFlattenedObject } from '@kbn/std';
import type { Filter, Query, TimeRange } from '@kbn/es-query';
import { EmbeddableInput, EmbeddableOutput } from '@kbn/embeddable-plugin/public';
import type { Filter, AggregateQuery, Query, TimeRange } from '@kbn/es-query';
import type {
EmbeddableApiContext,
HasParentApi,
HasUniqueId,
PublishesPanelTitle,
PublishesSavedObjectId,
PublishesLocalUnifiedSearch,
PublishesDataViews,
} from '@kbn/presentation-publishing';
import type { UrlTemplateEditorVariable } from '@kbn/kibana-react-plugin/public';
import { txtValue } from './i18n';
import type { EmbeddableWithQueryInput } from '../url_drilldown';
import { deleteUndefinedKeys } from './util';
import type { ActionFactoryContext } from '../url_drilldown';
/**
* Part of context scope extracted from an embeddable
* Part of context scope extracted from an api
* Expose on the scope as: `{{context.panel.id}}`, `{{context.panel.filters.[0]}}`
*/
interface PanelValues extends EmbeddableInput {
interface PanelValues {
/**
* ID of the embeddable panel.
* ID of the api panel.
*/
id: string;
id?: string;
/**
* Title of the embeddable panel.
* Title of the api panel.
*/
title?: string;
@ -41,7 +48,7 @@ interface PanelValues extends EmbeddableInput {
*/
indexPatternIds?: string[];
query?: Query;
query?: Query | AggregateQuery;
filters?: Filter[];
timeRange?: TimeRange;
savedObjectId?: string;
@ -51,65 +58,34 @@ export interface ContextValues {
panel: PanelValues;
}
function hasSavedObjectId(obj: Record<string, unknown>): obj is { savedObjectId: string } {
return 'savedObjectId' in obj && typeof obj.savedObjectId === 'string';
}
/**
* @todo Same functionality is implemented in x-pack/plugins/discover_enhanced/public/actions/explore_data/shared.ts,
* combine both implementations into a common approach.
*/
function getIndexPatternIds(output: EmbeddableOutput): string[] {
function hasIndexPatterns(
_output: unknown
): _output is { indexPatterns: Array<{ id?: string }> } {
return (
typeof _output === 'object' &&
!!_output &&
Array.isArray((_output as { indexPatterns: unknown[] }).indexPatterns) &&
(_output as { indexPatterns: Array<{ id?: string }> }).indexPatterns.length > 0
);
}
return hasIndexPatterns(output)
? (output.indexPatterns.map((ip) => ip.id).filter(Boolean) as string[])
: [];
}
export function getEmbeddableVariables(embeddable: EmbeddableWithQueryInput): PanelValues {
const input = embeddable.getInput();
const output = embeddable.getOutput();
const indexPatternsIds = getIndexPatternIds(output);
return deleteUndefinedKeys({
id: input.id,
title: output.title ?? input.title,
savedObjectId:
output.savedObjectId ?? (hasSavedObjectId(input) ? input.savedObjectId : undefined),
query: input.query,
timeRange: input.timeRange,
filters: input.filters,
indexPatternIds: indexPatternsIds.length > 1 ? indexPatternsIds : undefined,
indexPatternId: indexPatternsIds.length === 1 ? indexPatternsIds[0] : undefined,
});
}
const getContextPanelScopeValues = (contextScopeInput: unknown): PanelValues => {
function hasEmbeddable(val: unknown): val is { embeddable: EmbeddableWithQueryInput } {
if (val && typeof val === 'object' && 'embeddable' in val) return true;
return false;
}
if (!hasEmbeddable(contextScopeInput))
export const getContextScopeValues = (context: Partial<EmbeddableApiContext>): ContextValues => {
if (!context.embeddable)
throw new Error(
"UrlDrilldown [getContextScope] can't build scope because embeddable object is missing in context"
);
const embeddable = contextScopeInput.embeddable;
const api = context.embeddable as Partial<
HasUniqueId &
PublishesPanelTitle &
PublishesSavedObjectId &
PublishesLocalUnifiedSearch &
PublishesDataViews &
HasParentApi<Partial<PublishesLocalUnifiedSearch>>
>;
const dataViewIds = api.dataViews?.value
? (api.dataViews?.value.map((dataView) => dataView.id).filter(Boolean) as string[])
: [];
return getEmbeddableVariables(embeddable);
};
export const getContextScopeValues = (contextScopeInput: unknown): ContextValues => {
return {
panel: getContextPanelScopeValues(contextScopeInput),
panel: deleteUndefinedKeys({
id: api.uuid,
title: api.panelTitle?.value ?? api.defaultPanelTitle?.value,
savedObjectId: api.savedObjectId?.value,
query: api.parentApi?.localQuery?.value,
timeRange: api.localTimeRange?.value ?? api.parentApi?.localTimeRange?.value,
filters: api.parentApi?.localFilters?.value,
indexPatternIds: dataViewIds.length > 1 ? dataViewIds : undefined,
indexPatternId: dataViewIds.length === 1 ? dataViewIds[0] : undefined,
}),
};
};

View file

@ -7,7 +7,9 @@
import { i18n } from '@kbn/i18n';
import { monaco } from '@kbn/monaco';
import type { PublishesPanelTitle } from '@kbn/presentation-publishing';
import {
ChartActionContext,
isRangeSelectTriggerContext,
isValueClickTriggerContext,
isRowClickTriggerContext,
@ -19,11 +21,7 @@ import {
} from '@kbn/embeddable-plugin/public';
import { RowClickContext, ROW_CLICK_TRIGGER } from '@kbn/ui-actions-plugin/public';
import type { UrlTemplateEditorVariable } from '@kbn/kibana-react-plugin/public';
import type {
ActionContext,
ActionFactoryContext,
EmbeddableWithQueryInput,
} from '../url_drilldown';
import type { ActionFactoryContext } from '../url_drilldown';
import { deleteUndefinedKeys, toPrimitiveOrUndefined, Primitive } from './util';
/**
@ -35,8 +33,6 @@ export type UrlDrilldownEventScope =
| RowClickTriggerEventScope
| ContextMenuTriggerEventScope;
export type EventScopeInput = ActionContext;
export interface ValueClickTriggerEventScope {
key?: string;
value: Primitive;
@ -95,7 +91,7 @@ const getEventScopeFromRowClickTriggerContext = (
ctx: RowClickContext
): RowClickTriggerEventScope => {
const { data } = ctx;
const embeddable = ctx.embeddable as EmbeddableWithQueryInput;
const api = ctx.embeddable as Partial<PublishesPanelTitle>;
const { rowIndex } = data;
const columns = data.columns || data.table.columns.map(({ id }) => id);
@ -109,7 +105,7 @@ const getEventScopeFromRowClickTriggerContext = (
if (!column) {
// This should never happe, but in case it does we log data necessary for debugging.
// eslint-disable-next-line no-console
console.error(data, embeddable ? `Embeddable [${embeddable.getTitle()}]` : null);
console.error(data, api?.panelTitle ? `Embeddable [${api.panelTitle.value}]` : null);
throw new Error('Could not find a datatable column.');
}
values.push(row[columnId]);
@ -127,14 +123,14 @@ const getEventScopeFromRowClickTriggerContext = (
return scope;
};
export const getEventScopeValues = (eventScopeInput: EventScopeInput): UrlDrilldownEventScope => {
if (isRangeSelectTriggerContext(eventScopeInput)) {
return getEventScopeFromRangeSelectTriggerContext(eventScopeInput);
} else if (isValueClickTriggerContext(eventScopeInput)) {
return getEventScopeFromValueClickTriggerContext(eventScopeInput);
} else if (isRowClickTriggerContext(eventScopeInput)) {
return getEventScopeFromRowClickTriggerContext(eventScopeInput);
} else if (isContextMenuTriggerContext(eventScopeInput)) {
export const getEventScopeValues = (context: ChartActionContext): UrlDrilldownEventScope => {
if (isRangeSelectTriggerContext(context)) {
return getEventScopeFromRangeSelectTriggerContext(context);
} else if (isValueClickTriggerContext(context)) {
return getEventScopeFromValueClickTriggerContext(context);
} else if (isRowClickTriggerContext(context)) {
return getEventScopeFromRowClickTriggerContext(context);
} else if (isContextMenuTriggerContext(context)) {
return {};
} else {
throw new Error("UrlDrilldown [getEventScope] can't build scope from not supported trigger");

View file

@ -19,7 +19,8 @@
"@kbn/image-embeddable-plugin",
"@kbn/core-ui-settings-browser-mocks",
"@kbn/core-ui-settings-browser",
"@kbn/core-theme-browser-mocks"
"@kbn/core-theme-browser-mocks",
"@kbn/presentation-publishing"
],
"exclude": [
"target/**/*",

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import type { CellValueContext } from '@kbn/embeddable-plugin/public';
import type { CellValueContext, IEmbeddable } from '@kbn/embeddable-plugin/public';
import { isErrorEmbeddable, isFilterableEmbeddable } from '@kbn/embeddable-plugin/public';
import { createAction } from '@kbn/ui-actions-plugin/public';
import { KibanaServices } from '../../../common/lib/kibana';
@ -82,9 +82,9 @@ export const createAddToTimelineLensAction = ({
getIconType: () => ADD_TO_TIMELINE_ICON,
getDisplayName: () => ADD_TO_TIMELINE,
isCompatible: async ({ embeddable, data }) =>
!isErrorEmbeddable(embeddable) &&
isLensEmbeddable(embeddable) &&
isFilterableEmbeddable(embeddable) &&
!isErrorEmbeddable(embeddable as IEmbeddable) &&
isLensEmbeddable(embeddable as IEmbeddable) &&
isFilterableEmbeddable(embeddable as IEmbeddable) &&
isDataColumnsFilterable(data) &&
isInSecurityApp(currentAppId),
execute: async ({ data }) => {

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import type { CellValueContext } from '@kbn/embeddable-plugin/public';
import type { CellValueContext, IEmbeddable } from '@kbn/embeddable-plugin/public';
import { isErrorEmbeddable } from '@kbn/embeddable-plugin/public';
import { createAction } from '@kbn/ui-actions-plugin/public';
import copy from 'copy-to-clipboard';
@ -37,8 +37,8 @@ export const createCopyToClipboardLensAction = ({ order }: { order?: number }) =
getIconType: () => COPY_TO_CLIPBOARD_ICON,
getDisplayName: () => COPY_TO_CLIPBOARD,
isCompatible: async ({ embeddable, data }) =>
!isErrorEmbeddable(embeddable) &&
isLensEmbeddable(embeddable) &&
!isErrorEmbeddable(embeddable as IEmbeddable) &&
isLensEmbeddable(embeddable as IEmbeddable) &&
isDataColumnsValid(data) &&
isInSecurityApp(currentAppId),
execute: async ({ data }) => {

View file

@ -12,7 +12,7 @@ import {
filterOutNullableValues,
} from '@kbn/cell-actions/src/actions/utils';
import { isErrorEmbeddable } from '@kbn/embeddable-plugin/public';
import type { CellValueContext } from '@kbn/embeddable-plugin/public';
import type { CellValueContext, IEmbeddable } from '@kbn/embeddable-plugin/public';
import { createAction } from '@kbn/ui-actions-plugin/public';
import { ACTION_INCOMPATIBLE_VALUE_WARNING } from '@kbn/cell-actions/src/actions/translations';
import { i18n } from '@kbn/i18n';
@ -66,8 +66,8 @@ export const createFilterLensAction = ({
}),
type: DefaultCellActionTypes.FILTER,
isCompatible: async ({ embeddable, data }) =>
!isErrorEmbeddable(embeddable) &&
isLensEmbeddable(embeddable) &&
!isErrorEmbeddable(embeddable as IEmbeddable) &&
isLensEmbeddable(embeddable as IEmbeddable) &&
isDataColumnsValid(data) &&
isInSecurityApp(currentAppId),
execute: async ({ data }) => {