[CM] Add example app story, adjust client API (#151163)

## Summary


**In this PR:**

- Adds an example demo app that uses a content type CRUD and search. 
- The example uses client-side CRUD (see `TodosClient`), it doesn't use
server-side registry
- I plan to follow up with an end-to-end example plugin that uses the
server-side registry and re-uses most of the client side `demo/` code
- Adjust the `CrudClient` interface to not use generics on methods as
this made it impossible to use properly for implementing the client-side
custom client like `TodosClient` (I was hitting this
https://github.com/elastic/kibana/pull/151163#discussion_r1106926899)
- Adjust the types on the client to match the recent server-side changes
- Fix missing types in client APIs
- Invalidate active queries after updates succeed (in mutation hooks for
now)


https://ci-artifacts.kibana.dev/storybooks/pr-151163/7dcb085da80cdbf15978de0e005c5c2568bb3e79/content_management_plugin/index.html
<img width="756" alt="Screenshot 2023-02-21 at 11 20 20"
src="https://user-images.githubusercontent.com/7784120/220317668-4f3fdcff-8773-418f-8163-15a0dcb9d7dc.png">


**The main goal of this PR was to try to use the CM API and find any
caveats. Something I struggled with:**

- `CrudClient` with generics on the methods was impossible to use as an
interfaces for a custom client-side crud client. See
https://github.com/elastic/kibana/pull/151163#discussion_r1106926899. I
simplified and seems like didn't use any typescript value
- For Todo app use case we work with a single content type. But the apis
require to specify `contentTypeId` everywhere separately instead of
once. see
https://github.com/elastic/kibana/pull/151163#discussion_r1106930934.
Maybe we should add `Scoped*` version of the APIs where you don't need
to specify `contentTypeId` every time
This commit is contained in:
Anton Dosov 2023-02-22 11:13:41 +01:00 committed by GitHub
parent f7f29d758b
commit 355f77b752
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 367 additions and 18 deletions

View file

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

View file

@ -18,6 +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',
controls: 'src/plugins/controls/storybook',
custom_integrations: 'src/plugins/custom_integrations/storybook',
dashboard_enhanced: 'x-pack/plugins/dashboard_enhanced/.storybook',

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.
*/
module.exports = require('@kbn/storybook').defaultConfig;

View file

@ -0,0 +1,90 @@
/*
* 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 { render, screen, within, waitForElementToBeRemoved } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { SimpleTodoApp } from './todo.stories';
test('SimpleTodoApp works', async () => {
render(<SimpleTodoApp />);
// check initial todos
let todos = await screen.findAllByRole('listitem');
expect(todos).toHaveLength(2);
let [firstTodo, secondTodo] = todos;
expect(firstTodo).toHaveTextContent('Learn Elasticsearch');
expect(secondTodo).toHaveTextContent('Learn Kibana');
const [firstTodoCheckbox, secondTodoCheckbox] = await screen.findAllByRole('checkbox');
expect(firstTodoCheckbox).toBeChecked();
expect(secondTodoCheckbox).not.toBeChecked();
// apply "completed" filter
let todoFilters = screen.getByRole('group', { name: 'Todo filters' });
let completedFilter = within(todoFilters).getByTestId('completed');
userEvent.click(completedFilter);
// check only completed todos are shown
todos = await screen.findAllByRole('listitem');
expect(todos).toHaveLength(1);
[firstTodo] = todos;
expect(firstTodo).toHaveTextContent('Learn Elasticsearch');
// apply "todo" filter
todoFilters = screen.getByRole('group', { name: 'Todo filters' });
const todoFilter = within(todoFilters).getByTestId('todo');
userEvent.click(todoFilter);
// check only todo todos are shown
todos = await screen.findAllByRole('listitem');
expect(todos).toHaveLength(1);
[firstTodo] = todos;
expect(firstTodo).toHaveTextContent('Learn Kibana');
// apply "all" filter
todoFilters = screen.getByRole('group', { name: 'Todo filters' });
const allFilter = within(todoFilters).getByTestId('all');
userEvent.click(allFilter);
// check all todos are shown
todos = await screen.findAllByRole('listitem');
expect(todos).toHaveLength(2);
[firstTodo, secondTodo] = todos;
// add new todo
const newTodoInput = screen.getByTestId('newTodo');
userEvent.type(newTodoInput, 'Learn React{enter}');
// wait for new todo to be added
await screen.findByText('Learn React');
todos = await screen.findAllByRole('listitem');
expect(todos).toHaveLength(3);
let newTodo = todos[2];
// mark new todo as completed
userEvent.click(within(newTodo).getByRole('checkbox'));
// apply "completed" filter again
todoFilters = screen.getByRole('group', { name: 'Todo filters' });
completedFilter = within(todoFilters).getByTestId('completed');
userEvent.click(completedFilter);
// check only completed todos are shown and a new todo is there
await screen.findByText('Learn React'); // wait for new todo to be there
todos = await screen.findAllByRole('listitem');
expect(todos).toHaveLength(2);
[firstTodo, newTodo] = todos;
expect(newTodo).toHaveTextContent('Learn React');
// remove new todo
userEvent.click(within(newTodo).getByLabelText('Delete'));
// wait for new todo to be removed
await waitForElementToBeRemoved(() => screen.getByText('Learn React'));
});

View file

@ -0,0 +1,35 @@
/*
* 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 * as React from 'react';
import { Todos } from './todos';
import { ContentClientProvider, ContentClient } from '../../public/content_client';
import { TodosClient } from './todos_client';
export default {
title: 'Content Management/Demo/Todo',
description: 'A demo todo app that uses content management',
parameters: {},
};
const todosClient = new TodosClient();
const contentClient = new ContentClient((contentType: string) => {
switch (contentType) {
case 'todos':
return todosClient;
default:
throw new Error(`Unknown content type: ${contentType}`);
}
});
export const SimpleTodoApp = () => (
<ContentClientProvider contentClient={contentClient}>
<Todos />
</ContentClientProvider>
);

View file

@ -0,0 +1,133 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import 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';
const useCreateTodoMutation = () => useCreateContentMutation<TodoCreateIn, Todo>();
const useDeleteTodoMutation = () => useDeleteContentMutation<TodoDeleteIn, void>();
const useUpdateTodoMutation = () => useUpdateContentMutation<TodoUpdateIn, Todo>();
const useSearchTodosQuery = ({ filter }: { filter: TodoSearchIn['query']['filter'] }) =>
useSearchContentQuery<TodoSearchIn, { hits: Todo[] }>({
contentTypeId: 'todos',
query: { filter },
});
type TodoFilter = 'all' | 'completed' | 'todo';
const filters = [
{
id: `all`,
label: 'All',
},
{
id: `completed`,
label: 'Completed',
},
{
id: `todo`,
label: 'Todo',
},
];
export const Todos = () => {
const [filterIdSelected, setFilterIdSelected] = React.useState<TodoFilter>('all');
const { data, isLoading, isError, error } = useSearchTodosQuery({
filter: filterIdSelected === 'all' ? undefined : filterIdSelected,
});
const createTodoMutation = useCreateTodoMutation();
const deleteTodoMutation = useDeleteTodoMutation();
const updateTodoMutation = useUpdateTodoMutation();
if (isLoading) return <p>Loading...</p>;
if (isError) return <p>Error: {error}</p>;
return (
<>
<EuiButtonGroup
legend="Todo filters"
options={filters}
idSelected={filterIdSelected}
onChange={(id) => {
setFilterIdSelected(id as TodoFilter);
}}
/>
<EuiSpacer />
<ul>
{data.hits.map((todo: Todo) => (
<React.Fragment key={todo.id}>
<li style={{ display: 'flex', alignItems: 'center' }}>
<EuiCheckbox
id={todo.id + ''}
key={todo.id}
checked={todo.completed}
onChange={(e) => {
updateTodoMutation.mutate({
contentTypeId: 'todos',
id: todo.id,
data: {
completed: e.target.checked,
},
});
}}
label={todo.title}
data-test-subj={`todoCheckbox-${todo.id}`}
/>
<EuiButtonIcon
style={{ marginLeft: '8px' }}
display="base"
iconType="trash"
aria-label="Delete"
color="danger"
onClick={() => {
deleteTodoMutation.mutate({ contentTypeId: 'todos', id: todo.id });
}}
/>
</li>
<EuiSpacer size={'xs'} />
</React.Fragment>
))}
</ul>
<EuiSpacer />
<form
onSubmit={(e) => {
const inputRef = (e.target as HTMLFormElement).elements.namedItem(
'newTodo'
) as HTMLInputElement;
if (!inputRef || !inputRef.value) return;
createTodoMutation.mutate({
contentTypeId: 'todos',
data: {
title: inputRef.value,
},
});
inputRef.value = '';
e.preventDefault();
}}
>
<EuiFieldText
placeholder="Type your todo and press enter to submit"
name={'newTodo'}
data-test-subj={'newTodo'}
/>
</form>
</>
);
};

View file

@ -0,0 +1,64 @@
/*
* 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 { 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' }>;
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> {
const todo = {
id: uuidv4(),
title: input.data.title,
completed: false,
};
this.todos.push(todo);
return todo;
}
async delete(input: TodoDeleteIn): Promise<void> {
this.todos = this.todos.filter((todo) => todo.id !== input.id);
}
async get(input: TodoGetIn): Promise<Todo> {
return this.todos.find((todo) => todo.id === input.id)!;
}
async search(input: TodoSearchIn): Promise<{ hits: Todo[] }> {
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> {
const idToUpdate = input.id;
const todoToUpdate = this.todos.find((todo) => todo.id === idToUpdate)!;
if (todoToUpdate) {
Object.assign(todoToUpdate, input.data);
}
return { ...todoToUpdate };
}
}

View file

@ -11,13 +11,13 @@ import { createQueryObservable } from './query_observable';
import type { CrudClient } from '../crud_client';
import type { CreateIn, GetIn, UpdateIn, DeleteIn, SearchIn } from '../../common';
const queryKeyBuilder = {
export const queryKeyBuilder = {
all: (type: string) => [type] as const,
item: (type: string, id: string) => {
return [...queryKeyBuilder.all(type), id] as const;
},
search: (type: string, params: unknown) => {
return [...queryKeyBuilder.all(type), 'search', params] as const;
search: (type: string, query: unknown) => {
return [...queryKeyBuilder.all(type), 'search', query] as const;
},
};
@ -30,13 +30,13 @@ const createQueryOptionBuilder = ({
get: <I extends GetIn = GetIn, O = unknown>(input: I) => {
return {
queryKey: queryKeyBuilder.item(input.contentTypeId, input.id),
queryFn: () => crudClientProvider(input.contentTypeId).get<I, O>(input),
queryFn: () => crudClientProvider(input.contentTypeId).get(input) as Promise<O>,
};
},
search: <I extends SearchIn = SearchIn, O = unknown>(input: I) => {
return {
queryKey: queryKeyBuilder.search(input.contentTypeId, input.query),
queryFn: () => crudClientProvider(input.contentTypeId).search<I, O>(input),
queryFn: () => crudClientProvider(input.contentTypeId).search(input) as Promise<O>,
};
},
};
@ -62,19 +62,19 @@ export class ContentClient {
}
create<I extends CreateIn, O = unknown>(input: I): Promise<O> {
return this.crudClientProvider(input.contentTypeId).create(input);
return this.crudClientProvider(input.contentTypeId).create(input) as Promise<O>;
}
update<I extends UpdateIn, O = unknown>(input: I): Promise<O> {
return this.crudClientProvider(input.contentTypeId).update(input);
return this.crudClientProvider(input.contentTypeId).update(input) as Promise<O>;
}
delete<I extends DeleteIn, O = unknown>(input: I): Promise<O> {
return this.crudClientProvider(input.contentTypeId).delete(input);
return this.crudClientProvider(input.contentTypeId).delete(input) as Promise<O>;
}
search<I extends SearchIn, O = unknown>(input: I): Promise<O> {
return this.crudClientProvider(input.contentTypeId).search(input);
return this.crudClientProvider(input.contentTypeId).search(input) as Promise<O>;
}
search$<I extends SearchIn, O = unknown>(input: I) {

View file

@ -9,12 +9,18 @@
import { useMutation } from '@tanstack/react-query';
import { useContentClient } from './content_client_context';
import type { CreateIn, UpdateIn, DeleteIn } from '../../common';
import { queryKeyBuilder } from './content_client';
export const useCreateContentMutation = <I extends CreateIn = CreateIn, O = unknown>() => {
const contentClient = useContentClient();
return useMutation({
mutationFn: (input: I) => {
return contentClient.create(input);
return contentClient.create<I, O>(input);
},
onSuccess: (data, variables) => {
contentClient.queryClient.invalidateQueries({
queryKey: queryKeyBuilder.all(variables.contentTypeId),
});
},
});
};
@ -23,7 +29,12 @@ export const useUpdateContentMutation = <I extends UpdateIn = UpdateIn, O = unkn
const contentClient = useContentClient();
return useMutation({
mutationFn: (input: I) => {
return contentClient.update(input);
return contentClient.update<I, O>(input);
},
onSuccess: (data, variables) => {
contentClient.queryClient.invalidateQueries({
queryKey: queryKeyBuilder.all(variables.contentTypeId),
});
},
});
};
@ -32,7 +43,12 @@ export const useDeleteContentMutation = <I extends DeleteIn = DeleteIn, O = unkn
const contentClient = useContentClient();
return useMutation({
mutationFn: (input: I) => {
return contentClient.delete(input);
return contentClient.delete<I, O>(input);
},
onSuccess: (data, variables) => {
contentClient.queryClient.invalidateQueries({
queryKey: queryKeyBuilder.all(variables.contentTypeId),
});
},
});
};

View file

@ -9,9 +9,9 @@
import type { GetIn, CreateIn, UpdateIn, DeleteIn, SearchIn } from '../../common';
export interface CrudClient {
get<I extends GetIn = GetIn, O = unknown>(input: I): Promise<O>;
create<I extends CreateIn = CreateIn, O = unknown>(input: I): Promise<O>;
update<I extends UpdateIn = UpdateIn, O = unknown>(input: I): Promise<O>;
delete<I extends DeleteIn = DeleteIn, O = unknown>(input: I): Promise<O>;
search<I extends SearchIn = SearchIn, O = unknown>(input: I): Promise<O>;
get(input: GetIn): Promise<unknown>;
create(input: CreateIn): Promise<unknown>;
update(input: UpdateIn): Promise<unknown>;
delete(input: DeleteIn): Promise<unknown>;
search(input: SearchIn): Promise<unknown>;
}

View file

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