diff --git a/src/plugins/content_management/public/content_client/content_client.tsx b/src/plugins/content_management/public/content_client/content_client.tsx new file mode 100644 index 0000000000000..5d2f7f7bfa5b6 --- /dev/null +++ b/src/plugins/content_management/public/content_client/content_client.tsx @@ -0,0 +1,199 @@ +/* + * 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 { QueryClient, QueryClientProvider, useMutation, useQuery } from '@tanstack/react-query'; +import React from 'react'; +import type { RpcClient } from '../rpc'; +import { createQueryObservable } from './query_observable'; +import type { SearchIn } from '../../common'; +import { CreateIn, CreateOut } from '../../common'; + +const contentQueryKeyBuilder = { + all: (type?: string) => [type ? type : '*'] as const, + // lists: (type: string) => [...contentQueryKeyBuilder.all(type), 'list'] as const, + // list: (type: string, filters: any) => + // [...contentQueryKeyBuilder.lists(type), { filters }] as const, + item: (type: string, id: string) => { + return [...contentQueryKeyBuilder.all(type), id] as const; + }, + itemPreview: (type: string, id: string) => { + return [...contentQueryKeyBuilder.item(type, id), 'preview'] as const; + }, + search: (searchParams: SearchIn = {}) => { + const { type, ...otherParams } = searchParams; + return [...contentQueryKeyBuilder.all(type), 'search', otherParams] as const; + }, +}; + +const createContentQueryOptionBuilder = ({ rpcClient }: { rpcClient: RpcClient }) => { + return { + get: (type: string, id: string) => { + return { + queryKey: contentQueryKeyBuilder.item(type, id), + queryFn: () => rpcClient.get({ type, id }), + }; + }, + getPreview: (type: string, id: string) => { + return { + queryKey: contentQueryKeyBuilder.itemPreview(type, id), + queryFn: () => rpcClient.getPreview({ type, id }), + }; + }, + search: (searchParams: SearchIn) => { + return { + queryKey: contentQueryKeyBuilder.search(searchParams), + queryFn: () => rpcClient.search(searchParams), + }; + }, + }; +}; + +export class ContentClient { + private readonly queryClient: QueryClient; + private readonly contentQueryOptionBuilder: ReturnType; + + public get _queryClient() { + return this.queryClient; + } + + public get _contentItemQueryOptionBuilder() { + return this.contentQueryOptionBuilder; + } + + constructor(private readonly rpcClient: RpcClient) { + this.queryClient = new QueryClient(); + this.contentQueryOptionBuilder = createContentQueryOptionBuilder({ + rpcClient: this.rpcClient, + }); + } + + get({ + type, + id, + }: { + type: string; + id: string; + }): Promise { + return this.queryClient.fetchQuery(this.contentQueryOptionBuilder.get(type, id)); + } + + get$({ type, id }: { type: string; id: string }) { + return createQueryObservable( + this.queryClient, + this.contentQueryOptionBuilder.get(type, id) + ); + } + + getPreview({ type, id }: { type: string; id: string }) { + return this.queryClient.fetchQuery(this.contentQueryOptionBuilder.getPreview(type, id)); + } + + getPreview$({ type, id }: { type: string; id: string }) { + return createQueryObservable( + this.queryClient, + this.contentQueryOptionBuilder.getPreview(type, id) + ); + } + + search(searchParams: SearchIn) { + return this.queryClient.fetchQuery(this.contentQueryOptionBuilder.search(searchParams)); + } + + search$(searchParams: SearchIn) { + return createQueryObservable( + this.queryClient, + this.contentQueryOptionBuilder.search(searchParams) + ); + } + + create( + input: CreateIn + ): Promise { + return this.rpcClient.create(input); + } +} + +const ContentClientContext = React.createContext(null as unknown as ContentClient); + +export const useContentClient = (): ContentClient => { + const contentClient = React.useContext(ContentClientContext); + if (!contentClient) throw new Error('contentClient not found'); + return contentClient; +}; + +export const ContentClientProvider: React.FC<{ contentClient: ContentClient }> = ({ + contentClient, + children, +}) => { + return ( + + {children} + + ); +}; + +export const useContentItem = ( + { + id, + type, + }: { + id: string; + type: string; + }, + queryOptions: { enabled?: boolean } = { enabled: true } +) => { + const contentQueryClient = useContentClient(); + const query = useQuery({ + ...contentQueryClient._contentItemQueryOptionBuilder.get(type, id), + ...queryOptions, + }); + return query; +}; + +export const useContentItemPreview = ( + { + id, + type, + }: { + id: string; + type: string; + }, + queryOptions: { enabled?: boolean } = { enabled: true } +) => { + const contentQueryClient = useContentClient(); + const query = useQuery({ + ...contentQueryClient._contentItemQueryOptionBuilder.getPreview(type, id), + ...queryOptions, + }); + return query; +}; + +export const useContentSearch = ( + searchParams: SearchIn = {}, + queryOptions: { enabled?: boolean } = { enabled: true } +) => { + const contentQueryClient = useContentClient(); + const query = useQuery({ + ...contentQueryClient._contentItemQueryOptionBuilder.search(searchParams), + ...queryOptions, + }); + return query; +}; + +export const useCreateContentItemMutation = < + I extends object, + O extends CreateOut, + Options extends object | undefined = undefined +>() => { + const contentQueryClient = useContentClient(); + return useMutation({ + mutationFn: (input: CreateIn) => { + return contentQueryClient.create(input); + }, + }); +}; diff --git a/src/plugins/content_management/public/content_client/index.ts b/src/plugins/content_management/public/content_client/index.ts new file mode 100644 index 0000000000000..fe79d3b6c8123 --- /dev/null +++ b/src/plugins/content_management/public/content_client/index.ts @@ -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 './content_client'; diff --git a/src/plugins/content_management/public/content_client/query_observable.ts b/src/plugins/content_management/public/content_client/query_observable.ts new file mode 100644 index 0000000000000..276feb66dfb87 --- /dev/null +++ b/src/plugins/content_management/public/content_client/query_observable.ts @@ -0,0 +1,45 @@ +/* + * 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 { + notifyManager, + QueryObserver, + QueryObserverOptions, + QueryObserverResult, + QueryClient, +} from '@tanstack/react-query'; +import { Observable } from 'rxjs'; +import { QueryKey } from '@tanstack/query-core/src/types'; + +export const createQueryObservable = < + TQueryFnData = unknown, + TError = unknown, + TData = TQueryFnData, + TQueryData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey +>( + queryClient: QueryClient, + queryOptions: QueryObserverOptions +) => { + const queryObserver = new QueryObserver( + queryClient, + queryClient.defaultQueryOptions(queryOptions) + ); + + return new Observable>((subscriber) => { + const unsubscribe = queryObserver.subscribe( + // notifyManager is a singleton that batches updates across react query + notifyManager.batchCalls((result) => { + subscriber.next(result); + }) + ); + return () => { + unsubscribe(); + }; + }); +}; diff --git a/src/plugins/content_management/public/demo-app/components/content_details_section.tsx b/src/plugins/content_management/public/demo-app/components/content_details_section.tsx index 7c13920f5c7d2..b05dd171df25b 100644 --- a/src/plugins/content_management/public/demo-app/components/content_details_section.tsx +++ b/src/plugins/content_management/public/demo-app/components/content_details_section.tsx @@ -6,7 +6,6 @@ * Side Public License, v 1. */ import React, { FC, useState } from 'react'; -import useDebounce from 'react-use/lib/useDebounce'; import { EuiFieldText, EuiFlexGroup, @@ -17,30 +16,15 @@ import { } from '@elastic/eui'; import { EuiCodeEditor } from '@kbn/es-ui-shared-plugin/public'; -import { useApp } from '../context'; +import { useContentItem } from '../../content_client'; export const ContentDetailsSection: FC = () => { - const { rpc } = useApp(); - - const [contentType, setContentType] = useState('foo'); const [contentId, setContentId] = useState(''); - const [content, setContent] = useState>({}); - - const isIdEmpty = contentId.trim() === ''; - - useDebounce( - () => { - const load = async () => { - const res = await rpc.get({ type: contentType, id: contentId }); - setContent(res as Record); - }; + const [contentType, setContentType] = useState('foo'); - if (!isIdEmpty) { - load(); - } - }, - 500, - [rpc, contentType, contentId, isIdEmpty] + const { error, data } = useContentItem( + { type: contentType, id: contentId }, + { enabled: !!(contentType && contentId) } ); return ( @@ -73,7 +57,7 @@ export const ContentDetailsSection: FC = () => { = ({ type, id }) => { - const { rpc } = useApp(); - const [content, setContent] = useState(null); - const isIdEmpty = id.trim() === ''; + const isIdEmpty = !(type && id); - useDebounce( - () => { - const load = async () => { - const res = await rpc.getPreview({ type, id }); - setContent(res); - }; - - if (!isIdEmpty) { - load(); - } - }, - 500, - [rpc, type, id, isIdEmpty] - ); + const { isLoading, isError, data } = useContentItemPreview({ id, type }, { enabled: !isIdEmpty }); if (isIdEmpty) { return Provide an id to load the content; } - if (!content) { + if (isLoading) { return Loading...; } + if (isError) { + return Error; + } + return ( - {content.title} - {Boolean(content.description) && ( - {content.description} + {data.title} + {Boolean(data.description) && ( + {data.description} )}

- Type {content.type} + Type {data.type}

diff --git a/src/plugins/content_management/public/demo-app/components/create_content_section.tsx b/src/plugins/content_management/public/demo-app/components/create_content_section.tsx index f48bbadbff1e3..6cd71a889f6f4 100644 --- a/src/plugins/content_management/public/demo-app/components/create_content_section.tsx +++ b/src/plugins/content_management/public/demo-app/components/create_content_section.tsx @@ -21,22 +21,16 @@ import { EuiTitle, } from '@elastic/eui'; -import { CreateOut } from '../../../common'; -import { useApp } from '../context'; +import { useCreateContentItemMutation } from '../../content_client'; export const CreateContentSection: FC = () => { + const createContentMutation = useCreateContentItemMutation(); const [title, setContentType] = useState(''); - const [description, setContentId] = useState(''); - const [contentCreated, setContentCreated] = useState(null); - - const { rpc } = useApp(); + const [description, setContentDescription] = useState(''); const createContent = async () => { const content = { title, description }; - - setContentCreated(null); - const created = await rpc.create({ type: 'foo', data: content }); - setContentCreated(created as any); + createContentMutation.mutate({ type: 'foo', data: content }); }; return ( @@ -72,17 +66,17 @@ export const CreateContentSection: FC = () => { { - setContentId(e.currentTarget.value); + setContentDescription(e.currentTarget.value); }} fullWidth /> - {contentCreated !== null && ( + {createContentMutation.isSuccess && ( <> - {contentCreated.id} + {createContentMutation.data.id} diff --git a/src/plugins/content_management/public/demo-app/components/search_content_section.tsx b/src/plugins/content_management/public/demo-app/components/search_content_section.tsx index fad6665caae8e..5d78442b11175 100644 --- a/src/plugins/content_management/public/demo-app/components/search_content_section.tsx +++ b/src/plugins/content_management/public/demo-app/components/search_content_section.tsx @@ -6,27 +6,25 @@ * Side Public License, v 1. */ -import React, { FC, useEffect, useState, useCallback } from 'react'; -import { EuiButton, EuiInMemoryTable, EuiSpacer, EuiTitle } from '@elastic/eui'; - +import React, { FC } from 'react'; +import { + EuiButton, + EuiInMemoryTable, + EuiSpacer, + EuiTitle, + EuiBasicTableColumn, + EuiIcon, + EuiToolTip, +} from '@elastic/eui'; +import { useContentSearch } from '../../content_client'; import { Content } from '../../../common'; import { useApp } from '../context'; export const SearchContentSection: FC = () => { - const { rpc } = useApp(); - const [items, setItems] = useState([]); - const [isLoading, setIsLoading] = useState(false); - - const sendSearch = useCallback(async () => { - setIsLoading(true); - - const { hits } = await rpc.search(); - setItems(hits); + const { data, isLoading, isError, refetch } = useContentSearch(); + const { contentRegistry } = useApp(); - setIsLoading(false); - }, [rpc]); - - const columns = [ + const columns: Array> = [ { field: 'id', name: 'Id', @@ -38,6 +36,17 @@ export const SearchContentSection: FC = () => { name: 'Type', sortable: true, truncateText: false, + render: (type: string) => { + const contentType = contentRegistry.get(type); + if (!contentType) return type; + return ( + + <> + {contentType.name()} + + + ); + }, }, { field: 'title', @@ -64,7 +73,7 @@ export const SearchContentSection: FC = () => { { - sendSearch(); + refetch(); }} isDisabled={isLoading} > @@ -80,9 +89,13 @@ export const SearchContentSection: FC = () => { }, }; - useEffect(() => { - sendSearch(); - }, [sendSearch]); + if (isLoading) { + return Loading...; + } + + if (isError) { + return Error; + } return ( <> @@ -93,7 +106,7 @@ export const SearchContentSection: FC = () => { (null); diff --git a/src/plugins/content_management/public/demo-app/mount_app.tsx b/src/plugins/content_management/public/demo-app/mount_app.tsx index 666a8a48dd6f5..920c3d4f7fc1d 100755 --- a/src/plugins/content_management/public/demo-app/mount_app.tsx +++ b/src/plugins/content_management/public/demo-app/mount_app.tsx @@ -14,6 +14,7 @@ import type { ManagementAppMountParams } from '@kbn/management-plugin/public'; import { App } from './app'; import { ContextProvider, Context } from './context'; +import { ContentClientProvider } from '../content_client'; export const mountApp = ( coreStart: CoreStart, @@ -23,7 +24,9 @@ export const mountApp = ( ReactDOM.render( - + + + , element diff --git a/src/plugins/content_management/public/plugin.ts b/src/plugins/content_management/public/plugin.ts index a4d63d4c833fc..959e1abf61f59 100644 --- a/src/plugins/content_management/public/plugin.ts +++ b/src/plugins/content_management/public/plugin.ts @@ -11,7 +11,9 @@ import { ManagementAppMountParams, ManagementSetup } from '@kbn/management-plugi import { PLUGIN_ID } from '../common'; import { RpcClient } from './rpc'; import type { Context } from './demo-app'; -import { ContentManagementPublicStart } from './types'; +import { ContentManagementPublicStart, ContentManagementPublicSetup } from './types'; +import { ContentClient } from './content_client'; +import { ContentRegistry } from './registry'; interface SetupDependencies { management: ManagementSetup; @@ -19,14 +21,27 @@ interface SetupDependencies { export class ContentManagementPlugin implements Plugin { private rpcClient: RpcClient | undefined; + private contentClient: ContentClient | undefined; + private contentRegistry: ContentRegistry | undefined; - public setup(core: CoreSetup, { management }: SetupDependencies): void { + public setup(core: CoreSetup, { management }: SetupDependencies): ContentManagementPublicSetup { const httpClient = { post: core.http.post, }; const rpcClient = new RpcClient(httpClient); + const registry = new ContentRegistry(); + const contentClient = new ContentClient(rpcClient); + this.contentRegistry = registry; this.rpcClient = rpcClient; + this.contentClient = contentClient; + + this.contentRegistry.register({ + id: 'foo', + name: 'Foo', + description: 'Foo content', + icon: 'accessibility', + }); management.sections.section.kibana.registerApp({ id: PLUGIN_ID, @@ -41,10 +56,16 @@ export class ContentManagementPlugin implements Plugin { const [coreStart] = await core.getStartServices(); const ctx: Context = { rpc: rpcClient, + contentClient, + contentRegistry: registry, }; return mountApp(coreStart, ctx, params); }, }); + + return { + registry: this.contentRegistry, + }; } public start(): ContentManagementPublicStart { @@ -52,8 +73,17 @@ export class ContentManagementPlugin implements Plugin { throw new Error('Rcp client has not been initialized'); } + if (!this.contentClient) { + throw new Error('ContentQueryClient has not been initialized'); + } + + if (!this.contentRegistry) { + throw new Error('ContentRegistry has not been initialized'); + } + return { - rpc: this.rpcClient, + client: this.contentClient, + registry: this.contentRegistry, }; } } diff --git a/src/plugins/content_management/public/registry/content_type.ts b/src/plugins/content_management/public/registry/content_type.ts new file mode 100644 index 0000000000000..fbe47cb77d061 --- /dev/null +++ b/src/plugins/content_management/public/registry/content_type.ts @@ -0,0 +1,33 @@ +/* + * 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 { ContentTypeDetails, ContentTypeKind } from './types'; + +export class ContentType { + constructor(public readonly details: ContentTypeDetails) {} + + id(): string { + return this.details.id; + } + + name(): string { + return this.details.name || this.id(); + } + + description(): string { + return this.details.description || ''; + } + + kind(): ContentTypeKind { + return this.details.kind || 'other'; + } + + icon(): string { + return this.details.icon || 'questionInCircle'; + } +} diff --git a/src/plugins/content_management/public/registry/index.ts b/src/plugins/content_management/public/registry/index.ts new file mode 100644 index 0000000000000..86e7bf497f506 --- /dev/null +++ b/src/plugins/content_management/public/registry/index.ts @@ -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. + */ + +export * from './types'; +export * from './content_type'; +export * from './registry'; diff --git a/src/plugins/content_management/public/registry/registry.ts b/src/plugins/content_management/public/registry/registry.ts new file mode 100644 index 0000000000000..3b67b82e56ca9 --- /dev/null +++ b/src/plugins/content_management/public/registry/registry.ts @@ -0,0 +1,27 @@ +/* + * 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 { ContentTypeDetails } from './types'; +import { ContentType } from './content_type'; + +export class ContentRegistry { + private readonly types: Map = new Map(); + + public register(details: ContentTypeDetails) { + const type = new ContentType(details); + this.types.set(type.id(), type); + } + + public get(id: string): ContentType | undefined { + return this.types.get(id); + } + + public getAll(): ContentType[] { + return Array.from(this.types.values()); + } +} diff --git a/src/plugins/content_management/public/registry/types.ts b/src/plugins/content_management/public/registry/types.ts new file mode 100644 index 0000000000000..7c2e5bd12d887 --- /dev/null +++ b/src/plugins/content_management/public/registry/types.ts @@ -0,0 +1,45 @@ +/* + * 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. + */ + +/** + * Content type definition as it is registered in the content registry. + */ +export interface ContentTypeDetails { + /** + * ID of the type. Must be unique. Like "dashboard", "visualization", etc. + */ + id: string; + + /** + * Human-readable name of the type. Like "Dashboard", "Visualization", etc. + */ + name?: string; + + /** + * Human-readable description of the type. + */ + description?: string; + + /** + * Icon to use for this type. Usually an EUI icon type. + * + * @see https://elastic.github.io/eui/#/display/icons + */ + icon?: string; + + /** + * Specifies item type. Defaults to 'other'. + */ + kind?: ContentTypeKind; +} + +/** + * Specifies whether this type represents user-like items (like user profiles) + * or something else (like dashboards). Defaults to 'other'. + */ +export type ContentTypeKind = 'user' | 'other'; diff --git a/src/plugins/content_management/public/types.ts b/src/plugins/content_management/public/types.ts index ead2ca3303041..86a3b50dc854e 100644 --- a/src/plugins/content_management/public/types.ts +++ b/src/plugins/content_management/public/types.ts @@ -6,8 +6,14 @@ * Side Public License, v 1. */ -import { RpcClient } from './rpc'; +import { ContentClient } from './content_client'; +import { ContentRegistry } from './registry'; + +export interface ContentManagementPublicSetup { + registry: ContentRegistry; +} export interface ContentManagementPublicStart { - rpc: RpcClient; + client: ContentClient; + registry: ContentRegistry; } diff --git a/x-pack/plugins/maps/public/map_attribute_service.ts b/x-pack/plugins/maps/public/map_attribute_service.ts index c31ed93d4e71b..65c531355feea 100644 --- a/x-pack/plugins/maps/public/map_attribute_service.ts +++ b/x-pack/plugins/maps/public/map_attribute_service.ts @@ -75,7 +75,7 @@ export function getMapAttributeService(): MapAttributeService { updatedAttributes, { references } ) - : getContentManagement().rpc.create< + : getContentManagement().client.create< MapSavedObjectAttributes, any, // We probably want to type the response SavedObjectsCreateOptions diff --git a/x-pack/plugins/maps/public/plugin.ts b/x-pack/plugins/maps/public/plugin.ts index 6d5d91fe36a52..ead521da11e54 100644 --- a/x-pack/plugins/maps/public/plugin.ts +++ b/x-pack/plugins/maps/public/plugin.ts @@ -42,7 +42,10 @@ import type { SpacesPluginStart } from '@kbn/spaces-plugin/public'; import type { CloudSetup } from '@kbn/cloud-plugin/public'; import type { LensPublicSetup } from '@kbn/lens-plugin/public'; import { ScreenshotModePluginSetup } from '@kbn/screenshot-mode-plugin/public'; -import type { ContentManagementPublicStart } from '@kbn/content-management-plugin/public'; +import type { + ContentManagementPublicStart, + ContentManagementPublicSetup, +} from '@kbn/content-management-plugin/public'; import { createRegionMapFn, regionMapRenderer, @@ -92,6 +95,7 @@ export interface MapsPluginSetupDependencies { licensing: LicensingPluginSetup; usageCollection?: UsageCollectionSetup; screenshotMode?: ScreenshotModePluginSetup; + contentManagement: ContentManagementPublicSetup; } export interface MapsPluginStartDependencies { @@ -212,6 +216,13 @@ export class MapsPlugin setIsCloudEnabled(!!plugins.cloud?.isCloudEnabled); + plugins.contentManagement.registry.register({ + id: 'map', + title: 'Map', + description: 'Map visualization', + icon: 'mapMarker', + }); + return { registerLayerWizard: registerLayerWizardExternal, registerSource, diff --git a/x-pack/plugins/maps/tsconfig.json b/x-pack/plugins/maps/tsconfig.json index 1ca7825e79c7b..7a0aac9e04c07 100644 --- a/x-pack/plugins/maps/tsconfig.json +++ b/x-pack/plugins/maps/tsconfig.json @@ -66,6 +66,7 @@ "@kbn/controls-plugin", "@kbn/content-management-plugin", "@kbn/core-http-request-handler-context-server", + "@kbn/content-management-plugin", ], "exclude": [ "target/**/*",