Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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 = {
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Can we maybe shorten this to queryKeyBuilder?

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: <TContentItem extends object = object>(type: string, id: string) => {
return {
queryKey: contentQueryKeyBuilder.item(type, id),
queryFn: () => rpcClient.get<TContentItem>({ 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<typeof createContentQueryOptionBuilder>;

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<ContentItem extends object = object>({
type,
id,
}: {
type: string;
id: string;
}): Promise<ContentItem> {
return this.queryClient.fetchQuery(this.contentQueryOptionBuilder.get<ContentItem>(type, id));
}

get$<ContentItem extends object = object>({ type, id }: { type: string; id: string }) {
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not sure I fully understand what those observable version of the method do 😊

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have you seen https://docs.google.com/document/d/13aOspEx_yGcdaRwXZTAOCaiPuAIZlcrkZopvR0cr2_k/edit#heading=h.oijoa468kc4q ?

The idea is that cache is reactive, so the main way to access data would be through the react hook or RXJS observable. Regular promise based method can be used in the cases where reactivity is not needed

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah I see, indeed this is outside React context so we need a way to make it reactive. 👍

return createQueryObservable<ContentItem>(
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<I extends object, O extends CreateOut, Options extends object | undefined = undefined>(
input: CreateIn<I, Options>
): Promise<O> {
return this.rpcClient.create(input);
}
}

const ContentClientContext = React.createContext<ContentClient>(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 (
<ContentClientContext.Provider value={contentClient}>
<QueryClientProvider client={contentClient._queryClient}>{children}</QueryClientProvider>
</ContentClientContext.Provider>
);
};

export const useContentItem = <ContentItem extends object = object>(
{
id,
type,
}: {
id: string;
type: string;
},
queryOptions: { enabled?: boolean } = { enabled: true }
) => {
const contentQueryClient = useContentClient();
const query = useQuery({
...contentQueryClient._contentItemQueryOptionBuilder.get<ContentItem>(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<I, Options>) => {
return contentQueryClient.create(input);
},
});
};
9 changes: 9 additions & 0 deletions src/plugins/content_management/public/content_client/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Original file line number Diff line number Diff line change
@@ -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<TQueryFnData, TError, TData, TQueryData, TQueryKey>
) => {
const queryObserver = new QueryObserver(
queryClient,
queryClient.defaultQueryOptions(queryOptions)
);

return new Observable<QueryObserverResult<TData, TError>>((subscriber) => {
const unsubscribe = queryObserver.subscribe(
// notifyManager is a singleton that batches updates across react query
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Strange I can't find anything about notifyManager in their docs... 🤔

Screenshot 2023-01-30 at 13 52 49

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think it is a public api. It is exported from query-core and is used by react (or other libs) implementations. I learned about it from the useQuery hook source

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok. Isn't it risky to not use a public api?

notifyManager.batchCalls((result) => {
subscriber.next(result);
})
);
return () => {
unsubscribe();
};
});
};
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<Record<string, unknown>>({});

const isIdEmpty = contentId.trim() === '';

useDebounce(
() => {
const load = async () => {
const res = await rpc.get({ type: contentType, id: contentId });
setContent(res as Record<string, unknown>);
};
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 (
Expand Down Expand Up @@ -73,7 +57,7 @@ export const ContentDetailsSection: FC = () => {
</EuiFlexItem>
<EuiFlexItem>
<EuiCodeEditor
value={JSON.stringify(content, null, 4)}
value={JSON.stringify(error || data, null, 4)}
width="100%"
height="500px"
mode="json"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,7 @@
* Side Public License, v 1.
*/

import React, { FC, useState } from 'react';
import useDebounce from 'react-use/lib/useDebounce';
import React, { FC } from 'react';
import {
EuiDescriptionListTitle,
EuiDescriptionListDescription,
Expand All @@ -16,51 +15,39 @@ import {
EuiCode,
} from '@elastic/eui';

import { Content } from '../../../common';
import { useApp } from '../context';
import { useContentItemPreview } from '../../content_client';

export const ContentPreview: FC<{ type: string; id: string }> = ({ type, id }) => {
const { rpc } = useApp();
const [content, setContent] = useState<Content | null>(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 <EuiText>Provide an id to load the content</EuiText>;
}

if (!content) {
if (isLoading) {
return <span>Loading...</span>;
}

if (isError) {
return <span>Error</span>;
}

return (
<EuiSplitPanel.Outer grow>
<EuiSplitPanel.Inner>
<EuiText>
<EuiDescriptionListTitle>{content.title}</EuiDescriptionListTitle>
{Boolean(content.description) && (
<EuiDescriptionListDescription>{content.description}</EuiDescriptionListDescription>
<EuiDescriptionListTitle>{data.title}</EuiDescriptionListTitle>
{Boolean(data.description) && (
<EuiDescriptionListDescription>{data.description}</EuiDescriptionListDescription>
)}
</EuiText>
</EuiSplitPanel.Inner>
<EuiSplitPanel.Inner grow={false} color="subdued">
<EuiText>
<p>
Type <EuiCode>{content.type}</EuiCode>
Type <EuiCode>{data.type}</EuiCode>
</p>
</EuiText>
</EuiSplitPanel.Inner>
Expand Down
Loading