[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:
Anton Dosov 2023-04-21 16:41:08 +02:00 committed by GitHub
parent cfc01d5444
commit 6aa1491c9e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 316 additions and 23 deletions

View file

@ -9,7 +9,9 @@
"browser": true,
"requiredPlugins": [
"contentManagement",
"developerExamples"
"developerExamples",
"kibanaReact",
"savedObjectsTaggingOss"
]
}
}

View file

@ -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
);

View file

@ -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';

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 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>
);
};

View file

@ -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})`);
}}
/>
);
};

View file

@ -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;
}

View file

@ -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",
]
}

View file

@ -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>;

View file

@ -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' }
);

View file

@ -240,6 +240,7 @@ describe('CM services getTransforms()', () => {
'delete.out.result',
'search.in.options',
'search.out.result',
'mSearch.out.result',
].sort()
);
});

View file

@ -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 = (

View file

@ -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 {

View file

@ -17,6 +17,7 @@ export type {
ContentTypeDefinition,
StorageContext,
StorageContextGetTransformFn,
MSearchConfig,
} from './types';
export type { ContentRegistry } from './registry';

View file

@ -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`);
}

View file

@ -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;
/**

View file

@ -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';

View file

@ -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'));
});
}

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 { 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`
);
});
});
}

View file

@ -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,
});

View file

@ -134,4 +134,11 @@ export const serviceDefinition: ServicesDefinition = {
},
},
},
mSearch: {
out: {
result: {
schema: mapSavedObjectSchema,
},
},
},
};

View file

@ -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;
},
};
}