Skip to content

Commit

Permalink
Plugins mechanism (#910)
Browse files Browse the repository at this point in the history
* feat: prepare initial plugins mechanism
* fixup silent console.warns
* feat: synchronize plugin scope data on plugin level (instead of root)
* chore: rename LimitedPluginScope to LocalPluginScope
* fix: ts-check for plugins package
* feat: replace LocalPluginScope/PluginRootScope with universal PluginScope
* chore: delete unused provider
* feat: support nested plugin systems (outer root scope / outer dependencies)
* chore: add unit tests to avoid warnings for missing outer data/slots
* fix: ensure console.warn mock stability
* feat: support clearing data produced within the PluginScope
* chore: add a unit test for clearing PluginScope
* fix: children plugin scope should not be detached from the parent
* chore: replace Array with Set
* feat: improve code completion for external slots/data
* feat: allow multiple slots/data definition with same type within same call
* feat: add option to provide conditional providers
* feat: add documentation for the Plugins system
* feat: add documentation for Zustand store in the Plugins system
* feat: add option to use scope data in providers easily
  • Loading branch information
rangoo94 authored Oct 17, 2023
1 parent aa18049 commit cbda693
Show file tree
Hide file tree
Showing 41 changed files with 3,031 additions and 1 deletion.
4 changes: 4 additions & 0 deletions .prettierrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ module.exports = {
'^(antd|@ant-design)',
'^@reduxjs',
'<THIRD_PARTY_MODULES>',
'^@testkube/plugins',
'^@testkube/plugin-',
'^@testkube/',
'^@plugin/',
'^\\.\\.(\\/)?',
'^\\.\\/',
'^.$',
Expand Down
5 changes: 4 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

351 changes: 351 additions & 0 deletions packages/plugins/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,351 @@
# @testkube/plugins

## Table of contents

1. [Creating plugin](#creating-plugin)
1. [Definition](#definition)
2. [Dependencies](#dependencies)
3. [Components](#components)
4. [Routing](#routing)
5. [Providers](#providers)
1. [Zustand stores](#zustand-stores)
6. [Unit testing](#unit-testing)
2. [Resolving plugins](#resolving-plugins)
1. [Basic](#basic)
2. [Nested systems](#nested-systems)

## Creating plugin

To create a new Testkube plugin, create a new package (you may copy some existing one), install `@testkube/plugins` and configure the plugin.
Remember, to have `antd`/`react`/`react-dom` installed as dev/peer dependencies only and in the same version as Dashboard, to avoid duplicating it.

### Definition

The plugin definition is fully type-safe. The plugin may define different parts of itself, as follows:

```tsx
import {StoreProvider, createPlugin, data, external, slot} from '@testkube/plugins';

// Remember to use `import type`, as you won't need to include the external plugin in code
import type SomePlugin from '@testkube/some-plugin';

// Remember to use `import type`, as you won't need to include the external plugin in code
import type SomeCloudPlugin from '@testkube-cloud/some-cloud-plugin';

// Load types from external plugin
const somePluginStub = external<typeof SomePlugin>();

// Load types from external plugin
const someOuterStub = external<typeof SomeCloudPlugin>();

export default createPlugin('some-plugin-name')
// Set plugin's priority to order with other plugins.
// By default it's 0, and the plugins are ordered from -Infinity to Infinity.
.order(-100)

// Declare initial data for the plugin
.data({someVariable: 'abc', someFn: () => 5})

// Declare other variables that will be exposed by this plugin
.define(data<string>()('otherVariable'))
.define(data<(value: string) => number>()('otherFn'))

// Declare the slot that other plugins may inject to
.define(slot<string>('warnings'))

// Declare usage of public data from some other plugins
.needs(somePluginStub.data('some1Variable', 'some1Fn'))

// Declare injecting to slot of a different plugin
.needs(somePluginStub.slot('someSlot', 'someOtherSlot'))

// Declare dependency from the plugin,
// that is in another plugins system above.
// Most likely, you will use it only while creating Dashboard plugin,
// that will interact with internal Cloud data.
// Alternatively, it may be used to get data from optional plugins.
.outer(someOuterStub.data('organizationId'))

// Inject provider that will wrap all the components inside
.provider(<SomeReactProvider value={10} />)

// Inject provider that will wrap all the components inside.
// Append only if `someVariable` is set to 'xyz'.
.provider(<SomeConditionalReactProvider value={10} />, {
enabled: tk => tk.data.someVariable === 'xyz',
})

// Inject a new page to the system
.route('/tests', <TestsPage />)

// Configure the plugin
.init(tk => {
// Change current value of the `someVariable`
tk.data.someVariable = 'xyz';

// Read own data
console.log(tk.data.someFn());

// Read data from external plugin
console.log(tk.data.some1Variable);
console.log(tk.data.some1Fn());

// Read data from external plugin in outer system.
console.log(tk.data.organizationId);

// Read all available values from the current slot.
console.log(tk.slots.warnings.all());

// Read the first value from the current slot.
console.log(tk.slots.warnings.first());

// Inject to declared SomePlugin dependency slot.
// It may be ordered similarly to plugin. As an example, `Infinity` should keep it as last item.
tk.slots.somePluginStub.someSlot.add('some slot value', {order: Infinity});

// Inject to declared SomePlugin dependency slot.
// The slot item may be conditional.
tk.slots.somePluginStub.someOtherSlot.add(1234, {enabled: () => tk.data.someVariable === 'xyz'});

// When you need to call the React hooks in this place,
// You may use .sync() helper.
// It's not fast option, but it's very convenient.
const isLoading = tk.sync(() => useSomeStoreData('loading'));
tk.slots.somePluginStub.someOtherSlot.add(<>Loading...</>, {enabled: isLoading});
});
```

### Dependencies

There are two methods in the plugin definition to declare dependency:

* `.needs(/* dependency */)` - required dependency in the same system
* `.outer(/* dependency */)` - optional dependency, that may be included even from higher scope (Cloud -> OSS)

These methods take a parameter, that declares what are the dependencies used by the plugin.

To use values/slots from different plugin, you may use `external` utility:

```ts
import {createPlugin, external} from '@testkube/plugins';

// Remember to use `type` - otherwise @testkube/some-other-plugin may be unnecessarily bundled twice
import type SomeOtherPlugin from '@testkube/some-other-plugin';

// Create a type-safe layer describing SomeOtherPlugin
const someOtherPluginStub = external<typeof SomeOtherPlugin>();

export default createPlugin('some-plugin')
// Use 'slot1' and 'slot2' slots from SomeOtherPlugin
.needs(someOtherPluginStub.slots('slot1', 'slot2'))

// Use 'value1' and 'value2' data from SomeOtherPlugin
.needs(someOtherPluginStub.slots('value1', 'value2'))

/* ... */

.init();
```

To use generic values, you may use same helpers as for declaration.
It's not preferred though, as it won't reflect changes in the external module:

```ts
import {createPlugin, data, slot} from '@testkube/plugins';

export default createPlugin('some-plugin')
// Use 'slot1' and 'slot2' slots from other plugin
.needs(slot<{index: number, key: string, value: string}>()('slot1'))
.needs(slot<string>()('slot2'))

// Use 'value1' and 'value2' data from other plugin
.needs(data<{index: number, key: string, value: string}>()('value1'))
.needs(data<string>()('value2'))

/* ... */

.init();
```

### Components

To use the data and slots from the plugin inside the component,
you may use the provided hooks (or actually, hook creators).

The suggested way would be to create the hooks file in the plugin package,
that will have all typed hooks created.

```tsx
// Tree structure:
// .
// └── src
// ├── components
// │ └── SomeComponent.tsx
// ├── hooks.ts
// └── plugin.tsx

// src/hooks.ts
import {createUseData, createUseSlot, createUseSlotFirst} from '@testkube/plugins';
import type TheCurrentPlugin from './plugin';

export const useData = createUseData<typeof TheCurrentPlugin>();
export const useSlot = createUseSlot<typeof TheCurrentPlugin>();
export const useSlotFirst = createUseSlotFirst<typeof TheCurrentPlugin>();

// src/components/SomeComponent.tsx
import {FC} from 'react';
import {useData, useSlot, useSlotFirst} from '../hooks';

export const SomeComponent: FC = () => {
const {value1} = useData();
const slot1Data = useSlot('slot1');
const slotFirst = useSlotFirst('slot1'); // faster equivalent of: useSlot('slot1')[0]

return slot1Data.map(x => <div key={x.id}>{x.name} {value1 === x.id ? ' (current)' : null}</div>);
};
```

### Routing

To add new route, simply append the routing with component that should be rendered.

```tsx
import {createPlugin} from '@testkube/plugins';
import TestsList from './components/TestsList';
import TestDetails from './components/TestDetails';

export default createPlugin('some-plugin')
.route('/tests', <TestsList />)
.route('/tests/:id', <TestDetails />)

.init();
```

> **The mechanism for choosing route is not included in the package, it may be specific to project.**
### Providers

When you're dealing with React, you often need to provide data via context through wrapping components.
Such component basically renders the children, often with wrapping the components with the React Provider only.

To add such provider, you may use `.provider()` method:

```tsx
import {createContext} from 'react';
import {createPlugin} from '@testkube/plugins';

const SomeContext = createContext();

export default createPlugin('some-name')
// Append the provider, that is just simple React Context provider
.provider({type: SomeContext.Provider, props: {value: {foo: 'bar'}}})

// You may use alternative JSX syntax too, the children will be added automatically anyway.
.provider(<SomeContext.Provider value={{foo: 'bar'}} />)

// Additionally

.init();
```

#### Zustand stores

As we are commonly using Zustand as the data store,
there is a helpful provider to automatically inject it.

It allows passing dependencies and initial state too, so it's able to even recreate the store after changes.

```tsx
import {createPlugin, StoreProvider} from '@testkube/plugins';

import {initializeSomeStore, useSomeStorePick} from './store';

export default createPlugin('some-name')
// Inject the store
.provider(<StoreProvider store={initializeSomeStore} />)

// Such store may be reset by some data too
.needs(data<string>()('environmentId'))
.provider(tk => <StoreProvider store={initializeSomeStore} dependencies={[tk.data.environmentId]} />)

// Expose public interface for other plugins
.data({useSomeStorePick})

.init();
```

### Unit testing

> **TODO:** There will be prepared nice utilities to help testing the plugins.
## Resolving plugins

The plugin resolution is confirming the plugins integrity (proper order, all dependencies available, etc),
and returns scope factory and information required to build the application upon.

### Basic

```tsx
import {FC, useMemo} from 'react';
import {PluginResolver} from '@testkube/plugins';

import SomePlugin1 from '@testkube/some-plugin-1';
import SomePlugin2 from '@testkube/some-plugin-2';

// - `routes` contains an ordered list of routes that are available
// - `initialize` builds the scope, that contains all the slots & data
// - `Provider` is a context provider for the root scope
const [Provider, {initialize, routes}] = new PluginResolver()
.register(SomePlugin1)
.register(SomePlugin2)
.resolve();

export const App: FC = () => {
const scope = useMemo(() => initialize(), []);
return (
<Provider root={scope}>
{/* Most likely react-router or other routing solution should build upon the data. */}
{routes[0].element}
</Provider>
);
}
```

### Nested systems

When you want to have nested systems, it is possible to build them, and even access from the lower system to the upper.
It may be helpful, when i.e. Cloud solution is using OSS solution, and some of the OSS plugins needs to access the Cloud scope (like organizations list), with `.outer()` (as described [above](#dependencies)).

```tsx
import {FC, useMemo} from 'react';
import {PluginResolver} from '@testkube/plugins';

import SomeCloudPlugin1 from '@testkube/some-cloud-plugin-1';
import SomeCloudPlugin2 from '@testkube/some-cloud-plugin-2';
import SomePlugin1 from '@testkube/some-plugin-1';
import SomePlugin2 from '@testkube/some-plugin-2';

const [CloudProvider, {initialize: initializeCloud, routes: cloudRoutes}] = new PluginResolver()
.register(SomeCloudPlugin1)
.register(SomeCloudPlugin2)
.resolve();

const [LowerProvider, {initialize: initializeLower, routes: lowerRoutes}] = new PluginResolver()
.register(SomePlugin1)
.register(SomePlugin2)
.resolve();

export const App: FC = () => {
const cloudScope = useMemo(() => initializeCloud(), []);
const lowerScope = useMemo(() => initializeLower(cloudScope), [cloudScope]);
const combinedRoutes = useMemo(() => [...cloudRoutes, ...lowerRoutes.map(x => ({...x, path: `/dashboard/${x}`}))], []);
return (
<CloudProvider root={cloudScope}>
<LowerProvider root={lowerScope}>
{/* Most likely react-router or other routing solution should build upon the data. */}
{combinedRoutes[0].element}
</LowerProvider>
</CloudProvider>
);
}
```
5 changes: 5 additions & 0 deletions packages/plugins/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export * from './src/createPlugin';
export * from './src/hooks';
export * from './src/utils';
export * from './src/StoreProvider';
export * from './src/PluginResolver';
Loading

0 comments on commit cbda693

Please sign in to comment.