Url service locators (#101045)

* feat: 🎸 add url service types

* refactor: 💡 move locator types into its own folder

* feat: 🎸 add abstract locator implementation

* feat: 🎸 implement abstract locator client

* feat: 🎸 add browser-side locators service

* feat: 🎸 implement locator .getLocation()

* feat: 🎸 implement navigate function

* feat: 🎸 implement locator service in /common folder

* feat: 🎸 expose locators client on browser and server

* refactor: 💡 make locators async

* chore: 🤖 add deprecation notice to URL generators

* docs: ✏️ add deprecation notice to readme

* test: 💍 make test locator async

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Vadim Dalecky 2021-06-07 13:15:13 +02:00 committed by GitHub
parent 6338a598f1
commit 2a71047d9b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 553 additions and 20 deletions

View file

@ -6,16 +6,10 @@
* Side Public License, v 1.
*/
import {
TimeRange,
Filter,
Query,
esFilters,
QueryState,
RefreshInterval,
} from '../../data/public';
import type { UrlGeneratorsDefinition } from '../../share/public';
import type { TimeRange, Filter, Query, QueryState, RefreshInterval } from '../../data/public';
import { esFilters } from '../../data/public';
import { setStateToKbnUrl } from '../../kibana_utils/public';
import { UrlGeneratorsDefinition } from '../../share/public';
export const DISCOVER_APP_URL_GENERATOR = 'DISCOVER_APP_URL_GENERATOR';
@ -71,10 +65,12 @@ export interface DiscoverUrlGeneratorState {
* Used interval of the histogram
*/
interval?: string;
/**
* Array of the used sorting [[field,direction],...]
*/
sort?: string[][];
/**
* id of the used saved query
*/

View file

@ -0,0 +1,165 @@
/*
* 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 { of } from 'src/plugins/kibana_utils/common';
import { testLocator, TestLocatorState, urlServiceTestSetup } from './setup';
describe('locators', () => {
test('can start locators service', () => {
const {
service: { locators },
} = urlServiceTestSetup();
expect(typeof locators).toBe('object');
expect(typeof locators.create).toBe('function');
expect(typeof locators.get).toBe('function');
});
test('returns "undefined" for unregistered locator', () => {
const {
service: { locators },
} = urlServiceTestSetup();
expect(locators.get(testLocator.id)).toBe(undefined);
});
test('can register a locator', () => {
const {
service: { locators },
} = urlServiceTestSetup();
locators.create(testLocator);
expect(typeof locators.get(testLocator.id)).toBe('object');
});
test('getLocation() returns KibanaLocation generated by the locator', async () => {
const {
service: { locators },
} = urlServiceTestSetup();
locators.create(testLocator);
const locator = locators.get<TestLocatorState>(testLocator.id);
const location = await locator?.getLocation({
savedObjectId: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx',
pageNumber: 21,
showFlyout: true,
});
expect(location).toEqual({
app: 'test_app',
route: '/my-object/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx?page=21',
state: { isFlyoutOpen: true },
});
});
describe('.navigate()', () => {
test('throws if navigation method is not implemented', async () => {
const {
service: { locators },
} = urlServiceTestSetup();
const locator = locators.create(testLocator);
const [, error] = await of(
locator.navigate({
pageNumber: 1,
savedObjectId: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx',
showFlyout: false,
})
);
expect(error).toBeInstanceOf(Error);
expect(error.message).toBe('not implemented');
});
test('navigates user when .navigate() method is called', async () => {
const {
service: { locators },
deps,
} = urlServiceTestSetup({
navigate: jest.fn(async () => {}),
});
const locator = locators.create(testLocator);
const [, error] = await of(
locator.navigate({
pageNumber: 1,
savedObjectId: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx',
showFlyout: false,
})
);
expect(error).toBe(undefined);
expect(deps.navigate).toHaveBeenCalledTimes(1);
expect(deps.navigate).toHaveBeenCalledWith(
{
app: 'test_app',
route: '/my-object/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx?page=1',
state: {
isFlyoutOpen: false,
},
},
{ replace: false }
);
});
test('can specify "replace" navigation parameter', async () => {
const {
service: { locators },
deps,
} = urlServiceTestSetup({
navigate: jest.fn(async () => {}),
});
const locator = locators.create(testLocator);
await locator.navigate(
{
pageNumber: 1,
savedObjectId: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx',
showFlyout: false,
},
{
replace: false,
}
);
expect(deps.navigate).toHaveBeenCalledTimes(1);
expect(deps.navigate).toHaveBeenCalledWith(
{
app: 'test_app',
route: '/my-object/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx?page=1',
state: {
isFlyoutOpen: false,
},
},
{ replace: false }
);
await locator.navigate(
{
pageNumber: 2,
savedObjectId: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx',
showFlyout: false,
},
{
replace: true,
}
);
expect(deps.navigate).toHaveBeenCalledTimes(2);
expect(deps.navigate).toHaveBeenCalledWith(
{
app: 'test_app',
route: '/my-object/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx?page=2',
state: {
isFlyoutOpen: false,
},
},
{ replace: true }
);
});
});
});

View file

@ -0,0 +1,42 @@
/*
* 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 { SerializableState } from 'src/plugins/kibana_utils/common';
import { LocatorDefinition } from '../locators';
import { UrlService, UrlServiceDependencies } from '../url_service';
export interface TestLocatorState extends SerializableState {
savedObjectId: string;
showFlyout: boolean;
pageNumber: number;
}
export const testLocator: LocatorDefinition<TestLocatorState> = {
id: 'TEST_LOCATOR',
getLocation: async ({ savedObjectId, pageNumber, showFlyout }) => {
return {
app: 'test_app',
route: `/my-object/${savedObjectId}?page=${pageNumber}`,
state: {
isFlyoutOpen: showFlyout,
},
};
},
};
export const urlServiceTestSetup = (partialDeps: Partial<UrlServiceDependencies> = {}) => {
const deps: UrlServiceDependencies = {
navigate: async () => {
throw new Error('not implemented');
},
...partialDeps,
};
const service = new UrlService(deps);
return { service, deps };
};

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 './url_service';
export * from './locators';

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 './types';
export * from './locator';
export * from './locator_client';

View file

@ -0,0 +1,69 @@
/*
* 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 { SavedObjectReference } from 'kibana/server';
import type { PersistableState, SerializableState } from 'src/plugins/kibana_utils/common';
import type {
LocatorDefinition,
LocatorPublic,
KibanaLocation,
LocatorNavigationParams,
} from './types';
export interface LocatorDependencies {
navigate: (location: KibanaLocation, params?: LocatorNavigationParams) => Promise<void>;
}
export class Locator<P extends SerializableState> implements PersistableState<P>, LocatorPublic<P> {
public readonly migrations: PersistableState<P>['migrations'];
constructor(
public readonly definition: LocatorDefinition<P>,
protected readonly deps: LocatorDependencies
) {
this.migrations = definition.migrations || {};
}
// PersistableState<P> -------------------------------------------------------
public readonly telemetry: PersistableState<P>['telemetry'] = (
state: P,
stats: Record<string, any>
): Record<string, any> => {
return this.definition.telemetry ? this.definition.telemetry(state, stats) : stats;
};
public readonly inject: PersistableState<P>['inject'] = (
state: P,
references: SavedObjectReference[]
): P => {
return this.definition.inject ? this.definition.inject(state, references) : state;
};
public readonly extract: PersistableState<P>['extract'] = (
state: P
): { state: P; references: SavedObjectReference[] } => {
return this.definition.extract ? this.definition.extract(state) : { state, references: [] };
};
// LocatorPublic<P> ----------------------------------------------------------
public async getLocation(params: P): Promise<KibanaLocation> {
return await this.definition.getLocation(params);
}
public async navigate(
params: P,
{ replace = false }: LocatorNavigationParams = {}
): Promise<void> {
const location = await this.getLocation(params);
await this.deps.navigate(location, {
replace,
});
}
}

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 type { SerializableState } from 'src/plugins/kibana_utils/common';
import type { LocatorDependencies } from './locator';
import type { LocatorDefinition, LocatorPublic, ILocatorClient } from './types';
import { Locator } from './locator';
export type LocatorClientDependencies = LocatorDependencies;
export class LocatorClient implements ILocatorClient {
/**
* Collection of registered locators.
*/
protected locators: Map<string, Locator<any>> = new Map();
constructor(protected readonly deps: LocatorClientDependencies) {}
/**
* Creates and register a URL locator.
*
* @param definition A definition of URL locator.
* @returns A public interface of URL locator.
*/
public create<P extends SerializableState>(definition: LocatorDefinition<P>): LocatorPublic<P> {
const locator = new Locator<P>(definition, this.deps);
this.locators.set(definition.id, locator);
return locator;
}
/**
* Returns a previously registered URL locator.
*
* @param id ID of a URL locator.
* @returns A public interface of a registered URL locator.
*/
public get<P>(id: string): undefined | LocatorPublic<P> {
return this.locators.get(id);
}
}

View file

@ -0,0 +1,91 @@
/*
* 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 { PersistableState, SerializableState } from 'src/plugins/kibana_utils/common';
/**
* URL locator registry.
*/
export interface ILocatorClient {
/**
* Create and register a new locator.
*
* @param urlGenerator Definition of the new locator.
*/
create<P extends SerializableState>(locatorDefinition: LocatorDefinition<P>): LocatorPublic<P>;
/**
* Retrieve a previously registered locator.
*
* @param id Unique ID of the locator.
*/
get<P>(id: string): undefined | LocatorPublic<P>;
}
/**
* A convenience interface used to define and register a locator.
*/
export interface LocatorDefinition<P extends SerializableState>
extends Partial<PersistableState<P>> {
/**
* Unique ID of the locator. Should be constant and unique across Kibana.
*/
id: string;
/**
* Returns a deep link, including location state, which can be used for
* navigation in Kibana.
*
* @param params Parameters from which to generate a Kibana location.
*/
getLocation(params: P): Promise<KibanaLocation>;
}
/**
* Public interface of a registered locator.
*/
export interface LocatorPublic<P> {
/**
* Returns a relative URL to the client-side redirect endpoint using this
* locator. (This method is necessary for compatibility with URL generators.)
*/
getLocation(params: P): Promise<KibanaLocation>;
/**
* Navigate using the `core.application.navigateToApp()` method to a Kibana
* location generated by this locator. This method is available only on the
* browser.
*/
navigate(params: P, navigationParams?: LocatorNavigationParams): Promise<void>;
}
export interface LocatorNavigationParams {
replace?: boolean;
}
/**
* This interface represents a location in Kibana to which one can navigate
* using the `core.application.navigateToApp()` method.
*/
export interface KibanaLocation<S = object> {
/**
* Kibana application ID.
*/
app: string;
/**
* A URL route within a Kibana application.
*/
route: string;
/**
* A serializable location state object, which the app can use to determine
* what should be displayed on the screen.
*/
state: S;
}

View file

@ -0,0 +1,23 @@
/*
* 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 { LocatorClient, LocatorClientDependencies } from './locators';
export type UrlServiceDependencies = LocatorClientDependencies;
/**
* Common URL Service client interface for server-side and client-side.
*/
export class UrlService {
/**
* Client to work with locators.
*/
locators: LocatorClient = new LocatorClient(this.deps);
constructor(protected readonly deps: UrlServiceDependencies) {}
}

View file

@ -18,6 +18,7 @@ import {
UrlGeneratorsSetup,
UrlGeneratorsStart,
} from './url_generators/url_generator_service';
import { UrlService } from '../common/url_service';
export interface ShareSetupDependencies {
securityOss?: SecurityOssPluginSetup;
@ -27,16 +28,60 @@ export interface ShareStartDependencies {
securityOss?: SecurityOssPluginStart;
}
/** @public */
export type SharePluginSetup = ShareMenuRegistrySetup & {
/**
* @deprecated
*
* URL Generators are deprecated use UrlService instead.
*/
urlGenerators: UrlGeneratorsSetup;
/**
* Utilities to work with URL locators and short URLs.
*/
url: UrlService;
};
/** @public */
export type SharePluginStart = ShareMenuManagerStart & {
/**
* @deprecated
*
* URL Generators are deprecated use UrlService instead.
*/
urlGenerators: UrlGeneratorsStart;
/**
* Utilities to work with URL locators and short URLs.
*/
url: UrlService;
};
export class SharePlugin implements Plugin<SharePluginSetup, SharePluginStart> {
private readonly shareMenuRegistry = new ShareMenuRegistry();
private readonly shareContextMenu = new ShareMenuManager();
private readonly urlGeneratorsService = new UrlGeneratorsService();
private url?: UrlService;
public setup(core: CoreSetup, plugins: ShareSetupDependencies): SharePluginSetup {
core.application.register(createShortUrlRedirectApp(core, window.location));
this.url = new UrlService({
navigate: async (location, { replace = false } = {}) => {
const [start] = await core.getStartServices();
await start.application.navigateToApp(location.app, {
path: location.route,
state: location.state,
replace,
});
},
});
return {
...this.shareMenuRegistry.setup(),
urlGenerators: this.urlGeneratorsService.setup(core),
url: this.url,
};
}
@ -48,16 +93,7 @@ export class SharePlugin implements Plugin<SharePluginSetup, SharePluginStart> {
plugins.securityOss?.anonymousAccess
),
urlGenerators: this.urlGeneratorsService.start(core),
url: this.url!,
};
}
}
/** @public */
export type SharePluginSetup = ShareMenuRegistrySetup & {
urlGenerators: UrlGeneratorsSetup;
};
/** @public */
export type SharePluginStart = ShareMenuManagerStart & {
urlGenerators: UrlGeneratorsStart;
};

View file

@ -1,3 +1,9 @@
# URL Generators are deprecated
__Below is documentation of URL Generators, which are now deprecated and will be removed in favor of URL locators in 7.14.__
---
## URL Generator Services
Developers who maintain pages in Kibana that other developers may want to link to

View file

@ -13,10 +13,20 @@ import { UrlGeneratorInternal } from './url_generator_internal';
import { UrlGeneratorContract } from './url_generator_contract';
export interface UrlGeneratorsStart {
/**
* @deprecated
*
* URL Generators are deprecated, use URL locators in UrlService instead.
*/
getUrlGenerator: <T extends UrlGeneratorId>(urlGeneratorId: T) => UrlGeneratorContract<T>;
}
export interface UrlGeneratorsSetup {
/**
* @deprecated
*
* URL Generators are deprecated, use URL locators in UrlService instead.
*/
registerUrlGenerator: <Id extends UrlGeneratorId>(
generator: UrlGeneratorsDefinition<Id>
) => UrlGeneratorContract<Id>;

View file

@ -12,11 +12,30 @@ import { CoreSetup, Plugin, PluginInitializerContext } from 'kibana/server';
import { createRoutes } from './routes/create_routes';
import { url } from './saved_objects';
import { CSV_SEPARATOR_SETTING, CSV_QUOTE_VALUES_SETTING } from '../common/constants';
import { UrlService } from '../common/url_service';
/** @public */
export interface SharePluginSetup {
url: UrlService;
}
/** @public */
export interface SharePluginStart {
url: UrlService;
}
export class SharePlugin implements Plugin<SharePluginSetup, SharePluginStart> {
private url?: UrlService;
export class SharePlugin implements Plugin {
constructor(private readonly initializerContext: PluginInitializerContext) {}
public setup(core: CoreSetup) {
this.url = new UrlService({
navigate: async () => {
throw new Error('Locator .navigate() does not work on server.');
},
});
createRoutes(core, this.initializerContext.logger.get());
core.savedObjects.registerType(url);
core.uiSettings.register({
@ -41,10 +60,18 @@ export class SharePlugin implements Plugin {
schema: schema.boolean(),
},
});
return {
url: this.url,
};
}
public start() {
this.initializerContext.logger.get().debug('Starting plugin');
return {
url: this.url!,
};
}
public stop() {