diff --git a/src/plugins/content_management/common/index.ts b/src/plugins/content_management/common/index.ts index bc5dde9968d6d..3746f26a2cf3d 100644 --- a/src/plugins/content_management/common/index.ts +++ b/src/plugins/content_management/common/index.ts @@ -7,3 +7,5 @@ */ export { PLUGIN_ID, API_ENDPOINT } from './constants'; +export type { ProcedureSchemas, ProcedureName, GetIn, CreateIn } from './rpc'; +export { procedureNames, schemas as rpcSchemas } from './rpc'; diff --git a/src/plugins/content_management/common/rpc.ts b/src/plugins/content_management/common/rpc.ts new file mode 100644 index 0000000000000..8e03dc886a3f0 --- /dev/null +++ b/src/plugins/content_management/common/rpc.ts @@ -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 { schema, Type } from '@kbn/config-schema'; + +export interface ProcedureSchemas { + in?: Type | false; + out?: Type | false; +} + +export const procedureNames = ['get', 'create'] as const; + +export type ProcedureName = typeof procedureNames[number]; + +// --------------------------------- +// API +// --------------------------------- + +// ------- GET -------- +const getSchemas: ProcedureSchemas = { + in: schema.object( + { + contentType: schema.string(), + id: schema.string(), + options: schema.maybe(schema.object({}, { unknowns: 'allow' })), + }, + { unknowns: 'forbid' } + ), + // --> "out" will be specified by each storage layer + out: schema.maybe(schema.object({}, { unknowns: 'allow' })), +}; + +export interface GetIn { + id: string; + contentType: string; + options?: Options; +} + +// -- Create content +const createSchemas: ProcedureSchemas = { + in: schema.object( + { + contentType: schema.string(), + data: schema.object({}, { unknowns: 'allow' }), + options: schema.maybe(schema.object({}, { unknowns: 'allow' })), + }, + { unknowns: 'forbid' } + ), + // Here we could enforce that an "id" field is returned + out: schema.maybe(schema.object({}, { unknowns: 'allow' })), +}; + +export interface CreateIn< + T extends string = string, + Data extends object = Record, + Options extends object = any +> { + contentType: T; + data: Data; + options?: Options; +} + +export const schemas: { + [key in ProcedureName]: ProcedureSchemas; +} = { + get: getSchemas, + create: createSchemas, +}; diff --git a/src/plugins/content_management/public/content_client/content_client.test.ts b/src/plugins/content_management/public/content_client/content_client.test.ts new file mode 100644 index 0000000000000..74be9a7c6aecf --- /dev/null +++ b/src/plugins/content_management/public/content_client/content_client.test.ts @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { lastValueFrom } from 'rxjs'; +import { takeWhile, toArray } from 'rxjs/operators'; +import type { RpcClient } from '../rpc_client'; +import { createRpcClientMock } from '../rpc_client/rpc_client.mock'; +import { ContentClient } from './content_client'; +import type { GetIn, CreateIn } from '../../common'; + +let contentClient: ContentClient; +let rpcClient: jest.Mocked; +beforeEach(() => { + rpcClient = createRpcClientMock(); + contentClient = new ContentClient(rpcClient); +}); + +describe('#get', () => { + it('calls rpcClient.get with input and returns output', async () => { + const input: GetIn = { id: 'test', contentType: 'testType' }; + const output = { test: 'test' }; + rpcClient.get.mockResolvedValueOnce(output); + expect(await contentClient.get(input)).toEqual(output); + expect(rpcClient.get).toBeCalledWith(input); + }); + + it('calls rpcClient.get$ with input and returns output', async () => { + const input: GetIn = { id: 'test', contentType: 'testType' }; + const output = { test: 'test' }; + rpcClient.get.mockResolvedValueOnce(output); + const get$ = contentClient.get$(input).pipe( + takeWhile((result) => { + return result.data == null; + }, true), + toArray() + ); + + const [loadingState, loadedState] = await lastValueFrom(get$); + + expect(loadingState.isLoading).toBe(true); + expect(loadingState.data).toBeUndefined(); + + expect(loadedState.isLoading).toBe(false); + expect(loadedState.data).toEqual(output); + }); +}); + +describe('#create', () => { + it('calls rpcClient.create with input and returns output', async () => { + const input: CreateIn = { contentType: 'testType', data: { foo: 'bar' } }; + const output = { test: 'test' }; + rpcClient.create.mockImplementation(() => Promise.resolve(output)); + + expect(await contentClient.create(input)).toEqual(output); + expect(rpcClient.create).toBeCalledWith(input); + }); +}); 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..72c9ea9f88929 --- /dev/null +++ b/src/plugins/content_management/public/content_client/content_client.tsx @@ -0,0 +1,54 @@ +/* + * 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 } from '@tanstack/react-query'; +import { createQueryObservable } from './query_observable'; +import type { RpcClient } from '../rpc_client'; +import type { CreateIn, GetIn } from '../../common'; + +const queryKeyBuilder = { + all: (type: string) => [type] as const, + item: (type: string, id: string) => { + return [...queryKeyBuilder.all(type), id] as const; + }, +}; + +const createQueryOptionBuilder = ({ rpcClient }: { rpcClient: RpcClient }) => { + return { + get: (input: I) => { + return { + queryKey: queryKeyBuilder.item(input.contentType, input.id), + queryFn: () => rpcClient.get(input), + }; + }, + }; +}; + +export class ContentClient { + readonly queryClient: QueryClient; + readonly queryOptionBuilder: ReturnType; + + constructor(private readonly rpcClient: RpcClient) { + this.queryClient = new QueryClient(); + this.queryOptionBuilder = createQueryOptionBuilder({ + rpcClient: this.rpcClient, + }); + } + + get(input: I): Promise { + return this.queryClient.fetchQuery(this.queryOptionBuilder.get(input)); + } + + get$(input: I) { + return createQueryObservable(this.queryClient, this.queryOptionBuilder.get(input)); + } + + create(input: I): Promise { + return this.rpcClient.create(input); + } +} diff --git a/src/plugins/content_management/public/content_client/content_client_context.tsx b/src/plugins/content_management/public/content_client/content_client_context.tsx new file mode 100644 index 0000000000000..0685c6acf74ed --- /dev/null +++ b/src/plugins/content_management/public/content_client/content_client_context.tsx @@ -0,0 +1,30 @@ +/* + * 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 { QueryClientProvider } from '@tanstack/react-query'; +import type { ContentClient } from './content_client'; + +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} + + ); +}; diff --git a/src/plugins/content_management/public/content_client/content_client_mutation_hooks.test.tsx b/src/plugins/content_management/public/content_client/content_client_mutation_hooks.test.tsx new file mode 100644 index 0000000000000..a7b9d4a5dfc7a --- /dev/null +++ b/src/plugins/content_management/public/content_client/content_client_mutation_hooks.test.tsx @@ -0,0 +1,41 @@ +/* + * 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 { renderHook } from '@testing-library/react-hooks'; +import { ContentClientProvider } from './content_client_context'; +import { ContentClient } from './content_client'; +import { RpcClient } from '../rpc_client'; +import { createRpcClientMock } from '../rpc_client/rpc_client.mock'; +import { useCreateContentMutation } from './content_client_mutation_hooks'; +import type { CreateIn } from '../../common'; + +let contentClient: ContentClient; +let rpcClient: jest.Mocked; +beforeEach(() => { + rpcClient = createRpcClientMock(); + contentClient = new ContentClient(rpcClient); +}); + +const Wrapper: React.FC = ({ children }) => ( + {children} +); + +describe('useCreateContentMutation', () => { + test('should call rpcClient.create with input and resolve with output', async () => { + const input: CreateIn = { contentType: 'testType', data: { foo: 'bar' } }; + const output = { test: 'test' }; + rpcClient.create.mockImplementation(() => Promise.resolve(output)); + const { result, waitFor } = renderHook(() => useCreateContentMutation(), { wrapper: Wrapper }); + result.current.mutate(input); + + await waitFor(() => result.current.isSuccess); + + expect(result.current.data).toEqual(output); + }); +}); diff --git a/src/plugins/content_management/public/content_client/content_client_mutation_hooks.tsx b/src/plugins/content_management/public/content_client/content_client_mutation_hooks.tsx new file mode 100644 index 0000000000000..372b05b2ed093 --- /dev/null +++ b/src/plugins/content_management/public/content_client/content_client_mutation_hooks.tsx @@ -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 { useMutation } from '@tanstack/react-query'; +import { useContentClient } from './content_client_context'; +import type { CreateIn } from '../../common'; + +export const useCreateContentMutation = () => { + const contentClient = useContentClient(); + return useMutation({ + mutationFn: (input: I) => { + return contentClient.create(input); + }, + }); +}; diff --git a/src/plugins/content_management/public/content_client/content_client_query_hooks.test.tsx b/src/plugins/content_management/public/content_client/content_client_query_hooks.test.tsx new file mode 100644 index 0000000000000..07a22bb5154d5 --- /dev/null +++ b/src/plugins/content_management/public/content_client/content_client_query_hooks.test.tsx @@ -0,0 +1,38 @@ +/* + * 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 { renderHook } from '@testing-library/react-hooks'; +import { ContentClientProvider } from './content_client_context'; +import { ContentClient } from './content_client'; +import { RpcClient } from '../rpc_client'; +import { createRpcClientMock } from '../rpc_client/rpc_client.mock'; +import { useGetContentQuery } from './content_client_query_hooks'; +import type { GetIn } from '../../common'; + +let contentClient: ContentClient; +let rpcClient: jest.Mocked; +beforeEach(() => { + rpcClient = createRpcClientMock(); + contentClient = new ContentClient(rpcClient); +}); + +const Wrapper: React.FC = ({ children }) => ( + {children} +); + +describe('useGetContentQuery', () => { + test('should call rpcClient.get with input and resolve with output', async () => { + const input: GetIn = { id: 'test', contentType: 'testType' }; + const output = { test: 'test' }; + rpcClient.get.mockImplementation(() => Promise.resolve(output)); + const { result, waitFor } = renderHook(() => useGetContentQuery(input), { wrapper: Wrapper }); + await waitFor(() => result.current.isSuccess); + expect(result.current.data).toEqual(output); + }); +}); diff --git a/src/plugins/content_management/public/content_client/content_client_query_hooks.tsx b/src/plugins/content_management/public/content_client/content_client_query_hooks.tsx new file mode 100644 index 0000000000000..09ee31bdd14f3 --- /dev/null +++ b/src/plugins/content_management/public/content_client/content_client_query_hooks.tsx @@ -0,0 +1,32 @@ +/* + * 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 { useQuery, QueryObserverOptions } from '@tanstack/react-query'; +import { useContentClient } from './content_client_context'; +import type { GetIn } from '../../common'; + +/** + * Exposed `useQuery` options + */ +export type QueryOptions = Pick; + +/** + * + * @param input - get content identifier like "id" and "contentType" + * @param queryOptions - + */ +export const useGetContentQuery = ( + input: I, + queryOptions?: QueryOptions +) => { + const contentClient = useContentClient(); + return useQuery({ + ...contentClient.queryOptionBuilder.get(input), + ...queryOptions, + }); +}; 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..329df9c596452 --- /dev/null +++ b/src/plugins/content_management/public/content_client/index.ts @@ -0,0 +1,12 @@ +/* + * 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 { ContentClient } from './content_client'; +export { ContentClientProvider, useContentClient } from './content_client_context'; +export { useGetContentQuery } from './content_client_query_hooks'; +export { useCreateContentMutation } from './content_client_mutation_hooks'; 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..f486befb87d5e --- /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, + QueryKey, +} from '@tanstack/react-query'; +import { Observable } from 'rxjs'; + +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/plugin.ts b/src/plugins/content_management/public/plugin.ts index 1650d95955d44..77d0bcc6039bc 100644 --- a/src/plugins/content_management/public/plugin.ts +++ b/src/plugins/content_management/public/plugin.ts @@ -6,21 +6,33 @@ * Side Public License, v 1. */ -import type { CoreSetup, Plugin } from '@kbn/core/public'; +import type { CoreSetup, CoreStart, Plugin } from '@kbn/core/public'; import { ContentManagementPublicStart, ContentManagementPublicSetup, SetupDependencies, + StartDependencies, } from './types'; +import type { ContentClient } from './content_client'; export class ContentManagementPlugin - implements Plugin + implements + Plugin< + ContentManagementPublicSetup, + ContentManagementPublicStart, + SetupDependencies, + StartDependencies + > { public setup(core: CoreSetup, deps: SetupDependencies) { return {}; } - public start() { - return {}; + public start(core: CoreStart, deps: StartDependencies) { + // don't actually expose the client until it is used to avoid increasing bundle size + // const rpcClient = new RpcClient(core.http); + // const contentClient = new ContentClient(rpcClient); + // return { client: contentClient }; + return { client: {} as ContentClient }; } } diff --git a/src/plugins/content_management/public/rpc_client/index.ts b/src/plugins/content_management/public/rpc_client/index.ts new file mode 100644 index 0000000000000..da7d96888124b --- /dev/null +++ b/src/plugins/content_management/public/rpc_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 { RpcClient } from './rpc_client'; diff --git a/src/plugins/content_management/public/rpc_client/rpc_client.mock.ts b/src/plugins/content_management/public/rpc_client/rpc_client.mock.ts new file mode 100644 index 0000000000000..ad8cd384dd0e5 --- /dev/null +++ b/src/plugins/content_management/public/rpc_client/rpc_client.mock.ts @@ -0,0 +1,18 @@ +/* + * 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 { RpcClient } from './rpc_client'; +import { PublicMethodsOf } from '@kbn/utility-types'; + +export const createRpcClientMock = (): jest.Mocked => { + const mock: jest.Mocked> = { + get: jest.fn((input) => Promise.resolve({} as any)), + create: jest.fn((input) => Promise.resolve({} as any)), + }; + return mock as jest.Mocked; +}; diff --git a/src/plugins/content_management/public/rpc_client/rpc_client.ts b/src/plugins/content_management/public/rpc_client/rpc_client.ts new file mode 100644 index 0000000000000..1cfb2b12f02a5 --- /dev/null +++ b/src/plugins/content_management/public/rpc_client/rpc_client.ts @@ -0,0 +1,30 @@ +/* + * 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 { HttpSetup } from '@kbn/core/public'; +import { API_ENDPOINT } from '../../common'; +import type { GetIn, CreateIn, ProcedureName } from '../../common'; + +export class RpcClient { + constructor(private http: { post: HttpSetup['post'] }) {} + + public get(input: I): Promise { + return this.sendMessage('get', input); + } + + public create(input: I): Promise { + return this.sendMessage('create', input); + } + + private sendMessage = async (name: ProcedureName, input: any): Promise => { + const { result } = await this.http.post<{ result: any }>(`${API_ENDPOINT}/${name}`, { + body: JSON.stringify(input), + }); + return result; + }; +} diff --git a/src/plugins/content_management/public/types.ts b/src/plugins/content_management/public/types.ts index e06d6e48bba40..dcee504cd8d6b 100644 --- a/src/plugins/content_management/public/types.ts +++ b/src/plugins/content_management/public/types.ts @@ -6,11 +6,17 @@ * Side Public License, v 1. */ +import type { ContentClient } from './content_client'; + // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface SetupDependencies {} // eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface ContentManagementPublicSetup {} +export interface StartDependencies {} // eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface ContentManagementPublicStart {} +export interface ContentManagementPublicSetup {} + +export interface ContentManagementPublicStart { + client: ContentClient; +} diff --git a/src/plugins/content_management/tsconfig.json b/src/plugins/content_management/tsconfig.json index d0ad9f31d30c0..0c998149f88b8 100644 --- a/src/plugins/content_management/tsconfig.json +++ b/src/plugins/content_management/tsconfig.json @@ -6,6 +6,8 @@ "include": ["common/**/*", "public/**/*", "server/**/*", ".storybook/**/*"], "kbn_references": [ "@kbn/core", + "@kbn/config-schema", + "@kbn/utility-types", ], "exclude": [ "target/**/*",