kibana-react

Created on 27 May 2019  路  18Comments  路  Source: elastic/kibana

Kibana's New Platform is written in a library agnostic way, but plugins are written in React. Create a glue library kibana-react that will allow to quickly write Kibana plugins in idiomatic React. This library will provide functionality that would be otherwise re-implemented in every plugin thus help us move faster to the New Platform.

Scope of the library:

  • All Core's UI-related features would be wrapped in React-friendly utilities.
  • All core/plugin functionality to read state in async way could potentially have a React-friendly wrapper.
  • All core/plugin observables could potentially have a React-friendly wrapper.
  • All core/plugin functions that mutate state could potentially have a React-friendly wrapper.
  • Possibly it could provide in-app routing.
  • Possibly it could provide React-friendly wrappings for Kibana-wide features such as Embeddables and Visualizations.
  • Utilities for mounting to DOM elements, as we have to do it often across plugin-plugin and core-plugin boundaries.
  • Various utilities, like error boundaries to send crash reports to Core; integration with Telemetry.

Organizational setup:

  • kibana-react is a plugin with only static/stateless code, managed by App Architecture team.
  • App Architecture team is set as code owner for kibana-react plugin.
  • kibana-react is maintened as an OSS plugin to benefit from common build, testing and watching/reloading setup of Kibana. (If it was placed in ./packages monorepo we would need to setup all of those ourselves.)

Hooks, HOCs, render-props

All utilities that connect to state will be implemented as React hooks. Every hook will also be converted into a React higher-order-component and render-prop for DX.

We can convert any hook into a HOC and render-prop with two lines of code. Imagine you have a useUiSetting hook. To create withUiSetting HOC and <UseUiSetting> render-prop we do the following:

import {hookToRenderProp, createEnhancer} from 'react-universal-interface';

export const useUiSetting = () => {
  // ...
};

export const UseUiSetting = hookToRenderProp(useUiSetting);
export const withUiSetting = createEnhancer(UseUiSetting);

Why implement functionality as hooks?

  1. Because hooks allow us to express such logic succinctly.
  2. It is convenient to test such logic in hooks, just the hook can be tested without needing to spin-up a whole React component with Enzyme.

Why export functionality also as HOC and render-prop?

  1. It is just 2 extra lines of code, basically free.
  2. Some developers may not be familiar with hooks.
  3. Hooks might be tricky to use in some situations.
  4. Hooks don't work inside React class components.
  5. Hooks don't work inside render-props or anywhere but very top of functional components.

Example wrapping around Core's Toasts service

  • <Toast /> — display a toast message.
  • useToasts — get the list of active toasts.

Display a toast notification.

import {Toast} from 'kibana-react';

<Toast>
  Hello <strong>world</strong>
</Toast>

Get a list of all active toasts.

import {useToasts} from 'kibana-react';

const MyComponent = () => {
  const toasts = useToasts();

  return (
    <div>
      Active toasts:
      <ul>
        {toasts.map(({id}) => <li key={id}>{id}</li>)}
      </ul>
    </div>
  );
};

All React hooks would also be provided in HOC and render-prop forms.

Higher order component example:

import {withToasts} from 'kibana-react';

const MyComponent = withToasts(({toasts}) => {
  return (
    <div>
      Active toasts:
      <ul>
        {toasts.map(({id}) => <li key={id}>{id}</li>)}
      </ul>
    </div>
  );
});

Render-prop example:

import {UseToasts} from 'kibana-react';

const MyComponent = () => {
  return (
    <div>
      Active toasts:
      <UseToasts>{toasts =>
        <ul>
          {toasts.map(({id}) => <li key={id}>{id}</li>)}
        </ul>
      }</UseToasts>
    </div>
  );
};

Example of wrapping Core's UI

This is a hypothetical example, where let's say Core would allow apps to draw any app icons, like proposed here. And you want to draw a dynamic icon like below, where progress bar is moving and numbers it top-right badge change over time.

image

you could do it like this:

import {setAppIcon, AppIcon, IconProgress, IconBadge} from 'kibana-react';
import {EuiIcon} from '@elastic/eui';

{
  icon: setAppIcon(() =>
    <AppIcon>
      <EuiIcon type="machineLearningApp" />
      <IconProgress value={0.8} />
      <IconBadge color="pink" where="top-right">3</IconBadge>
    </AppIcon>
  ),
}

where

  • setAppIconkibana-react function that makes sure application icon is set.
  • <AppIcon> — generic kibana-react component for rendering app icons.
  • <EuiIcon> — specific icon from EUI to be rendered.
  • <IconProgress> — renders icon's green progress bar.
  • <IconBadge> — renders icon's badge in the top-right corner.

image

NP Migration AppServices

Most helpful comment

Thanks for jumping on this, @streamich! 馃帄

Overall, I love the approach here and I think this will eliminate a large category of problems we've been seeing when it comes to migrating React code to the new platform. I do have a few concerns on the scope though.

with anything like this the big concern is that the shared implementation won't be broad enough for everyone's use and then as soon as 2-3 groups build their own side-versions of something provided here, it all starts to fall apart.

Completely agree with @jasonrhodes. The risk of this project is that we fall under one of two extremes: either we try to be everything for everyone and this turns into a catch-all for "All Things React", or the wrapper is not "thin" enough and we make too many subjective design decisions which will deter others from adopting the code in their plugins.

Possibly it could provide React-friendly wrappings for Kibana-wide features such as Embeddables and Visualizations.

Embeddables and Visualizations are major concepts within Kibana, but I do not consider them to be "Kibana-wide" features as they are not a dependency of every plugin.

My initial understanding was that the goal of this plugin would be to simply provide lightweight React bindings exclusively for Core services -- which are Kibana-wide -- with perhaps a few _truly generic_ helper utilities thrown in. In other words, core-react instead of kibana-react.

This makes sense because:

  1. All Kibana plugins will depend on core services (even if they don't use all of them, they have them injected at runtime)
  2. By design, core services don't care about how you render your UI (React, Angular, or otherwise)

If we start introducing React wrappers for things that aren't core services or generic utilities, we start to muddy the waters of domain ownership and introduce a potential maintenance nightmare.

Say we wanted to introduce a set of React-friendly wrappers specifically for embeddables: I would expect those to be exported alongside the embeddables plugin, as they are all part of the same domain. And someday if we determine embeddables should be a core service in Kibana, then at that point we split out the React pieces into this core wrapper plugin.

All 18 comments

As solutions developers this would be tremendously helpful! :tada:

kibana-react is a plugin with only static/stateless code

What is the planned pattern for wiring up the runtime deps, e.g. for the toast helpers? Should every consuming Kibana plugin add a set of context providers to its react hierarchy, which are also maintained as part of this plugin?

Great summary @streamich

A great point to mention here, is that it will be possible to import code from the kibana-react plugin, as it's purely static code.

@weltenwort for wiring up the library with core the safest and simplest way seems to be though React context. But any suggestions are welcome.

Could be something like this:

import {mountApp} from 'kibana-react';

export const plugin = {
  setup: core => {
    core.application.register({
      id: 'my-app',
      title: 'My Application',
      rootRoute: '/myapp',
      mount: mountApp(<MyApplication />),
    });
  },
};

There are many ways how the API could look like, but most likely the core would be stored in React context. In the example above, mountApp function would set it up.

There would also be a more explicit option to create the context yourself.

import {createContext} from 'kibana-react';

export const plugin = {
  start: (core, plugins) => {
    const {Provider} = createContext(core, plugins);
  },
};

Pinging @elastic/kibana-app-arch

I like the idea of this a lot, but with anything like this the big concern is that the shared implementation won't be broad enough for everyone's use and then as soon as 2-3 groups build their own side-versions of something provided here, it all starts to fall apart. Keeping this plugin as small and focused as possible will help with that but just something to keep in mind I guess.

Do we have a good sense of the group of people who write React Kibana code across Elastic? I think it would be good to somehow make that group official and then make sure those people see this plugin as it develops and have a chance to comment on it/ask questions about it.

@weltenwort regarding wiring with core, I went with createContext approach in this PR. (Any feedback welcome.)

Which means for infra plugin the root component could be changed as follows:

import { createContext, UseUiSetting } from 'kibana-react';
const { Provider: KibanaProvider } = createContext(getNewPlatform().setup.core);

export async function startApp(libs: InfraFrontendLibs) {
  const InfraPluginRoot: React.FunctionComponent = () => {
    return (
      <I18nContext>
        <UICapabilitiesProvider>
          <EuiErrorBoundary>
            <ConstateProvider devtools>
              <ReduxStoreProvider store={store}>
                <ApolloProvider client={libs.apolloClient}>
                  <ApolloClientContext.Provider value={libs.apolloClient}>
                    <KibanaProvider>
                      <UseUiSetting>{([darkMode]) =>
                        <EuiThemeProvider darkMode={darkMode}>
                          <HistoryContext.Provider value={history}>
                            <PageRouter history={history} />
                          </HistoryContext.Provider>
                        </EuiThemeProvider>
                      }</UseUiSetting>
                    </KibanaProvider>
                  </ApolloClientContext.Provider>
                </ApolloProvider>
              </ReduxStoreProvider>
            </ConstateProvider>
          </EuiErrorBoundary>
        </UICapabilitiesProvider>
      </I18nContext>
    );
  };

  libs.framework.render(<InfraPluginRoot />);
}

One thing I'm not sure about is how we will handle the different core and plugins objects in NP. Currently, there is one set of core and plugins in setup life-cycle, and another set of core and plugins in start life-cycle.

Do we have a good sense of the group of people who write React Kibana code across Elastic? I think it would be good to somehow make that group official ...

@jasonrhodes like creating @elastic/kibana-react GitHub team?

Perhaps, or a slack group/email distro ... and maybe even a semi-regular meeting (I know, everyone hates meetings) on even a quarterly basis or something, IDK.

Currently, there is one set of core and plugins in setup life-cycle, and another set of core and plugins in start life-cycle.

Two thoughts:

  1. There could be two providers.
  2. Are React components supposed to access the setup objects at all? My mental model puts React rendering squarely into the start part of the lifecycle.

Are React components supposed to access the setup objects at all? My mental model puts React rendering squarely into the start part of the lifecycle.

@weltenwort Probably, but currently uiSettings are available only in setup core.

image

I feel I have an insufficient understanding of the intended semantics of these life-cycle stages. I would mostly expect the Setup interfaces to be a subset of the Start interfaces, because some things are not yet initialized. If they are completely independent interface we could expose both, either through two providers or through a provider that exposes { setup, start, stop }.

Are React components supposed to access the setup objects at all? My mental model puts React rendering squarely into the start part of the lifecycle.

This is correct. Your application will only be rendered on the page as part of the start lifecycle. We are currently fleshing out which services need to be provided as part of both lifecycles, uiSettings is a good example of one that may need to be available to both setup and start.

There are many ways how the API could look like, but most likely the core would be stored in React context. In the example above, mountApp function would set it up.

This is something that kibana-react plugin explicitly cannot do and be used with static imports. Any code that is imported statically needs to get access to core provided by the _consuming_ code and not by the plugin that exports it. This is necessary because these modules will be _copied_ into the consuming code's plugin bundle and the exporting plugin will not be able to initialize these modules.

Instead, the API will need to look more like:

import {mountApp} from 'kibana-react';

export const plugin = {
  setup: core => {
    core.application.register({
      id: 'my-app',
      title: 'My Application',
      rootRoute: '/myapp',
      // `core` must be passed into `mountApp` to be used in React.Context provider
      mount: mountApp(core, <MyApplication />),
    });
  },
};

If you do want the consuming code to not have to pass core into these helper utilities, then these utilities can only be exposed at runtime via plugin contracts:

// no imports from kibana-react

export const plugin = {
  setup: (core, plugins) => {
    core.application.register({
      id: 'my-app',
      title: 'My Application',
      rootRoute: '/myapp',
      // get the `mountApp` function from kibana-react's plugin contract at runtime
      mount: plugins.kibanaReact.mountApp(<MyApplication />),
    });
  },
};

@joshdover My idea for mountApp was that mount property follows this signature

mount(targetDomElement, pluginStartContext, pluginStart)

as proposed in RFC.

In the example it looks like you are passing the setup core in:

export const plugin = {
 setup: core => {
   core.application.register({
     id: 'my-app',
     title: 'My Application',
     rootRoute: '/myapp',
     // `core` must be passed into `mountApp` to be used in React.Context provider
     mount: mountApp(core, <MyApplication />),
   });
 },
};

@streamich Got it, that should work. Keep in mind that mount interface is going to change based on the Handler Context RFC (#36509)

Thanks for jumping on this, @streamich! 馃帄

Overall, I love the approach here and I think this will eliminate a large category of problems we've been seeing when it comes to migrating React code to the new platform. I do have a few concerns on the scope though.

with anything like this the big concern is that the shared implementation won't be broad enough for everyone's use and then as soon as 2-3 groups build their own side-versions of something provided here, it all starts to fall apart.

Completely agree with @jasonrhodes. The risk of this project is that we fall under one of two extremes: either we try to be everything for everyone and this turns into a catch-all for "All Things React", or the wrapper is not "thin" enough and we make too many subjective design decisions which will deter others from adopting the code in their plugins.

Possibly it could provide React-friendly wrappings for Kibana-wide features such as Embeddables and Visualizations.

Embeddables and Visualizations are major concepts within Kibana, but I do not consider them to be "Kibana-wide" features as they are not a dependency of every plugin.

My initial understanding was that the goal of this plugin would be to simply provide lightweight React bindings exclusively for Core services -- which are Kibana-wide -- with perhaps a few _truly generic_ helper utilities thrown in. In other words, core-react instead of kibana-react.

This makes sense because:

  1. All Kibana plugins will depend on core services (even if they don't use all of them, they have them injected at runtime)
  2. By design, core services don't care about how you render your UI (React, Angular, or otherwise)

If we start introducing React wrappers for things that aren't core services or generic utilities, we start to muddy the waters of domain ownership and introduce a potential maintenance nightmare.

Say we wanted to introduce a set of React-friendly wrappers specifically for embeddables: I would expect those to be exported alongside the embeddables plugin, as they are all part of the same domain. And someday if we determine embeddables should be a core service in Kibana, then at that point we split out the React pieces into this core wrapper plugin.

I had a chance to look at the referenced PR today -- I see now that the implementation so far is limited to Core, which is what I initially expected.

I think I was taken off guard by the description in this issue which suggests a much broader scope, so please don't take my comments above as a vote against this idea; more of a caution about overextending scope beyond what's already there :)

Closing as kibana_react was implemented already awhile back. Feel free to re-open if anyone feels this issue is still needed.

Was this page helpful?
0 / 5 - 0 ratings