mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 17:59:23 -04:00
[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:
parent
f7f29d758b
commit
355f77b752
11 changed files with 367 additions and 18 deletions
|
@ -19,6 +19,7 @@ const STORYBOOKS = [
|
|||
'cloud_chat',
|
||||
'coloring',
|
||||
'chart_icons',
|
||||
'content_management_plugin',
|
||||
'controls',
|
||||
'custom_integrations',
|
||||
'dashboard_enhanced',
|
||||
|
|
|
@ -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',
|
||||
|
|
9
src/plugins/content_management/.storybook/main.js
Normal file
9
src/plugins/content_management/.storybook/main.js
Normal 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;
|
|
@ -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'));
|
||||
});
|
35
src/plugins/content_management/demo/todo/todo.stories.tsx
Normal file
35
src/plugins/content_management/demo/todo/todo.stories.tsx
Normal 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>
|
||||
);
|
133
src/plugins/content_management/demo/todo/todos.tsx
Normal file
133
src/plugins/content_management/demo/todo/todos.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
64
src/plugins/content_management/demo/todo/todos_client.ts
Normal file
64
src/plugins/content_management/demo/todo/todos_client.ts
Normal 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 };
|
||||
}
|
||||
}
|
|
@ -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) {
|
||||
|
|
|
@ -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),
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue