[CM] Example plugin with server-side registry usage (#151885)

## Summary

Close https://github.com/elastic/kibana/issues/152002

In https://github.com/elastic/kibana/pull/151163 we introduced a simple
demo todo app run in a storybook with a custom client-side content
management client (no server-side cm registry usage).
This is a follow-up PR that re-uses the same demo todo app, but also
runs it in an example plugin with proper server-side content management
registry usage, so now we have a basic end-to-end demonstration of
content management capabilities. The demo app is covered by functional
tests, so now we also have basic end-to-end test coverage.


As this is the first kind of real-world end-to-end usage of the CM APIs,
I'd like to use this and
[previous](https://github.com/elastic/kibana/pull/151163) prs as a base
for the discussion and polishing current APIs. I'll leave a review with
comments where I think some API polishing is needed.


**Notable changes apart from the example plugin itself:** 

1. Move `demo/` todo app and its stories introduced in
https://github.com/elastic/kibana/pull/151163 from
`src/plugins/content_management` to
`examples/content_management_examples`. This was mostly needed to not
export `demo/` code on the public plugin export to avoid increasing
bundle size.
2. Add needed exports to the plugin contract 
3. Reshuffle `common/` to not import `@kbn/schema` client side
48aa41403b
4. Fix client-side RPC client to work with the latest server-side
changes (shouldn't break from now on because of the end-to-end test
coverage)
This commit is contained in:
Anton Dosov 2023-02-28 14:57:57 +01:00 committed by GitHub
parent a799a85533
commit 2e171759ca
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
48 changed files with 706 additions and 72 deletions

View file

@ -19,7 +19,7 @@ const STORYBOOKS = [
'cloud_chat',
'coloring',
'chart_icons',
'content_management_plugin',
'content_management_examples',
'controls',
'custom_integrations',
'dashboard_enhanced',

1
.github/CODEOWNERS vendored
View file

@ -83,6 +83,7 @@ packages/kbn-config-mocks @elastic/kibana-core
packages/kbn-config-schema @elastic/kibana-core
src/plugins/console @elastic/platform-deployment-management
packages/content-management/content_editor @elastic/appex-sharedux
examples/content_management_examples @elastic/appex-sharedux
src/plugins/content_management @elastic/appex-sharedux
packages/content-management/table_list @elastic/appex-sharedux
examples/controls_example @elastic/kibana-presentation

View file

@ -0,0 +1,3 @@
# Content Management Examples
An example plugin that shows how to integrate with the Kibana "content management" plugin.

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 * from './todos';

View file

@ -0,0 +1,63 @@
/*
* 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 { schema } from '@kbn/config-schema';
import {
CreateIn,
DeleteIn,
GetIn,
SearchIn,
UpdateIn,
} from '@kbn/content-management-plugin/common';
export const TODO_CONTENT_ID = 'todos';
export interface Todo {
id: string;
title: string;
completed: boolean;
}
const todoSchema = schema.object({
id: schema.string(),
title: schema.string(),
completed: schema.boolean(),
});
export type TodoCreateIn = CreateIn<'todos', { title: string }>;
export type TodoCreateOut = Todo; // TODO: Is this correct?
export const createInSchema = schema.object({ title: schema.string() });
export const createOutSchema = todoSchema;
export type TodoUpdateIn = UpdateIn<'todos', Partial<Omit<Todo, 'id'>>>;
export type TodoUpdateOut = Todo;
export const updateInSchema = schema.object({
title: schema.maybe(schema.string()),
completed: schema.maybe(schema.boolean()),
});
export const updateOutSchema = todoSchema;
export type TodoDeleteIn = DeleteIn<'todos', { id: string }>;
export type TodoDeleteOut = void;
export type TodoGetIn = GetIn<'todos'>;
export type TodoGetOut = Todo;
export const getOutSchema = todoSchema;
export type TodoSearchIn = SearchIn<'todos', { filter?: 'todo' | 'completed' }>;
export interface TodoSearchOut {
hits: Todo[];
}
export const searchInSchema = schema.object({
filter: schema.maybe(
schema.oneOf([schema.literal('todo'), schema.literal('completed')], {
defaultValue: undefined,
})
),
});
export const searchOutSchema = schema.object({
hits: schema.arrayOf(todoSchema),
});

View file

@ -0,0 +1,13 @@
/*
* 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.
*/
module.exports = {
preset: '@kbn/test',
rootDir: '../..',
roots: ['<rootDir>/examples/content_management_examples'],
};

View file

@ -0,0 +1,15 @@
{
"type": "plugin",
"id": "@kbn/content-management-examples-plugin",
"owner": "@elastic/appex-sharedux",
"description": "Example plugin integrating with content management plugin",
"plugin": {
"id": "contentManagementExamples",
"server": true,
"browser": true,
"requiredPlugins": [
"contentManagement",
"developerExamples"
]
}
}

View file

@ -0,0 +1,31 @@
/*
* 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 ReactDOM from 'react-dom';
import { EuiPageTemplate } from '@elastic/eui';
import { AppMountParameters, CoreStart } from '@kbn/core/public';
import { StartDeps } from '../types';
import { TodoApp } from './todos';
export const renderApp = (
{ notifications }: CoreStart,
{ contentManagement }: StartDeps,
{ element }: AppMountParameters
) => {
ReactDOM.render(
<EuiPageTemplate offset={0}>
<EuiPageTemplate.Section>
<TodoApp contentClient={contentManagement.client} />
</EuiPageTemplate.Section>
</EuiPageTemplate>,
element
);
return () => ReactDOM.unmountComponentAtNode(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 { TodoApp } from './todo_app';

View file

@ -7,8 +7,9 @@
*/
import * as React from 'react';
import { Todos } from './todos';
import { ContentClientProvider, ContentClient } from '../../public/content_client';
import { ContentClientProvider, ContentClient } from '@kbn/content-management-plugin/public';
import { Todos } from '../todos';
import { TodosClient } from './todos_client';
export default {

View file

@ -7,28 +7,32 @@
*/
import { v4 as uuidv4 } from 'uuid';
import type { CrudClient } from '../../public/crud_client';
import type { CreateIn, DeleteIn, GetIn, SearchIn, UpdateIn } from '../../common';
export interface Todo {
id: string;
title: string;
completed: boolean;
}
export type TodoCreateIn = CreateIn<'todos', { title: string }>;
export type TodoUpdateIn = UpdateIn<'todos', Partial<Omit<Todo, 'id'>>>;
export type TodoDeleteIn = DeleteIn<'todos', { id: string }>;
export type TodoGetIn = GetIn<'todos'>;
export type TodoSearchIn = SearchIn<'todos', { filter?: 'todo' | 'completed' }>;
import type { CrudClient } from '@kbn/content-management-plugin/public';
import type {
TodoCreateIn,
TodoUpdateIn,
TodoDeleteIn,
TodoGetIn,
TodoSearchIn,
TodoUpdateOut,
TodoCreateOut,
TodoSearchOut,
TodoDeleteOut,
Todo,
TodoGetOut,
} from '../../../../common/examples/todos';
/**
* This client is used in the storybook examples to simulate a server-side registry client
* and to show how a content type can have a custom client-side CRUD client without using the server-side registry
*/
export class TodosClient implements CrudClient {
private todos: Todo[] = [
{ id: uuidv4(), title: 'Learn Elasticsearch', completed: true },
{ id: uuidv4(), title: 'Learn Kibana', completed: false },
];
async create(input: TodoCreateIn): Promise<Todo> {
async create(input: TodoCreateIn): Promise<TodoCreateOut> {
const todo = {
id: uuidv4(),
title: input.data.title,
@ -38,22 +42,22 @@ export class TodosClient implements CrudClient {
return todo;
}
async delete(input: TodoDeleteIn): Promise<void> {
async delete(input: TodoDeleteIn): Promise<TodoDeleteOut> {
this.todos = this.todos.filter((todo) => todo.id !== input.id);
}
async get(input: TodoGetIn): Promise<Todo> {
async get(input: TodoGetIn): Promise<TodoGetOut> {
return this.todos.find((todo) => todo.id === input.id)!;
}
async search(input: TodoSearchIn): Promise<{ hits: Todo[] }> {
async search(input: TodoSearchIn): Promise<TodoSearchOut> {
const filter = input.query.filter;
if (filter === 'todo') return { hits: this.todos.filter((t) => !t.completed) };
if (filter === 'completed') return { hits: this.todos.filter((t) => t.completed) };
return { hits: [...this.todos] };
}
async update(input: TodoUpdateIn): Promise<Todo> {
async update(input: TodoUpdateIn): Promise<TodoUpdateOut> {
const idToUpdate = input.id;
const todoToUpdate = this.todos.find((todo) => todo.id === idToUpdate)!;
if (todoToUpdate) {

View file

@ -0,0 +1,19 @@
/*
* 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 { Todos } from './todos';
export const TodoApp = (props: { contentClient: ContentClient }) => {
return (
<ContentClientProvider contentClient={props.contentClient}>
<Todos />
</ContentClientProvider>
);
};

View file

@ -7,22 +7,32 @@
*/
import React from 'react';
import { EuiButtonGroup, EuiButtonIcon, EuiCheckbox, EuiFieldText, EuiSpacer } from '@elastic/eui';
import {
useCreateContentMutation,
useDeleteContentMutation,
useSearchContentQuery,
useUpdateContentMutation,
// eslint-disable-next-line @kbn/imports/no_boundary_crossing
} from '../../public/content_client';
import type { Todo, TodoCreateIn, TodoDeleteIn, TodoSearchIn, TodoUpdateIn } from './todos_client';
} from '@kbn/content-management-plugin/public';
const useCreateTodoMutation = () => useCreateContentMutation<TodoCreateIn, Todo>();
const useDeleteTodoMutation = () => useDeleteContentMutation<TodoDeleteIn, void>();
const useUpdateTodoMutation = () => useUpdateContentMutation<TodoUpdateIn, Todo>();
import {
TODO_CONTENT_ID,
Todo,
TodoCreateIn,
TodoDeleteIn,
TodoSearchIn,
TodoUpdateIn,
TodoUpdateOut,
TodoCreateOut,
TodoSearchOut,
TodoDeleteOut,
} from '../../../common/examples/todos';
const useCreateTodoMutation = () => useCreateContentMutation<TodoCreateIn, TodoCreateOut>();
const useDeleteTodoMutation = () => useDeleteContentMutation<TodoDeleteIn, TodoDeleteOut>();
const useUpdateTodoMutation = () => useUpdateContentMutation<TodoUpdateIn, TodoUpdateOut>();
const useSearchTodosQuery = ({ filter }: { filter: TodoSearchIn['query']['filter'] }) =>
useSearchContentQuery<TodoSearchIn, { hits: Todo[] }>({
contentTypeId: 'todos',
useSearchContentQuery<TodoSearchIn, TodoSearchOut>({
contentTypeId: TODO_CONTENT_ID,
query: { filter },
});
@ -70,14 +80,17 @@ export const Todos = () => {
<ul>
{data.hits.map((todo: Todo) => (
<React.Fragment key={todo.id}>
<li style={{ display: 'flex', alignItems: 'center' }}>
<li
style={{ display: 'flex', alignItems: 'center' }}
data-test-subj={`todoItem todoItem-${todo.id}`}
>
<EuiCheckbox
id={todo.id + ''}
key={todo.id}
checked={todo.completed}
onChange={(e) => {
updateTodoMutation.mutate({
contentTypeId: 'todos',
contentTypeId: TODO_CONTENT_ID,
id: todo.id,
data: {
completed: e.target.checked,
@ -85,7 +98,7 @@ export const Todos = () => {
});
}}
label={todo.title}
data-test-subj={`todoCheckbox-${todo.id}`}
data-test-subj={`todoCheckbox todoCheckbox-${todo.id}`}
/>
<EuiButtonIcon
@ -95,7 +108,7 @@ export const Todos = () => {
aria-label="Delete"
color="danger"
onClick={() => {
deleteTodoMutation.mutate({ contentTypeId: 'todos', id: todo.id });
deleteTodoMutation.mutate({ contentTypeId: TODO_CONTENT_ID, id: todo.id });
}}
/>
</li>
@ -112,7 +125,7 @@ export const Todos = () => {
if (!inputRef || !inputRef.value) return;
createTodoMutation.mutate({
contentTypeId: 'todos',
contentTypeId: TODO_CONTENT_ID,
data: {
title: inputRef.value,
},

View file

@ -0,0 +1,13 @@
/*
* 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 { ContentManagementExamplesPlugin } from './plugin';
export function plugin() {
return new ContentManagementExamplesPlugin();
}

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 { AppNavLinkStatus } from '@kbn/core-application-browser';
import { AppMountParameters, CoreSetup, CoreStart, Plugin } from '@kbn/core/public';
import { StartDeps, SetupDeps } from './types';
export class ContentManagementExamplesPlugin
implements Plugin<unknown, unknown, SetupDeps, StartDeps>
{
public setup(core: CoreSetup<StartDeps>, { contentManagement, developerExamples }: SetupDeps) {
developerExamples.register({
appId: `contentManagementExamples`,
title: `Content Management Examples`,
description: 'Example plugin for the content management plugin',
});
core.application.register({
id: `contentManagementExamples`,
title: `Content Management Examples`,
navLinkStatus: AppNavLinkStatus.hidden,
async mount(params: AppMountParameters) {
const { renderApp } = await import('./examples');
const [coreStart, deps] = await core.getStartServices();
return renderApp(coreStart, deps, params);
},
});
return {};
}
public start(core: CoreStart) {
return {};
}
public stop() {}
}

View file

@ -0,0 +1,22 @@
/*
* 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 {
ContentManagementPublicSetup,
ContentManagementPublicStart,
} from '@kbn/content-management-plugin/public';
import { DeveloperExamplesSetup } from '@kbn/developer-examples-plugin/public';
export interface SetupDeps {
contentManagement: ContentManagementPublicSetup;
developerExamples: DeveloperExamplesSetup;
}
export interface StartDeps {
contentManagement: ContentManagementPublicStart;
}

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 { registerTodoContentType } from './todos';

View file

@ -0,0 +1,147 @@
/*
* 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 {
ContentStorage,
StorageContext,
ContentManagementServerSetup,
} from '@kbn/content-management-plugin/server';
import { v4 } from 'uuid';
import {
createInSchema,
searchInSchema,
Todo,
TODO_CONTENT_ID,
updateInSchema,
TodoSearchOut,
TodoCreateOut,
TodoUpdateOut,
TodoDeleteOut,
TodoGetOut,
createOutSchema,
getOutSchema,
updateOutSchema,
searchOutSchema,
TodoUpdateIn,
TodoSearchIn,
TodoCreateIn,
} from '../../../common/examples/todos';
export const registerTodoContentType = ({
contentManagement,
}: {
contentManagement: ContentManagementServerSetup;
}) => {
contentManagement.register({
id: TODO_CONTENT_ID,
schemas: {
content: {
create: {
in: {
data: createInSchema,
},
out: {
result: createOutSchema,
},
},
update: {
in: {
data: updateInSchema,
},
out: {
result: updateOutSchema,
},
},
search: {
in: {
query: searchInSchema,
},
out: {
result: searchOutSchema,
},
},
get: {
out: {
result: getOutSchema,
},
},
},
},
storage: new TodosStorage(),
});
};
class TodosStorage implements ContentStorage {
private db: Map<string, Todo> = new Map();
constructor() {
const id1 = v4();
this.db.set(id1, {
id: id1,
title: 'Learn Elasticsearch',
completed: true,
});
const id2 = v4();
this.db.set(id2, {
id: id2,
title: 'Learn Kibana',
completed: false,
});
}
async get(ctx: StorageContext, id: string): Promise<TodoGetOut> {
return this.db.get(id)!;
}
async bulkGet(ctx: StorageContext, ids: string[]): Promise<TodoGetOut[]> {
return ids.map((id) => this.db.get(id)!);
}
async create(ctx: StorageContext, data: TodoCreateIn['data']): Promise<TodoCreateOut> {
const todo: Todo = {
...data,
completed: false,
id: v4(),
};
this.db.set(todo.id, todo);
return todo;
}
async update(
ctx: StorageContext,
id: string,
data: TodoUpdateIn['data']
): Promise<TodoUpdateOut> {
const content = this.db.get(id);
if (!content) {
throw new Error(`Content to update not found [${id}].`);
}
const updatedContent = {
...content,
...data,
};
this.db.set(id, updatedContent);
return updatedContent;
}
async delete(ctx: StorageContext, id: string): Promise<TodoDeleteOut> {
this.db.delete(id);
}
async search(ctx: StorageContext, query: TodoSearchIn['query']): Promise<TodoSearchOut> {
const hits = Array.from(this.db.values());
if (query.filter === 'todo') return { hits: hits.filter((t) => !t.completed) };
if (query.filter === 'completed') return { hits: hits.filter((t) => t.completed) };
return { hits };
}
}

View file

@ -0,0 +1,14 @@
/*
* 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 { PluginInitializerContext } from '@kbn/core/server';
import { ContentManagementExamplesPlugin } from './plugin';
export function plugin(initializerContext: PluginInitializerContext) {
return new ContentManagementExamplesPlugin(initializerContext);
}

View file

@ -0,0 +1,26 @@
/*
* 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 { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from '@kbn/core/server';
import type { SetupDeps, StartDeps } from './types';
import { registerTodoContentType } from './examples/todos';
export class ContentManagementExamplesPlugin implements Plugin<void, void, SetupDeps, StartDeps> {
constructor(initializerContext: PluginInitializerContext) {}
public setup(core: CoreSetup, { contentManagement }: SetupDeps) {
registerTodoContentType({ contentManagement });
return {};
}
public start(core: CoreStart, { contentManagement }: StartDeps) {
return {};
}
public stop() {}
}

View file

@ -0,0 +1,20 @@
/*
* 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 {
ContentManagementServerSetup,
ContentManagementServerStart,
} from '@kbn/content-management-plugin/server';
export interface SetupDeps {
contentManagement: ContentManagementServerSetup;
}
export interface StartDeps {
contentManagement: ContentManagementServerStart;
}

View file

@ -0,0 +1,24 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "target/types"
},
"include": [
"index.ts",
"common/**/*",
"public/**/*.ts",
"public/**/*.tsx",
"server/**/*.ts",
"../../typings/**/*"
],
"exclude": [
"target/**/*",
],
"kbn_references": [
"@kbn/core",
"@kbn/developer-examples-plugin",
"@kbn/config-schema",
"@kbn/content-management-plugin",
"@kbn/core-application-browser",
]
}

View file

@ -182,6 +182,7 @@
"@kbn/config-schema": "link:packages/kbn-config-schema",
"@kbn/console-plugin": "link:src/plugins/console",
"@kbn/content-management-content-editor": "link:packages/content-management/content_editor",
"@kbn/content-management-examples-plugin": "link:examples/content_management_examples",
"@kbn/content-management-plugin": "link:src/plugins/content_management",
"@kbn/content-management-table-list": "link:packages/content-management/table_list",
"@kbn/controls-example-plugin": "link:examples/controls_example",

View file

@ -18,7 +18,7 @@ export const storybookAliases = {
language_documentation_popover: 'packages/kbn-language-documentation-popover/.storybook',
chart_icons: 'packages/kbn-chart-icons/.storybook',
content_management: 'packages/content-management/.storybook',
content_management_plugin: 'src/plugins/content_management/.storybook',
content_management_examples: 'examples/content_management_examples/.storybook',
controls: 'src/plugins/controls/storybook',
custom_integrations: 'src/plugins/custom_integrations/storybook',
dashboard_enhanced: 'x-pack/plugins/dashboard_enhanced/.storybook',

View file

@ -8,4 +8,4 @@
export const PLUGIN_ID = 'contentManagement';
export const API_ENDPOINT = '/api/content_management';
export const API_ENDPOINT = '/api/content_management/rpc';

View file

@ -19,4 +19,7 @@ export type {
SearchIn,
} from './rpc';
export { procedureNames, schemas as rpcSchemas } from './rpc';
export { procedureNames } from './rpc/constants';
// intentionally not exporting schemas to not include @kbn/schema in the public bundle
// export { schemas as rpcSchemas } from './rpc';

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.
*/
// exporting schemas separately from the index.ts file to not include @kbn/schema in the public bundle
// should be only used server-side or in jest tests
export { schemas as rpcSchemas } from './rpc';

View file

@ -7,6 +7,18 @@
*/
import { ContentManagementPlugin } from './plugin';
export type { CrudClient } from './crud_client';
export {
ContentClientProvider,
ContentClient,
useCreateContentMutation,
useUpdateContentMutation,
useDeleteContentMutation,
useSearchContentQuery,
useGetContentQuery,
useContentClient,
type QueryOptions,
} from './content_client';
export function plugin() {
return new ContentManagementPlugin();

View file

@ -13,8 +13,9 @@ import {
SetupDependencies,
StartDependencies,
} from './types';
import type { ContentClient } from './content_client';
import type { ContentTypeRegistry } from './registry';
import { ContentClient } from './content_client';
import { ContentTypeRegistry } from './registry';
import { RpcClient } from './rpc_client';
export class ContentManagementPlugin
implements
@ -26,20 +27,17 @@ export class ContentManagementPlugin
>
{
public setup(core: CoreSetup, deps: SetupDependencies) {
// don't actually expose the client and the registry until it is used to avoid increasing bundle size
return {
registry: {} as ContentTypeRegistry,
};
}
public start(core: CoreStart, deps: StartDependencies) {
// don't actually expose the client and the registry until it is used to avoid increasing bundle size
// const rpcClient = new RpcClient(core.http);
// const contentTypeRegistry = new ContentTypeRegistry();
// const contentClient = new ContentClient(
// (contentType) => contentTypeRegistry.get(contentType)?.crud() ?? rpcClient
// );
// return { client: contentClient, registry: contentTypeRegistry };
return { client: {} as ContentClient, registry: {} as ContentTypeRegistry };
const rpcClient = new RpcClient(core.http);
const contentTypeRegistry = new ContentTypeRegistry();
const contentClient = new ContentClient(
(contentType) => contentTypeRegistry.get(contentType)?.crud ?? rpcClient
);
return { client: contentClient, registry: contentTypeRegistry };
}
}

View file

@ -6,7 +6,7 @@
* Side Public License, v 1.
*/
import { procedureNames, API_ENDPOINT } from '../../common';
import { API_ENDPOINT, procedureNames } from '../../common';
import { RpcClient } from './rpc_client';

View file

@ -18,36 +18,44 @@ import type {
ProcedureName,
} from '../../common';
import type { CrudClient } from '../crud_client/crud_client';
import type {
GetResponse,
BulkGetResponse,
CreateItemResponse,
DeleteItemResponse,
UpdateItemResponse,
SearchResponse,
} from '../../server/core/crud';
export class RpcClient implements CrudClient {
constructor(private http: { post: HttpSetup['post'] }) {}
public get<I extends GetIn = GetIn, O = unknown>(input: I): Promise<O> {
return this.sendMessage('get', input);
return this.sendMessage<GetResponse<O>>('get', input).then((r) => r.item);
}
public bulkGet<I extends BulkGetIn = BulkGetIn, O = unknown>(input: I): Promise<O> {
return this.sendMessage('bulkGet', input);
return this.sendMessage<BulkGetResponse<O>>('bulkGet', input).then((r) => r.items);
}
public create<I extends CreateIn = CreateIn, O = unknown>(input: I): Promise<O> {
return this.sendMessage('create', input);
return this.sendMessage<CreateItemResponse<O>>('create', input).then((r) => r.result);
}
public update<I extends UpdateIn = UpdateIn, O = unknown>(input: I): Promise<O> {
return this.sendMessage('update', input);
return this.sendMessage<UpdateItemResponse<O>>('update', input).then((r) => r.result);
}
public delete<I extends DeleteIn = DeleteIn, O = unknown>(input: I): Promise<O> {
return this.sendMessage('delete', input);
return this.sendMessage<DeleteItemResponse>('delete', input).then((r) => r.result);
}
public search<I extends SearchIn = SearchIn, O = unknown>(input: I): Promise<O> {
return this.sendMessage('search', input);
return this.sendMessage<SearchResponse>('search', input).then((r) => r.result);
}
private sendMessage = async (name: ProcedureName, input: any): Promise<any> => {
const { result } = await this.http.post<{ result: any }>(`${API_ENDPOINT}/${name}`, {
private sendMessage = async <O = unknown>(name: ProcedureName, input: any): Promise<O> => {
const { result } = await this.http.post<{ result: O }>(`${API_ENDPOINT}/${name}`, {
body: JSON.stringify(input),
});
return result;

View file

@ -10,7 +10,7 @@ import type { ContentStorage, StorageContext } from './types';
export interface GetResponse<T = any> {
contentTypeId: string;
item?: T;
item: T;
}
export interface BulkGetResponse<T = any> {
@ -33,6 +33,11 @@ export interface DeleteItemResponse<T = any> {
result: T;
}
export interface SearchResponse<T = any> {
contentTypeId: string;
result: T;
}
export class ContentCrud implements ContentStorage {
private storage: ContentStorage;
private eventBus: EventBus;
@ -245,7 +250,7 @@ export class ContentCrud implements ContentStorage {
ctx: StorageContext,
query: Query,
options?: Options
): Promise<CreateItemResponse<O>> {
): Promise<SearchResponse<O>> {
this.eventBus.emit({
type: 'searchItemStart',
contentTypeId: this.contentTypeId,

View file

@ -14,3 +14,4 @@ export function plugin(initializerContext: PluginInitializerContext) {
}
export type { ContentManagementServerSetup, ContentManagementServerStart } from './types';
export type { ContentStorage, StorageContext } from './core';

View file

@ -6,7 +6,7 @@
* Side Public License, v 1.
*/
import { rpcSchemas } from '../../../common';
import { rpcSchemas } from '../../../common/schemas';
import type { BulkGetIn } from '../../../common';
import type { StorageContext, ContentCrud } from '../../core';
import type { ProcedureDefinition } from '../rpc_service';

View file

@ -6,7 +6,7 @@
* Side Public License, v 1.
*/
import { rpcSchemas } from '../../../common';
import { rpcSchemas } from '../../../common/schemas';
import type { CreateIn } from '../../../common';
import type { StorageContext, ContentCrud } from '../../core';
import type { ProcedureDefinition } from '../rpc_service';

View file

@ -5,7 +5,7 @@
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { rpcSchemas } from '../../../common';
import { rpcSchemas } from '../../../common/schemas';
import type { DeleteIn } from '../../../common';
import type { StorageContext, ContentCrud } from '../../core';
import type { ProcedureDefinition } from '../rpc_service';

View file

@ -6,7 +6,7 @@
* Side Public License, v 1.
*/
import { rpcSchemas } from '../../../common';
import { rpcSchemas } from '../../../common/schemas';
import type { GetIn } from '../../../common';
import type { ContentCrud, StorageContext } from '../../core';
import { validate } from '../../utils';

View file

@ -6,7 +6,7 @@
* Side Public License, v 1.
*/
import { rpcSchemas } from '../../../common';
import { rpcSchemas } from '../../../common/schemas';
import type { SearchIn } from '../../../common';
import type { StorageContext, ContentCrud } from '../../core';
import type { ProcedureDefinition } from '../rpc_service';

View file

@ -5,7 +5,7 @@
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { rpcSchemas } from '../../../common';
import { rpcSchemas } from '../../../common/schemas';
import type { UpdateIn } from '../../../common';
import type { StorageContext, ContentCrud } from '../../core';
import type { ProcedureDefinition } from '../rpc_service';

View file

@ -6,11 +6,13 @@
* Side Public License, v 1.
*/
import { CoreApi } from './core';
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface SetupDependencies {}
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface ContentManagementServerSetup {}
export interface ContentManagementServerSetup extends CoreApi {}
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface ContentManagementServerStart {}

View file

@ -3,7 +3,7 @@
"compilerOptions": {
"outDir": "target/types",
},
"include": ["common/**/*", "public/**/*", "server/**/*", "demo/**/*"],
"include": ["common/**/*", "public/**/*", "server/**/*"],
"kbn_references": [
"@kbn/core",
"@kbn/config-schema",

View file

@ -28,6 +28,7 @@ export default async function ({ readConfigFile }) {
require.resolve('./field_formats'),
require.resolve('./partial_results'),
require.resolve('./search'),
require.resolve('./content_management'),
],
services: {
...functionalConfig.get('services'),

View file

@ -0,0 +1,16 @@
/*
* 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 ({ loadTestFile }: PluginFunctionalProviderContext) {
describe('content management examples', function () {
loadTestFile(require.resolve('./todo_app'));
});
}

View file

@ -0,0 +1,72 @@
/*
* 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 expect from '@kbn/expect';
import { Key } from 'selenium-webdriver';
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 find = getService('find');
const retry = getService('retry');
const PageObjects = getPageObjects(['common']);
describe('Todo app', () => {
it('Todo app works', async () => {
const appId = 'contentManagementExamples';
await PageObjects.common.navigateToApp(appId);
// check that initial state is correct
let todos = await testSubjects.findAll(`~todoItem`);
expect(todos.length).to.be(2);
// check that filters work
await (await find.byCssSelector('label[title="Completed"]')).click();
todos = await testSubjects.findAll(`~todoItem`);
expect(todos.length).to.be(1);
await (await find.byCssSelector('label[title="Todo"]')).click();
todos = await testSubjects.findAll(`~todoItem`);
expect(todos.length).to.be(1);
await (await find.byCssSelector('label[title="All"]')).click();
todos = await testSubjects.findAll(`~todoItem`);
expect(todos.length).to.be(2);
// check that adding new todo works
await testSubjects.setValue('newTodo', 'New todo');
await (await testSubjects.find('newTodo')).pressKeys(Key.ENTER);
await retry.tryForTime(1000, async () => {
todos = await testSubjects.findAll(`~todoItem`);
expect(todos.length).to.be(3);
});
// check that updating todo works
let newTodo = todos[2];
expect(await newTodo.getVisibleText()).to.be('New todo');
let newTodoCheckbox = await newTodo.findByTestSubject('~todoCheckbox');
expect(await newTodoCheckbox.isSelected()).to.be(false);
await (await newTodo.findByTagName('label')).click();
await (await find.byCssSelector('label[title="Completed"]')).click();
todos = await testSubjects.findAll(`~todoItem`);
expect(todos.length).to.be(2);
newTodo = todos[1];
expect(await newTodo.getVisibleText()).to.be('New todo');
newTodoCheckbox = await newTodo.findByTestSubject('~todoCheckbox');
expect(await newTodoCheckbox.isSelected()).to.be(true);
// check that deleting todo works
await (await newTodo.findByCssSelector('[aria-label="Delete"]')).click();
todos = await testSubjects.findAll(`~todoItem`);
expect(todos.length).to.be(1);
});
});
}

View file

@ -160,6 +160,8 @@
"@kbn/console-plugin/*": ["src/plugins/console/*"],
"@kbn/content-management-content-editor": ["packages/content-management/content_editor"],
"@kbn/content-management-content-editor/*": ["packages/content-management/content_editor/*"],
"@kbn/content-management-examples-plugin": ["examples/content_management_examples"],
"@kbn/content-management-examples-plugin/*": ["examples/content_management_examples/*"],
"@kbn/content-management-plugin": ["src/plugins/content_management"],
"@kbn/content-management-plugin/*": ["src/plugins/content_management/*"],
"@kbn/content-management-table-list": ["packages/content-management/table_list"],

View file

@ -3057,6 +3057,10 @@
version "0.0.0"
uid ""
"@kbn/content-management-examples-plugin@link:examples/content_management_examples":
version "0.0.0"
uid ""
"@kbn/content-management-plugin@link:src/plugins/content_management":
version "0.0.0"
uid ""
@ -17589,9 +17593,9 @@ inquirer@^8.2.3:
wrap-ansi "^7.0.0"
install-artifact-from-github@^1.3.0:
version "1.3.1"
resolved "https://registry.yarnpkg.com/install-artifact-from-github/-/install-artifact-from-github-1.3.1.tgz#eefaad9af35d632e5d912ad1569c1de38c3c2462"
integrity sha512-3l3Bymg2eKDsN5wQuMfgGEj2x6l5MCAv0zPL6rxHESufFVlEAKW/6oY9F1aGgvY/EgWm5+eWGRjINveL4X7Hgg==
version "1.3.2"
resolved "https://registry.yarnpkg.com/install-artifact-from-github/-/install-artifact-from-github-1.3.2.tgz#1a16d9508e40330523a3017ae0d4713ccc64de82"
integrity sha512-yCFcLvqk0yQdxx0uJz4t9Z3adDMLAYrcGYv546uRXCSvxE+GqNYhhz/KmrGcUKGI/gVLR9n/e/zM9jX/+ASMJQ==
internal-slot@^1.0.3:
version "1.0.3"