mirror of
https://github.com/elastic/kibana.git
synced 2025-06-27 10:40:07 -04:00
[CM] Onboard maps
to cross-type search (#155148)
## Summary Part of https://github.com/elastic/kibana/issues/152224 Follow up to https://github.com/elastic/kibana/issues/153256 This PR onboards maps CM integration into the multi-type search (`msearch`). It isn't actually used anywhere in the user-facing UI yet, as first other types need to be migrated to CM. This PR also adds an example app to test the `msearch` end-to-end.
This commit is contained in:
parent
cfc01d5444
commit
6aa1491c9e
21 changed files with 316 additions and 23 deletions
|
@ -9,7 +9,9 @@
|
|||
"browser": true,
|
||||
"requiredPlugins": [
|
||||
"contentManagement",
|
||||
"developerExamples"
|
||||
"developerExamples",
|
||||
"kibanaReact",
|
||||
"savedObjectsTaggingOss"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
@ -8,22 +8,67 @@
|
|||
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { EuiPageTemplate } from '@elastic/eui';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { Router, Switch, Route, Redirect } from 'react-router-dom';
|
||||
import { RedirectAppLinks } from '@kbn/shared-ux-link-redirect-app';
|
||||
import { EuiPageTemplate, EuiSideNav } from '@elastic/eui';
|
||||
import { AppMountParameters, CoreStart } from '@kbn/core/public';
|
||||
import { StartDeps } from '../types';
|
||||
import { TodoApp } from './todos';
|
||||
import { MSearchApp } from './msearch';
|
||||
|
||||
export const renderApp = (
|
||||
{ notifications }: CoreStart,
|
||||
{ contentManagement }: StartDeps,
|
||||
{ element }: AppMountParameters
|
||||
core: CoreStart,
|
||||
{ contentManagement, savedObjectsTaggingOss }: StartDeps,
|
||||
{ element, history }: AppMountParameters
|
||||
) => {
|
||||
ReactDOM.render(
|
||||
<EuiPageTemplate offset={0}>
|
||||
<EuiPageTemplate.Section>
|
||||
<TodoApp contentClient={contentManagement.client} />
|
||||
</EuiPageTemplate.Section>
|
||||
</EuiPageTemplate>,
|
||||
<Router history={history}>
|
||||
<RedirectAppLinks coreStart={core}>
|
||||
<EuiPageTemplate offset={0}>
|
||||
<EuiPageTemplate.Sidebar>
|
||||
<EuiSideNav
|
||||
items={[
|
||||
{
|
||||
id: 'Examples',
|
||||
name: 'Examples',
|
||||
items: [
|
||||
{
|
||||
id: 'todos',
|
||||
name: 'Todo app',
|
||||
'data-test-subj': 'todosExample',
|
||||
href: '/app/contentManagementExamples/todos',
|
||||
},
|
||||
{
|
||||
id: 'msearch',
|
||||
name: 'MSearch',
|
||||
'data-test-subj': 'msearchExample',
|
||||
href: '/app/contentManagementExamples/msearch',
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</EuiPageTemplate.Sidebar>
|
||||
|
||||
<EuiPageTemplate.Section>
|
||||
<Switch>
|
||||
<Redirect from="/" to="/todos" exact />
|
||||
<Route path="/todos">
|
||||
<TodoApp contentClient={contentManagement.client} />
|
||||
</Route>
|
||||
<Route path="/msearch">
|
||||
<MSearchApp
|
||||
contentClient={contentManagement.client}
|
||||
core={core}
|
||||
savedObjectsTagging={savedObjectsTaggingOss}
|
||||
/>
|
||||
</Route>
|
||||
</Switch>
|
||||
</EuiPageTemplate.Section>
|
||||
</EuiPageTemplate>
|
||||
</RedirectAppLinks>
|
||||
</Router>,
|
||||
element
|
||||
);
|
||||
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
/*
|
||||
* 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 { MSearchApp } from './msearch_app';
|
|
@ -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 React from 'react';
|
||||
import { ContentClientProvider, type ContentClient } from '@kbn/content-management-plugin/public';
|
||||
import { TableListViewKibanaProvider } from '@kbn/content-management-table-list';
|
||||
import type { CoreStart } from '@kbn/core/public';
|
||||
import { toMountPoint } from '@kbn/kibana-react-plugin/public';
|
||||
import { FormattedRelative, I18nProvider } from '@kbn/i18n-react';
|
||||
import { SavedObjectTaggingOssPluginStart } from '@kbn/saved-objects-tagging-oss-plugin/public';
|
||||
import { MSearchTable } from './msearch_table';
|
||||
|
||||
export const MSearchApp = (props: {
|
||||
contentClient: ContentClient;
|
||||
core: CoreStart;
|
||||
savedObjectsTagging: SavedObjectTaggingOssPluginStart;
|
||||
}) => {
|
||||
return (
|
||||
<ContentClientProvider contentClient={props.contentClient}>
|
||||
<I18nProvider>
|
||||
<TableListViewKibanaProvider
|
||||
core={{
|
||||
application: props.core.application,
|
||||
notifications: props.core.notifications,
|
||||
overlays: props.core.overlays,
|
||||
http: props.core.http,
|
||||
}}
|
||||
toMountPoint={toMountPoint}
|
||||
FormattedRelative={FormattedRelative}
|
||||
savedObjectsTagging={props.savedObjectsTagging.getTaggingApi()}
|
||||
>
|
||||
<MSearchTable />
|
||||
</TableListViewKibanaProvider>
|
||||
</I18nProvider>
|
||||
</ContentClientProvider>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,62 @@
|
|||
/*
|
||||
* 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 { TableListView, UserContentCommonSchema } from '@kbn/content-management-table-list';
|
||||
import { useContentClient } from '@kbn/content-management-plugin/public';
|
||||
import React from 'react';
|
||||
import { SavedObjectsFindOptionsReference } from '@kbn/core-saved-objects-api-browser';
|
||||
|
||||
const LISTING_LIMIT = 1000;
|
||||
|
||||
export const MSearchTable = () => {
|
||||
const contentClient = useContentClient();
|
||||
|
||||
const findItems = async (
|
||||
searchQuery: string,
|
||||
refs?: {
|
||||
references?: SavedObjectsFindOptionsReference[];
|
||||
referencesToExclude?: SavedObjectsFindOptionsReference[];
|
||||
}
|
||||
) => {
|
||||
const { hits, pagination } = await contentClient.mSearch<UserContentCommonSchema>({
|
||||
query: {
|
||||
text: searchQuery,
|
||||
limit: LISTING_LIMIT,
|
||||
cursor: '1',
|
||||
tags: {
|
||||
included: refs?.references?.map((ref) => ref.id),
|
||||
excluded: refs?.referencesToExclude?.map((ref) => ref.id),
|
||||
},
|
||||
},
|
||||
contentTypes: [{ contentTypeId: 'map' }], // TODO: improve types to not require objects here?
|
||||
});
|
||||
|
||||
// TODO: needs to have logic of extracting common schema from an unknown mSearch hit: hits.map(hit => cm.convertToCommonSchema(hit))
|
||||
// for now we just assume that mSearch hit satisfies UserContentCommonSchema
|
||||
|
||||
return { hits, total: pagination.total };
|
||||
};
|
||||
|
||||
return (
|
||||
<TableListView
|
||||
id="cm-msearch-table"
|
||||
headingId="cm-msearch-table-heading"
|
||||
findItems={findItems}
|
||||
listingLimit={LISTING_LIMIT}
|
||||
initialPageSize={50}
|
||||
entityName={`ContentItem`}
|
||||
entityNamePlural={`ContentItems`}
|
||||
tableListTitle={`MSearch Demo`}
|
||||
urlStateEnabled={false}
|
||||
emptyPrompt={<>No data found. Try to install some sample data first.</>}
|
||||
onClickTitle={(item) => {
|
||||
alert(`Clicked item ${item.attributes.title} (${item.id})`);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -11,6 +11,7 @@ import {
|
|||
ContentManagementPublicStart,
|
||||
} from '@kbn/content-management-plugin/public';
|
||||
import { DeveloperExamplesSetup } from '@kbn/developer-examples-plugin/public';
|
||||
import { SavedObjectTaggingOssPluginStart } from '@kbn/saved-objects-tagging-oss-plugin/public';
|
||||
|
||||
export interface SetupDeps {
|
||||
contentManagement: ContentManagementPublicSetup;
|
||||
|
@ -19,4 +20,5 @@ export interface SetupDeps {
|
|||
|
||||
export interface StartDeps {
|
||||
contentManagement: ContentManagementPublicStart;
|
||||
savedObjectsTaggingOss: SavedObjectTaggingOssPluginStart;
|
||||
}
|
||||
|
|
|
@ -19,5 +19,11 @@
|
|||
"@kbn/developer-examples-plugin",
|
||||
"@kbn/content-management-plugin",
|
||||
"@kbn/core-application-browser",
|
||||
"@kbn/shared-ux-link-redirect-app",
|
||||
"@kbn/content-management-table-list",
|
||||
"@kbn/kibana-react-plugin",
|
||||
"@kbn/i18n-react",
|
||||
"@kbn/saved-objects-tagging-oss-plugin",
|
||||
"@kbn/core-saved-objects-api-browser",
|
||||
]
|
||||
}
|
||||
|
|
|
@ -80,9 +80,7 @@ export interface TableListViewKibanaDependencies {
|
|||
core: {
|
||||
application: {
|
||||
capabilities: {
|
||||
advancedSettings?: {
|
||||
save: boolean;
|
||||
};
|
||||
[key: string]: Readonly<Record<string, boolean | Record<string, boolean>>>;
|
||||
};
|
||||
getUrlForApp: (app: string, options: { path: string }) => string;
|
||||
currentAppId$: Observable<string | undefined>;
|
||||
|
|
|
@ -103,6 +103,20 @@ const searchSchemas = getOptionalInOutSchemas({
|
|||
),
|
||||
});
|
||||
|
||||
// Schema to validate the "msearch" service objects
|
||||
const mSearchSchemas = schema.maybe(
|
||||
schema.object({
|
||||
out: schema.maybe(
|
||||
schema.object(
|
||||
{
|
||||
result: schema.maybe(versionableObjectSchema),
|
||||
},
|
||||
{ unknowns: 'forbid' }
|
||||
)
|
||||
),
|
||||
})
|
||||
);
|
||||
|
||||
export const serviceDefinitionSchema = schema.object(
|
||||
{
|
||||
get: getSchemas,
|
||||
|
@ -111,6 +125,7 @@ export const serviceDefinitionSchema = schema.object(
|
|||
update: createSchemas,
|
||||
delete: getSchemas,
|
||||
search: searchSchemas,
|
||||
mSearch: mSearchSchemas,
|
||||
},
|
||||
{ unknowns: 'forbid' }
|
||||
);
|
||||
|
|
|
@ -240,6 +240,7 @@ describe('CM services getTransforms()', () => {
|
|||
'delete.out.result',
|
||||
'search.in.options',
|
||||
'search.out.result',
|
||||
'mSearch.out.result',
|
||||
].sort()
|
||||
);
|
||||
});
|
||||
|
|
|
@ -34,6 +34,7 @@ const serviceObjectPaths = [
|
|||
'delete.out.result',
|
||||
'search.in.options',
|
||||
'search.out.result',
|
||||
'mSearch.out.result',
|
||||
];
|
||||
|
||||
const validateServiceDefinitions = (definitions: ServiceDefinitionVersioned) => {
|
||||
|
@ -175,6 +176,11 @@ const getDefaultServiceTransforms = (): ServiceTransforms => ({
|
|||
result: getDefaultTransforms(),
|
||||
},
|
||||
},
|
||||
mSearch: {
|
||||
out: {
|
||||
result: getDefaultTransforms(),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const getTransforms = (
|
||||
|
|
|
@ -59,6 +59,11 @@ export interface ServicesDefinition {
|
|||
result?: VersionableObject<any, any, any, any>;
|
||||
};
|
||||
};
|
||||
mSearch?: {
|
||||
out?: {
|
||||
result?: VersionableObject<any, any, any, any>;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface ServiceTransforms {
|
||||
|
@ -112,6 +117,11 @@ export interface ServiceTransforms {
|
|||
result: ObjectTransforms<any, any, any, any>;
|
||||
};
|
||||
};
|
||||
mSearch: {
|
||||
out: {
|
||||
result: ObjectTransforms<any, any, any, any>;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface ServiceDefinitionVersioned {
|
||||
|
|
|
@ -17,6 +17,7 @@ export type {
|
|||
ContentTypeDefinition,
|
||||
StorageContext,
|
||||
StorageContextGetTransformFn,
|
||||
MSearchConfig,
|
||||
} from './types';
|
||||
|
||||
export type { ContentRegistry } from './registry';
|
||||
|
|
|
@ -9,7 +9,7 @@
|
|||
import { validateVersion } from '@kbn/object-versioning/lib/utils';
|
||||
import { ContentType } from './content_type';
|
||||
import { EventBus } from './event_bus';
|
||||
import type { ContentStorage, ContentTypeDefinition } from './types';
|
||||
import type { ContentStorage, ContentTypeDefinition, MSearchConfig } from './types';
|
||||
import type { ContentCrud } from './crud';
|
||||
|
||||
export class ContentRegistry {
|
||||
|
@ -23,7 +23,9 @@ export class ContentRegistry {
|
|||
* @param contentType The content type to register
|
||||
* @param config The content configuration
|
||||
*/
|
||||
register<S extends ContentStorage<any> = ContentStorage>(definition: ContentTypeDefinition<S>) {
|
||||
register<S extends ContentStorage<any, any, MSearchConfig<any, any>> = ContentStorage>(
|
||||
definition: ContentTypeDefinition<S>
|
||||
) {
|
||||
if (this.types.has(definition.id)) {
|
||||
throw new Error(`Content [${definition.id}] is already registered`);
|
||||
}
|
||||
|
|
|
@ -41,7 +41,11 @@ export interface StorageContext {
|
|||
};
|
||||
}
|
||||
|
||||
export interface ContentStorage<T = unknown, U = T> {
|
||||
export interface ContentStorage<
|
||||
T = unknown,
|
||||
U = T,
|
||||
TMSearchConfig extends MSearchConfig<T, any> = MSearchConfig<T, unknown>
|
||||
> {
|
||||
/** Get a single item */
|
||||
get(ctx: StorageContext, id: string, options?: object): Promise<GetResult<T, any>>;
|
||||
|
||||
|
@ -69,7 +73,7 @@ export interface ContentStorage<T = unknown, U = T> {
|
|||
* Opt-in to multi-type search.
|
||||
* Can only be supported if the content type is backed by a saved object since `mSearch` is using the `savedObjects.find` API.
|
||||
**/
|
||||
mSearch?: MSearchConfig<T>;
|
||||
mSearch?: TMSearchConfig;
|
||||
}
|
||||
|
||||
export interface ContentTypeDefinition<S extends ContentStorage = ContentStorage> {
|
||||
|
@ -87,7 +91,7 @@ export interface ContentTypeDefinition<S extends ContentStorage = ContentStorage
|
|||
* By configuring a content type with a `MSearchConfig`, it can be searched in the multi-type search.
|
||||
* Underneath content management is using the `savedObjects.find` API to search the saved objects.
|
||||
*/
|
||||
export interface MSearchConfig<T = unknown, SavedObjectAttributes = unknown> {
|
||||
export interface MSearchConfig<T = unknown, TSavedObjectAttributes = unknown> {
|
||||
/**
|
||||
* The saved object type that corresponds to this content type.
|
||||
*/
|
||||
|
@ -98,7 +102,7 @@ export interface MSearchConfig<T = unknown, SavedObjectAttributes = unknown> {
|
|||
*/
|
||||
toItemResult: (
|
||||
ctx: StorageContext,
|
||||
savedObject: SavedObjectsFindResult<SavedObjectAttributes>
|
||||
savedObject: SavedObjectsFindResult<TSavedObjectAttributes>
|
||||
) => T;
|
||||
|
||||
/**
|
||||
|
|
|
@ -14,4 +14,4 @@ export function plugin(initializerContext: PluginInitializerContext) {
|
|||
}
|
||||
|
||||
export type { ContentManagementServerSetup, ContentManagementServerStart } from './types';
|
||||
export type { ContentStorage, StorageContext } from './core';
|
||||
export type { ContentStorage, StorageContext, MSearchConfig } from './core';
|
||||
|
|
|
@ -12,5 +12,6 @@ import { PluginFunctionalProviderContext } from '../../plugin_functional/service
|
|||
export default function ({ loadTestFile }: PluginFunctionalProviderContext) {
|
||||
describe('content management examples', function () {
|
||||
loadTestFile(require.resolve('./todo_app'));
|
||||
loadTestFile(require.resolve('./msearch'));
|
||||
});
|
||||
}
|
||||
|
|
47
test/examples/content_management/msearch.ts
Normal file
47
test/examples/content_management/msearch.ts
Normal 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 { PluginFunctionalProviderContext } from '../../plugin_functional/services';
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default function ({ getService, getPageObjects }: PluginFunctionalProviderContext) {
|
||||
const testSubjects = getService('testSubjects');
|
||||
const PageObjects = getPageObjects(['common', 'home', 'header']);
|
||||
const listingTable = getService('listingTable');
|
||||
|
||||
describe('MSearch demo', () => {
|
||||
before(async () => {
|
||||
await PageObjects.common.navigateToUrl('home', '/tutorial_directory/sampleData', {
|
||||
useActualUrl: true,
|
||||
});
|
||||
await PageObjects.header.waitUntilLoadingHasFinished();
|
||||
await PageObjects.home.addSampleDataSet('flights');
|
||||
});
|
||||
after(async () => {
|
||||
await PageObjects.common.navigateToUrl('home', '/tutorial_directory/sampleData', {
|
||||
useActualUrl: true,
|
||||
});
|
||||
await PageObjects.header.waitUntilLoadingHasFinished();
|
||||
await PageObjects.home.removeSampleDataSet('flights');
|
||||
});
|
||||
|
||||
it('MSearch demo works', async () => {
|
||||
const appId = 'contentManagementExamples';
|
||||
await PageObjects.common.navigateToApp(appId, {
|
||||
path: 'msearch',
|
||||
});
|
||||
|
||||
await listingTable.waitUntilTableIsLoaded();
|
||||
await listingTable.searchForItemWithName('Origin Time Delayed');
|
||||
|
||||
await testSubjects.existOrFail(
|
||||
`cm-msearch-tableListingTitleLink-[Flights]-Origin-Time-Delayed`
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
|
@ -221,6 +221,7 @@ export class CommonPageObject extends FtrService {
|
|||
{
|
||||
basePath = '',
|
||||
shouldLoginIfPrompted = true,
|
||||
path = '',
|
||||
hash = '',
|
||||
search = '',
|
||||
disableWelcomePrompt = true,
|
||||
|
@ -238,7 +239,7 @@ export class CommonPageObject extends FtrService {
|
|||
});
|
||||
} else {
|
||||
appUrl = getUrl.noAuth(this.config.get('servers.kibana'), {
|
||||
pathname: `${basePath}/app/${appName}`,
|
||||
pathname: `${basePath}/app/${appName}` + (path ? `/${path}` : ''),
|
||||
hash,
|
||||
search,
|
||||
});
|
||||
|
|
|
@ -134,4 +134,11 @@ export const serviceDefinition: ServicesDefinition = {
|
|||
},
|
||||
},
|
||||
},
|
||||
mSearch: {
|
||||
out: {
|
||||
result: {
|
||||
schema: mapSavedObjectSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
@ -6,11 +6,16 @@
|
|||
*/
|
||||
import Boom from '@hapi/boom';
|
||||
import type { SearchQuery } from '@kbn/content-management-plugin/common';
|
||||
import type { ContentStorage, StorageContext } from '@kbn/content-management-plugin/server';
|
||||
import type {
|
||||
ContentStorage,
|
||||
StorageContext,
|
||||
MSearchConfig,
|
||||
} from '@kbn/content-management-plugin/server';
|
||||
import type {
|
||||
SavedObject,
|
||||
SavedObjectReference,
|
||||
SavedObjectsFindOptions,
|
||||
SavedObjectsFindResult,
|
||||
} from '@kbn/core-saved-objects-api-server';
|
||||
|
||||
import { CONTENT_ID } from '../../common/content_management';
|
||||
|
@ -86,7 +91,9 @@ function savedObjectToMapItem(
|
|||
|
||||
const SO_TYPE: MapContentType = 'map';
|
||||
|
||||
export class MapsStorage implements ContentStorage<MapItem, PartialMapItem> {
|
||||
export class MapsStorage
|
||||
implements ContentStorage<MapItem, PartialMapItem, MSearchConfig<MapItem, MapAttributes>>
|
||||
{
|
||||
constructor() {}
|
||||
|
||||
async get(ctx: StorageContext, id: string): Promise<MapGetOut> {
|
||||
|
@ -306,4 +313,29 @@ export class MapsStorage implements ContentStorage<MapItem, PartialMapItem> {
|
|||
|
||||
return value;
|
||||
}
|
||||
|
||||
// Configure `mSearch` to opt-in maps into the multi content type search API
|
||||
mSearch = {
|
||||
savedObjectType: SO_TYPE,
|
||||
toItemResult: (
|
||||
ctx: StorageContext,
|
||||
savedObject: SavedObjectsFindResult<MapAttributes>
|
||||
): MapItem => {
|
||||
const {
|
||||
utils: { getTransforms },
|
||||
} = ctx;
|
||||
const transforms = getTransforms(cmServicesDefinition);
|
||||
|
||||
// Validate DB response and DOWN transform to the request version
|
||||
const { value, error: resultError } = transforms.mSearch.out.result.down<MapItem, MapItem>(
|
||||
savedObjectToMapItem(savedObject, false)
|
||||
);
|
||||
|
||||
if (resultError) {
|
||||
throw Boom.badRequest(`Invalid response. ${resultError.message}`);
|
||||
}
|
||||
|
||||
return value;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue