[Reporting/Visualization] Migrate Visualize to V2 reporting (#110206)

* added initial version of locator

* removed unused params and added jest test

* updated functional test to expect PDF reports to be available when vis is new

* fix TS: remove unkown field

* added some docs and removed unused code

* AggsConfigOption -> AggsConfigSerialized

* moved locator to common

* fixed building of "create" path and updated test snapshots

* updated import

* update encoding behaviour

* added time range from timefilter to locator params request

* add index pattern and search id to URL params

* reading index pattern from search source if it is there for the locator

* remove "type" from locator params, update comments and test

* removed duplicate identifier

* remove unused type

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Jean-Louis Leysens 2021-09-03 17:08:56 +02:00 committed by GitHub
parent d4c03eb9b4
commit 1f06cafa19
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 355 additions and 43 deletions

View file

@ -78,7 +78,7 @@ export const getStats = async (
const doTelemetry = ({ params }: SavedVisState) => {
try {
const spec = parse(params.spec, { legacyRoot: false });
const spec = parse(params.spec as string, { legacyRoot: false });
if (spec) {
shouldPublishTelemetry = true;

View file

@ -7,18 +7,20 @@
*/
import { SavedObjectAttributes } from 'kibana/server';
import { AggConfigOptions } from 'src/plugins/data/common';
import type { SerializableRecord } from '@kbn/utility-types';
import { AggConfigSerialized } from 'src/plugins/data/common';
export interface VisParams {
[key: string]: any;
}
export interface SavedVisState<TVisParams = VisParams> {
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
export type SavedVisState<TVisParams = SerializableRecord> = {
title: string;
type: string;
params: TVisParams;
aggs: AggConfigOptions[];
}
aggs: AggConfigSerialized[];
};
export interface VisualizationSavedObjectAttributes extends SavedObjectAttributes {
description: string;

View file

@ -115,7 +115,7 @@ describe('injectReferences', () => {
});
test('injects references into context', () => {
const context = {
const context = ({
id: '1',
title: 'test',
savedSearchRefName: 'search_0',
@ -133,7 +133,7 @@ describe('injectReferences', () => {
],
},
} as unknown) as SavedVisState,
} as VisSavedObject;
} as unknown) as VisSavedObject;
const references = [
{
name: 'search_0',
@ -182,7 +182,7 @@ describe('injectReferences', () => {
});
test(`fails when it can't find the index pattern reference in the array`, () => {
const context = {
const context = ({
id: '1',
title: 'test',
visState: ({
@ -196,7 +196,7 @@ describe('injectReferences', () => {
],
},
} as unknown) as SavedVisState,
} as VisSavedObject;
} as unknown) as VisSavedObject;
expect(() => injectReferences(context, [])).toThrowErrorMatchingInlineSnapshot(
`"Could not find index pattern reference \\"control_0_index_pattern\\""`
);

View file

@ -8,10 +8,10 @@
import { SavedObject } from '../../../plugins/saved_objects/public';
import {
AggConfigOptions,
IAggConfigs,
SearchSourceFields,
TimefilterContract,
AggConfigSerialized,
} from '../../../plugins/data/public';
import { ExpressionAstExpression } from '../../expressions/public';
@ -24,7 +24,7 @@ export interface SavedVisState {
title: string;
type: string;
params: VisParams;
aggs: AggConfigOptions[];
aggs: AggConfigSerialized[];
}
export interface ISavedVis {

View file

@ -26,7 +26,7 @@ import {
IAggConfigs,
IndexPattern,
ISearchSource,
AggConfigOptions,
AggConfigSerialized,
SearchSourceFields,
} from '../../../plugins/data/public';
import { BaseVisType } from './vis_types';
@ -34,7 +34,7 @@ import { VisParams } from '../common/types';
export interface SerializedVisData {
expression?: string;
aggs: AggConfigOptions[];
aggs: AggConfigSerialized[];
searchSource: SearchSourceFields;
savedSearchId?: string;
}
@ -194,7 +194,7 @@ export class Vis<TVisParams = VisParams> {
}
}
private initializeDefaultsFromSchemas(configStates: AggConfigOptions[], schemas: any) {
private initializeDefaultsFromSchemas(configStates: AggConfigSerialized[], schemas: any) {
// Set the defaults for any schema which has them. If the defaults
// for some reason has more then the max only set the max number
// of defaults (not sure why a someone define more...

View file

@ -8,3 +8,16 @@
export const STATE_STORAGE_KEY = '_a';
export const GLOBAL_STATE_STORAGE_KEY = '_g';
export const APP_NAME = 'visualize';
export const VisualizeConstants = {
VISUALIZE_BASE_PATH: '/app/visualize',
LANDING_PAGE_PATH: '/',
WIZARD_STEP_1_PAGE_PATH: '/new',
WIZARD_STEP_2_PAGE_PATH: '/new/configure',
CREATE_PATH: '/create',
EDIT_PATH: '/edit',
EDIT_BY_VALUE_PATH: '/edit_by_value',
APP_ID: 'visualize',
};

View file

@ -0,0 +1,133 @@
/*
* 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 { VisualizeLocatorDefinition } from './locator';
import { FilterStateStore } from '../../data/common';
describe('visualize locator', () => {
let definition: VisualizeLocatorDefinition;
beforeEach(() => {
definition = new VisualizeLocatorDefinition();
});
it('returns a location for "create" path', async () => {
const location = await definition.getLocation({});
expect(location.app).toMatchInlineSnapshot(`"visualize"`);
expect(location.path).toMatchInlineSnapshot(`"#/create?_g=()&_a=()"`);
expect(location.state).toMatchInlineSnapshot(`Object {}`);
});
it('returns a location for "edit" path', async () => {
const location = await definition.getLocation({
visId: 'test',
vis: {
title: 'test',
type: 'test',
aggs: [],
params: {},
},
});
expect(location.app).toMatchInlineSnapshot(`"visualize"`);
expect(location.path).toMatchInlineSnapshot(
`"#/edit/test?_g=()&_a=(vis:(aggs:!(),params:(),title:test,type:test))&type=test"`
);
expect(location.state).toMatchInlineSnapshot(`Object {}`);
});
it('creates a location with query, filters (global and app), refresh interval and time range', async () => {
const location = await definition.getLocation({
visId: '123',
vis: {
title: 'test',
type: 'test',
aggs: [],
params: {},
},
timeRange: { to: 'now', from: 'now-15m', mode: 'relative' },
refreshInterval: { pause: false, value: 300 },
filters: [
{
meta: {
alias: null,
disabled: false,
negate: false,
},
query: { query: 'hi' },
},
{
meta: {
alias: null,
disabled: false,
negate: false,
},
query: { query: 'hi' },
$state: {
store: FilterStateStore.GLOBAL_STATE,
},
},
],
query: { query: 'bye', language: 'kuery' },
});
expect(location.app).toMatchInlineSnapshot(`"visualize"`);
expect(location.path.match(/filters:/g)?.length).toBe(2);
expect(location.path.match(/refreshInterval:/g)?.length).toBe(1);
expect(location.path.match(/time:/g)?.length).toBe(1);
expect(location.path).toMatchInlineSnapshot(
`"#/edit/123?_g=(filters:!(('$state':(store:globalState),meta:(alias:!n,disabled:!f,negate:!f),query:(query:hi))),refreshInterval:(pause:!f,value:300),time:(from:now-15m,mode:relative,to:now))&_a=(filters:!((meta:(alias:!n,disabled:!f,negate:!f),query:(query:hi))),query:(language:kuery,query:bye),vis:(aggs:!(),params:(),title:test,type:test))&type=test"`
);
expect(location.state).toMatchInlineSnapshot(`Object {}`);
});
it('creates a location with all values provided', async () => {
const indexPattern = 'indexPatternTest';
const savedSearchId = 'savedSearchIdTest';
const location = await definition.getLocation({
visId: '123',
vis: {
title: 'test',
type: 'test',
aggs: [],
params: {},
},
timeRange: { to: 'now', from: 'now-15m', mode: 'relative' },
refreshInterval: { pause: false, value: 300 },
filters: [
{
meta: {
alias: null,
disabled: false,
negate: false,
},
query: { query: 'hi' },
},
],
query: { query: 'bye', language: 'kuery' },
linked: true,
uiState: {
fakeUIState: 'fakeUIState',
this: 'value contains a spaces that should be encoded',
},
indexPattern,
savedSearchId,
});
expect(location.app).toMatchInlineSnapshot(`"visualize"`);
expect(location.path).toContain(indexPattern);
expect(location.path).toContain(savedSearchId);
expect(location.path).toMatchInlineSnapshot(
`"#/edit/123?_g=(filters:!(),refreshInterval:(pause:!f,value:300),time:(from:now-15m,mode:relative,to:now))&_a=(filters:!((meta:(alias:!n,disabled:!f,negate:!f),query:(query:hi))),linked:!t,query:(language:kuery,query:bye),uiState:(fakeUIState:fakeUIState,this:'value%20contains%20a%20spaces%20that%20should%20be%20encoded'),vis:(aggs:!(),params:(),title:test,type:test))&indexPattern=indexPatternTest&savedSearchId=savedSearchIdTest&type=test"`
);
expect(location.state).toMatchInlineSnapshot(`Object {}`);
});
});

View file

@ -0,0 +1,133 @@
/*
* 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 { SerializableRecord, Serializable } from '@kbn/utility-types';
import { omitBy } from 'lodash';
import type { ParsedQuery } from 'query-string';
import { stringify } from 'query-string';
import rison from 'rison-node';
import type { Filter, Query, RefreshInterval, TimeRange } from 'src/plugins/data/common';
import type { LocatorDefinition, LocatorPublic } from 'src/plugins/share/common';
import { isFilterPinned } from '../../data/common';
import { url } from '../../kibana_utils/common';
import { GLOBAL_STATE_STORAGE_KEY, STATE_STORAGE_KEY, VisualizeConstants } from './constants';
import { PureVisState } from './types';
const removeEmptyKeys = (o: Record<string, Serializable>): Record<string, Serializable> =>
omitBy(o, (v) => v == null);
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
export type VisualizeLocatorParams = {
/**
* The ID of the saved visualization to load.
*/
visId?: string;
/**
* Global- and app-level filters to apply to data loaded by visualize.
*/
filters?: Filter[];
/**
* Time range to apply to data loaded by visualize.
*/
timeRange?: TimeRange;
/**
* How frequently to poll for data.
*/
refreshInterval?: RefreshInterval;
/**
* The query to use in to load data in visualize.
*/
query?: Query;
/**
* UI state to be passed on to the current visualization. This value is opaque from the perspective of visualize.
*/
uiState?: SerializableRecord;
/**
* Serialized visualization.
*
* @note This is required to navigate to "create" page (i.e., when no `visId` has been provided).
*/
vis?: PureVisState;
/**
* Whether this visualization is linked a saved search.
*/
linked?: boolean;
/**
* The saved search used as the source of the visualization.
*/
savedSearchId?: string;
/**
* The saved search used as the source of the visualization.
*/
indexPattern?: string;
};
export type VisualizeAppLocator = LocatorPublic<VisualizeLocatorParams>;
export const VISUALIZE_APP_LOCATOR = 'VISUALIZE_APP_LOCATOR';
export class VisualizeLocatorDefinition implements LocatorDefinition<VisualizeLocatorParams> {
id = VISUALIZE_APP_LOCATOR;
public async getLocation({
visId,
timeRange,
filters,
refreshInterval,
linked,
uiState,
query,
vis,
savedSearchId,
indexPattern,
}: VisualizeLocatorParams) {
let path = visId
? `#${VisualizeConstants.EDIT_PATH}/${visId}`
: `#${VisualizeConstants.CREATE_PATH}`;
const urlState: ParsedQuery = {
[GLOBAL_STATE_STORAGE_KEY]: rison.encode(
removeEmptyKeys({
time: timeRange,
filters: filters?.filter((f) => isFilterPinned(f)),
refreshInterval,
})
),
[STATE_STORAGE_KEY]: rison.encode(
removeEmptyKeys({
linked,
filters: filters?.filter((f) => !isFilterPinned(f)),
uiState,
query,
vis,
})
),
};
path += `?${stringify(url.encodeQuery(urlState), { encode: false, sort: false })}`;
const otherParams = stringify({ type: vis?.type, savedSearchId, indexPattern });
if (otherParams) path += `&${otherParams}`;
return {
app: VisualizeConstants.APP_ID,
path,
state: {},
};
}
}

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.
*/
import type { SavedVisState } from 'src/plugins/visualizations/common/types';
export type PureVisState = SavedVisState;

View file

@ -9,6 +9,8 @@
import type { EventEmitter } from 'events';
import type { History } from 'history';
import type { SerializableRecord } from '@kbn/utility-types';
import type {
CoreStart,
PluginInitializerContext,
@ -19,7 +21,6 @@ import type {
} from 'kibana/public';
import type {
SavedVisState,
VisualizationsStart,
Vis,
VisualizeEmbeddableContract,
@ -45,11 +46,11 @@ import type { DashboardStart } from '../../../dashboard/public';
import type { SavedObjectsTaggingApi } from '../../../saved_objects_tagging_oss/public';
import type { UsageCollectionStart } from '../../../usage_collection/public';
export type PureVisState = SavedVisState;
import { PureVisState } from '../../common/types';
export interface VisualizeAppState {
filters: Filter[];
uiState: Record<string, unknown>;
uiState: SerializableRecord;
vis: PureVisState;
query: Query;
savedQuery?: string;
@ -103,6 +104,7 @@ export interface VisualizeServices extends CoreStart {
savedObjectsTagging?: SavedObjectsTaggingApi;
presentationUtil: PresentationUtilPluginStart;
usageCollection?: UsageCollectionStart;
getKibanaVersion: () => string;
}
export interface SavedVisInstance {
@ -146,3 +148,5 @@ export interface EditorRenderProps {
*/
linked: boolean;
}
export { PureVisState };

View file

@ -63,7 +63,6 @@ const pureTransitions = {
function createVisualizeByValueAppState(stateDefaults: VisualizeAppState) {
const initialState = migrateAppState({
...stateDefaults,
...stateDefaults,
});
const stateContainer = createStateContainer<VisualizeAppState, VisualizeAppStateTransitions>(
initialState,

View file

@ -7,8 +7,10 @@
*/
import React from 'react';
import moment from 'moment';
import { i18n } from '@kbn/i18n';
import { METRIC_TYPE } from '@kbn/analytics';
import { parse } from 'query-string';
import { Capabilities } from 'src/core/public';
import { TopNavMenuData } from 'src/plugins/navigation/public';
@ -33,6 +35,7 @@ import {
import { APP_NAME, VisualizeConstants } from '../visualize_constants';
import { getEditBreadcrumbs } from './breadcrumbs';
import { EmbeddableStateTransfer } from '../../../../embeddable/public';
import { VISUALIZE_APP_LOCATOR, VisualizeLocatorParams } from '../../../common/locator';
interface VisualizeCapabilities {
createShortUrl: boolean;
@ -95,6 +98,7 @@ export const getTopNavConfig = (
savedObjectsTagging,
presentationUtil,
usageCollection,
getKibanaVersion,
}: VisualizeServices
) => {
const { vis, embeddableHandler } = visInstance;
@ -279,6 +283,22 @@ export const getTopNavConfig = (
testId: 'shareTopNavButton',
run: (anchorElement) => {
if (share && !embeddableId) {
const currentState = stateContainer.getState();
const searchParams = parse(history.location.search);
const params: VisualizeLocatorParams = {
visId: savedVis?.id,
filters: currentState.filters,
refreshInterval: undefined,
timeRange: data.query.timefilter.timefilter.getTime(),
uiState: currentState.uiState,
query: currentState.query,
vis: currentState.vis,
linked: currentState.linked,
indexPattern:
visInstance.savedSearch?.searchSource?.getField('index')?.id ??
(searchParams.indexPattern as string),
savedSearchId: visInstance.savedSearch?.id ?? (searchParams.savedSearchId as string),
};
// TODO: support sharing in by-value mode
share.toggleShareContextMenu({
anchorElement,
@ -288,7 +308,17 @@ export const getTopNavConfig = (
objectId: savedVis?.id,
objectType: 'visualization',
sharingData: {
title: savedVis?.title,
title:
savedVis?.title ||
i18n.translate('visualize.reporting.defaultReportTitle', {
defaultMessage: 'Visualization [{date}]',
values: { date: moment().toISOString(true) },
}),
locatorParams: {
id: VISUALIZE_APP_LOCATOR,
version: getKibanaVersion(),
params,
},
},
isDirty: hasUnappliedChanges || hasUnsavedChanges,
showPublicUrlSwitch,

View file

@ -27,7 +27,6 @@ export const visualizeAppStateStub: VisualizeAppState = {
{
id: '1',
enabled: true,
// @ts-expect-error
type: 'avg',
schema: 'metric',
params: { field: 'total_quantity', customLabel: 'average items' },

View file

@ -157,7 +157,6 @@ describe('useVisualizeAppState', () => {
};
it('should successfully update vis state and set up app state container', async () => {
// @ts-expect-error
stateContainerGetStateMock.mockImplementation(() => state);
const { result, waitForNextUpdate } = renderHook(() =>
useVisualizeAppState(mockServices, eventEmitter, savedVisInstance)
@ -204,7 +203,6 @@ describe('useVisualizeAppState', () => {
it(`should add warning toast and redirect to the landing page
if setting new vis state was not successful, e.x. invalid query params`, async () => {
// @ts-expect-error
stateContainerGetStateMock.mockImplementation(() => state);
// @ts-expect-error
savedVisInstance.vis.setState.mockRejectedValue({

View file

@ -6,15 +6,4 @@
* Side Public License, v 1.
*/
export const APP_NAME = 'visualize';
export const VisualizeConstants = {
VISUALIZE_BASE_PATH: '/app/visualize',
LANDING_PAGE_PATH: '/',
WIZARD_STEP_1_PAGE_PATH: '/new',
WIZARD_STEP_2_PAGE_PATH: '/new/configure',
CREATE_PATH: '/create',
EDIT_PATH: '/edit',
EDIT_BY_VALUE_PATH: '/edit_by_value',
APP_ID: 'visualize',
};
export { VisualizeConstants, APP_NAME } from '../../common/constants';

View file

@ -47,6 +47,7 @@ import type { UsageCollectionStart } from '../../usage_collection/public';
import { setVisEditorsRegistry, setUISettings, setUsageCollector } from './services';
import { createVisEditorsRegistry, VisEditorsRegistry } from './vis_editors_registry';
import { VisualizeLocatorDefinition } from '../common/locator';
export interface VisualizePluginStartDependencies {
data: DataPublicPluginStart;
@ -92,7 +93,7 @@ export class VisualizePlugin
public setup(
core: CoreSetup<VisualizePluginStartDependencies>,
{ home, urlForwarding, data }: VisualizePluginSetupDependencies
{ home, urlForwarding, data, share }: VisualizePluginSetupDependencies
) {
const {
appMounted,
@ -209,6 +210,7 @@ export class VisualizePlugin
savedObjectsTagging: pluginsStart.savedObjectsTaggingOss?.getTaggingApi(),
presentationUtil: pluginsStart.presentationUtil,
usageCollection: pluginsStart.usageCollection,
getKibanaVersion: () => this.initializerContext.env.packageInfo.version,
};
params.element.classList.add('visAppWrapper');
@ -241,6 +243,10 @@ export class VisualizePlugin
});
}
if (share) {
share.url.locators.create(new VisualizeLocatorDefinition());
}
return {
visEditorsRegistry: this.visEditorsRegistry,
} as VisualizePluginSetup;

View file

@ -6,11 +6,7 @@
"declaration": true,
"declarationMap": true
},
"include": [
"common/**/*",
"public/**/*",
"server/**/*"
],
"include": ["common/**/*", "public/**/*", "server/**/*", "../../../typings/**/*"],
"references": [
{ "path": "../../core/tsconfig.json" },
{ "path": "../data/tsconfig.json" },
@ -28,6 +24,6 @@
{ "path": "../kibana_react/tsconfig.json" },
{ "path": "../home/tsconfig.json" },
{ "path": "../presentation_util/tsconfig.json" },
{ "path": "../discover/tsconfig.json" },
{ "path": "../discover/tsconfig.json" }
]
}

View file

@ -42,13 +42,13 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
});
describe('Print PDF button', () => {
it('is not available if new', async () => {
it('is available if new', async () => {
await PageObjects.common.navigateToUrl('visualize', 'new', { useActualUrl: true });
await PageObjects.visualize.clickAggBasedVisualizations();
await PageObjects.visualize.clickAreaChart();
await PageObjects.visualize.clickNewSearch('ecommerce');
await PageObjects.reporting.openPdfReportingPanel();
expect(await PageObjects.reporting.isGenerateReportButtonDisabled()).to.be('true');
expect(await PageObjects.reporting.isGenerateReportButtonDisabled()).to.be(null);
});
it('becomes available when saved', async () => {