kibana/x-pack/plugins/fleet/dev_docs/react_query.md
Jen Huang 5000201d56
[UII] Make Fleet & Integrations layouts full width (#186056)
## Summary

Resolves https://github.com/elastic/ingest-dev/issues/2602.

This PR makes global changes to Fleet and Integrations:

1. Layout expands to 100% of available screen width when screen size is
< 1600px wide
2. Layout expands to 80% when screen size is >= 1600px
3. Sets all flyouts to `medium` size with a max width of 800px no matter
the screen size

Exceptions:
- Create/edit integration policy page is restricted to 1200px when
screen size is >= 1600px
- Agent policy settings page is restricted to 1200px no matter the
screen size

### Screenshots

<details>
<summary>On 1920px screen</summary>


![image](96198a5c-b247-4339-940b-134693fc7643)


![image](425e531f-6b02-4895-8478-ab42af6bd696)


![image](325b6dcf-f3e6-44a0-9baf-bfa9f4722b9b)


![image](cbab07a4-5bfe-480c-b8f9-0685615cf471)


![image](7c0852e6-ccd1-4b37-bb67-9e3d7a84941e)


![image](f16aa397-d695-4f86-b9e1-8fa1440ef5aa)
</details>

<details>
<summary>On smaller screen</summary>


![image](6d077af4-c792-4058-b957-9dbe425ba3dd)


![image](6ed144be-b410-4b26-ab6e-4aeb20bed038)


![image](673965b0-f46f-426b-ae95-6eb258f76ec4)


![image](2e0f5f23-29b0-4a90-a855-5e1767d93f7c)


![image](73c0a806-d0b1-40d3-aa99-a44e4fe9b4c0)


![image](fc3e15c8-4992-4808-8e15-4cf91acf5acc)
</details>

### Testing
Fire up the PR and click around everything :)
2024-06-13 15:34:20 -07:00

9 KiB

@tanstack/query Usage in Fleet + Integrations

This document seeks to outline the Fleet + Integrations apps' usage of @tanstack/query - formally known generally as react-query. When we talk about the React-specific adapter for @tanstack/query, we'll use the library name react-query. Since Kibana doesn't have Vue, Solid, or Svelte plugins, we don't need to be worried about the other client implementations. This is a library for asynchronous state management that's most commonly utilized for data fetching logic. @tanstack/query helps developers write consistent state management logic around asynchronous operations, while providing end users with a performant, "jank-free" experience.

How Fleet/Integrations uses custom data fetching hooks

Historically, Fleet/Integrations have used homegrown data fetching hooks in a common folder at public/hooks/use_request. Each .ts file in this directory contains one or more data fetching hooks related to a particular resource or concept. For example, here's what some data fetching hooks for packages and categories might look like:

// use_request/epm.ts

export const useGetCategories = (query: GetCategoriesRequest['query'] = {}) => {
  return useRequest<GetCategoriesResponse>({
    path: epmRouteService.getCategoriesPath(),
    method: 'get',
    query,
  });
};

export const sendGetCategories = (query: GetCategoriesRequest['query'] = {}) => {
  return sendRequest<GetCategoriesResponse>({
    path: epmRouteService.getCategoriesPath(),
    method: 'get',
    query,
  });
};

export const useGetPackages = (query: GetPackagesRequest['query'] = {}) => {
  return useRequest<GetPackagesResponse>({
    path: epmRouteService.getListPath(),
    method: 'get',
    query,
  });
};

export const sendGetPackages = (query: GetPackagesRequest['query'] = {}) => {
  return sendRequest<GetPackagesResponse>({
    path: epmRouteService.getListPath(),
    method: 'get',
    query,
  });
};
What are useRequest and sendRequest?

The useRequest and sendRequest methods are common across all of these data fetching hooks, and use Kibana's provide useRequest hook and sendRequest helper with some additional logic on top. e.g.

// use_request/use_request.ts - excerpts for clarity

import {
  sendRequest as _sendRequest,
  useRequest as _useRequest,
} from '@kbn/es-ui-shared-plugin/public';

export const sendRequest = <D = any, E = RequestError>(
  config: SendRequestConfig
): Promise<SendRequestResponse<D, E>> => {
  if (!httpClient) {
    throw new Error('sendRequest has no http client set');
  }
  return _sendRequest<D, E>(httpClient, config);
};

export const useRequest = <D = any, E = RequestError>(config: UseRequestConfig) => {
  if (!httpClient) {
    throw new Error('sendRequest has no http client set');
  }
  return _useRequest<D, E>(httpClient, config);
};

Consuming these data fetching hooks might look something like this

// applications/integrations/sections/epm/screens/detail/settings/update_button.tsx
const handleClickUpgradePolicies = useCallback(async () => {
  if (isUpgradingPackagePolicies) {
    return;
  }

  setIsUpdateModalVisible(false);
  setIsUpgradingPackagePolicies(true);

  await installPackage({ name, version, title });

  await sendUpgradePackagePolicy(
    // Only upgrade policies that don't have conflicts
    packagePolicyIds.filter(
      (id) => !dryRunData?.find((dryRunRecord) => dryRunRecord.diff?.[0].id === id)?.hasErrors
    )
  );

  setIsUpgradingPackagePolicies(false);

  notifications.toasts.addSuccess({
    title: toMountPoint(
      <FormattedMessage
        id="xpack.fleet.integrations.packageUpdateSuccessTitle"
        defaultMessage="Updated {title} and upgraded policies"
        values={{ title }}
      />,
      { theme$ }
    ),
    text: toMountPoint(
      <FormattedMessage
        id="xpack.fleet.integrations.packageUpdateSuccessDescription"
        defaultMessage="Successfully updated {title} and upgraded policies"
        values={{ title }}
      />,
      { theme$ }
    ),
  });

  navigateToNewSettingsPage();
}, [
  dryRunData,
  installPackage,
  isUpgradingPackagePolicies,
  name,
  navigateToNewSettingsPage,
  notifications.toasts,
  packagePolicyIds,
  setIsUpgradingPackagePolicies,
  title,
  version,
  theme$,
]);

In the "custom data fetching" hooks world, there are a few big problems:

  1. Caching, cancellation, deduping/debouncing successive requests, and optimizations around re-renders are an afterthought
  2. Mutations in particular are extremely verbose, as we need to "wire up" all error/loading state, "post-mutation" operations, etc
  3. Revalidating queries from elsewhere in the component tree (e.g. update the agent policy table when a new agent policy is saved) is a tricky operation usually solved by intermittent polling or a location.reload()

How react-query helps

react-query handles many of the "big problems" above out-of-the-box. By providing a basic key/value based cache for queries, consistent utilities around state transitions of async operations, and robust revalidation helpers, react-query makes working with the state around data fetching much more predictable and pleasant.

How Fleet/Integrations uses react-query

There's a bit of setup involved to actually get react-query up and running. First and foremost, each Kibana application is wrapped in a <QueryClientProvider> that handles react-query's internal query cache and various React context needs. e.g.

//...
<QueryClientProvider client={queryClient}>
  <ReactQueryDevtools initialIsOpen={true} />
  <UIExtensionsContext.Provider value={extensions}>
    <FleetStatusProvider>
      <Router history={history}>
        <PackageInstallProvider notifications={startServices.notifications} theme$={theme$}>
          <FlyoutContextProvider>{children}</FlyoutContextProvider>
        </PackageInstallProvider>
      </Router>
    </FleetStatusProvider>
  </UIExtensionsContext.Provider>
</QueryClientProvider>

We also set up react-query's dev tools, which provide a useful developer console for debugging query and mutation state across the whole application.

Another step required to use react-query in Fleet/Integrations is the introduction of a specialized data fetching utility. react-query operations expect a slightly different structure than what Kibana's useRequest and sendRequest helpers. For this purpose, we introduce the sendRequestForRq helper, e.g.

// Sends requests with better ergonomics for React Query, e.g. throw error rather
// than resolving with an `error` property in the result. Also returns `data` directly
// as opposed to { data } in a response object.
export const sendRequestForRq = async <D = any, E = RequestError>(
  config: SendRequestConfig
): Promise<D> => {
  if (!httpClient) {
    throw new Error('sendRequest has no http client set');
  }

  const response = await _sendRequest<D, E>(httpClient, config);

  if (response.error) {
    throw response.error;
  }

  // Data can't be null so long as `_sendRequest` did not throw
  return response.data!;
};

So, with those pieces of setup in mind, adding a new query or mutation looks like this:

export function useGetCategoriesQuery(query: GetCategoriesRequest['query'] = {}) {
  return useQuery<GetCategoriesResponse, RequestError>(['categories', query], () =>
    sendRequestForRq<GetCategoriesResponse>({
      path: epmRouteService.getCategoriesPath(),
      method: 'get',
      query,
    })
  );
}

export const useGetPackagesQuery = (query: GetPackagesRequest['query']) => {
  return useQuery<GetPackagesResponse, RequestError>(['get-packages', query.prerelease], () =>
    sendRequestForRq<GetPackagesResponse>({
      path: epmRouteService.getListPath(),
      method: 'get',
      query,
    })
  );
};

export const useUpdatePackageMutation = () => {
  return useMutation<UpdatePackageResponse, RequestError, UpdatePackageArgs>(
    ({ pkgName, pkgVersion, body }: UpdatePackageArgs) =>
      sendRequestForRq<UpdatePackageResponse>({
        path: epmRouteService.getUpdatePath(pkgName, pkgVersion),
        method: 'put',
        body,
      })
  );
};

react-query operation naming conventions

For react-query operations defined in use_request/, try to use a naming convention along the lines of use{Action}{Resource}{Query/Mutation} for your hooks. This helps with consistency and makes the intent of every data fetching operation clear.