From 5daf53e6cbeeea97608066f4fc3077fc58132db3 Mon Sep 17 00:00:00 2001 From: Roman Date: Wed, 9 Sep 2020 16:19:02 +0300 Subject: [PATCH] Extensions-api: initial hello-world example (#817) Signed-off-by: Roman Co-authored-by: Alex Andreev --- .eslintrc.js | 3 +- .gitignore | 1 + .../example-extension/example-extension.ts | 17 ------- .../example-extension/example-extension.tsx | 47 +++++++++++++++++++ src/extensions/example-extension/package.json | 5 +- .../example-extension/tsconfig.json | 2 +- src/extensions/extension-api.ts | 3 +- src/extensions/extension-store.ts | 16 +++---- .../{extension.ts => lens-extension.ts} | 34 ++++++++++++-- src/extensions/lens-runtime.ts | 31 +++++++----- src/extensions/register-page.tsx | 46 ++++++++++++++++++ src/renderer/bootstrap.tsx | 2 + src/renderer/components/app.tsx | 4 ++ .../cluster-manager/cluster-manager.tsx | 4 ++ .../cluster-manager/clusters-menu.tsx | 15 +++--- .../cluster-manager/register-page.ts | 28 ----------- .../components/layout/main-layout.tsx | 4 +- src/renderer/components/layout/sidebar.tsx | 13 +++++ src/renderer/lens-app.tsx | 6 --- 19 files changed, 190 insertions(+), 91 deletions(-) delete mode 100644 src/extensions/example-extension/example-extension.ts create mode 100644 src/extensions/example-extension/example-extension.tsx rename src/extensions/{extension.ts => lens-extension.ts} (74%) create mode 100644 src/extensions/register-page.tsx delete mode 100644 src/renderer/components/cluster-manager/register-page.ts diff --git a/.eslintrc.js b/.eslintrc.js index 516029cdd4..3de849de34 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -24,7 +24,8 @@ module.exports = { files: [ "build/*.ts", "src/**/*.ts", - "integration/**/*.ts" + "integration/**/*.ts", + "src/extensions/**/*.ts*" ], parser: "@typescript-eslint/parser", extends: [ diff --git a/.gitignore b/.gitignore index 6ddf3f489c..11cc6298ab 100644 --- a/.gitignore +++ b/.gitignore @@ -10,5 +10,6 @@ binaries/client/ binaries/server/ src/extensions/*/*.js src/extensions/*/*.d.ts +src/extensions/example-extension/src/** locales/**/**.js lens.log diff --git a/src/extensions/example-extension/example-extension.ts b/src/extensions/example-extension/example-extension.ts deleted file mode 100644 index 28b4da5825..0000000000 --- a/src/extensions/example-extension/example-extension.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { LensExtension, Icon, LensRuntimeRendererEnv } from "@lens/extensions"; // fixme: map to generated types from "extension-api.d.ts" - -// todo: register custom icon in cluster-menu -// todo: register custom view by clicking the item - -export default class ExampleExtension extends LensExtension { - async enable(runtime: /*LensRuntimeRendererEnv*/ any): Promise { - try { - super.enable(runtime); - runtime.logger.info('EXAMPLE EXTENSION: ENABLE() override'); - } catch (err){ - console.error(err) - } - } -} - -// console.log("done")}/> \ No newline at end of file diff --git a/src/extensions/example-extension/example-extension.tsx b/src/extensions/example-extension/example-extension.tsx new file mode 100644 index 0000000000..52a169a2c9 --- /dev/null +++ b/src/extensions/example-extension/example-extension.tsx @@ -0,0 +1,47 @@ +import { Button, DynamicPageType, Icon, LensExtension } from "@lens/extensions"; // fixme: map to generated types from "extension-api.ts" +import React from "react"; +import path from "path"; + +export default class ExampleExtension extends LensExtension { + onActivate() { + console.log('EXAMPLE EXTENSION: ACTIVATED', this.getMeta()); + this.registerPage({ + type: DynamicPageType.CLUSTER, + path: "/extension-example", + menuTitle: "Example Extension", + components: { + Page: () => , + MenuIcon: ExtensionIcon, + } + }) + } + + onDeactivate() { + console.log('EXAMPLE EXTENSION: DEACTIVATED', this.getMeta()); + } +} + +export function ExtensionIcon(props: {} /*IconProps |*/) { + return +} + +export class ExtensionPage extends React.Component<{ extension: ExampleExtension }> { + deactivate = () => { + const { extension } = this.props; + extension.runtime.navigate("/") + extension.disable(); + } + + render() { + const { MainLayout } = this.props.extension.runtime.components; + return ( + +
+

Hello from extensions-api!

+

File: {__filename}

+
+
+ ) + } +} diff --git a/src/extensions/example-extension/package.json b/src/extensions/example-extension/package.json index d57a78aab5..663d5d432a 100644 --- a/src/extensions/example-extension/package.json +++ b/src/extensions/example-extension/package.json @@ -2,9 +2,10 @@ "name": "extension-example", "version": "1.0.0", "description": "Example extension", - "main": "example-extension.ts", + "main": "example-extension.js", "lens": { - "metadata": {} + "metadata": {}, + "styles": [] }, "dependencies": { } diff --git a/src/extensions/example-extension/tsconfig.json b/src/extensions/example-extension/tsconfig.json index 16081f6055..993992112a 100644 --- a/src/extensions/example-extension/tsconfig.json +++ b/src/extensions/example-extension/tsconfig.json @@ -8,6 +8,6 @@ }, "include": [ "../../../types", - "./example-extension.ts" + "./example-extension.tsx" ] } diff --git a/src/extensions/extension-api.ts b/src/extensions/extension-api.ts index 3dff72d00c..8e5951a12e 100644 --- a/src/extensions/extension-api.ts +++ b/src/extensions/extension-api.ts @@ -2,7 +2,8 @@ export type { LensRuntimeRendererEnv } from "./lens-runtime"; // APIs -export * from "./extension" +export * from "./lens-extension" +export { DynamicPageType } from "./register-page"; // Common UI components export * from "../renderer/components/icon" diff --git a/src/extensions/extension-store.ts b/src/extensions/extension-store.ts index 655c4d6ca7..a7eb75db88 100644 --- a/src/extensions/extension-store.ts +++ b/src/extensions/extension-store.ts @@ -3,17 +3,17 @@ import path from "path"; import fs from "fs-extra"; import { action, observable, reaction, toJS, } from "mobx"; import { BaseStore } from "../common/base-store"; -import { ExtensionId, ExtensionManifest, ExtensionVersion, LensExtension } from "./extension"; -import { isDevelopment, isProduction, isTestEnv } from "../common/vars"; +import { ExtensionId, ExtensionManifest, ExtensionVersion, LensExtension } from "./lens-extension"; +import { isDevelopment } from "../common/vars"; import logger from "../main/logger"; export interface ExtensionStoreModel { version: ExtensionVersion; - extensions: Record + extensions: [ExtensionId, ExtensionModel][] } export interface ExtensionModel { - id?: ExtensionId; // available in lens-extension instance + id: ExtensionId; version: ExtensionVersion; name: string; manifestPath: string; @@ -35,7 +35,6 @@ export class ExtensionStore extends BaseStore { private constructor() { super({ configName: "lens-extension-store", - syncEnabled: false, }); } @@ -48,7 +47,7 @@ export class ExtensionStore extends BaseStore { if (isDevelopment) { return path.resolve(__static, "../src/extensions"); } - return path.resolve(__static, "../extensions"); //todo figure out prod + return path.resolve(__static, "../extensions"); } async load() { @@ -80,7 +79,6 @@ export class ExtensionStore extends BaseStore { try { manifestJson = __non_webpack_require__(manifestPath); // "__non_webpack_require__" converts to native node's require()-call mainJs = path.resolve(path.dirname(manifestPath), manifestJson.main); - mainJs = mainJs.replace(/\.ts$/i, ".js"); // todo: compile *.ts on the fly? const extensionModule = __non_webpack_require__(mainJs); return { manifestPath: manifestPath, @@ -132,7 +130,7 @@ export class ExtensionStore extends BaseStore { this.version = version; } if (extensions) { - const currentExtensions = new Map(Object.entries(extensions)); + const currentExtensions = new Map(extensions); this.extensions.forEach(extension => { if (!currentExtensions.has(extension.id)) { this.removed.set(extension.id, extension); @@ -164,7 +162,7 @@ export class ExtensionStore extends BaseStore { toJSON(): ExtensionStoreModel { return toJS({ version: this.version, - extensions: this.extensions.toJSON(), + extensions: Array.from(this.extensions).map(([id, instance]) => [id, instance.toJSON()]), }, { recurseEverything: true, }) diff --git a/src/extensions/extension.ts b/src/extensions/lens-extension.ts similarity index 74% rename from src/extensions/extension.ts rename to src/extensions/lens-extension.ts index 698422fb54..a285cf095e 100644 --- a/src/extensions/extension.ts +++ b/src/extensions/lens-extension.ts @@ -1,17 +1,20 @@ import type { ExtensionModel } from "./extension-store"; import type { LensRuntimeRendererEnv } from "./lens-runtime"; +import type { PageRegistration } from "./register-page"; import { readJsonSync } from "fs-extra"; import { action, observable, toJS } from "mobx"; import extensionManifest from "./example-extension/package.json" import logger from "../main/logger"; -export type ExtensionId = string; // instance-id or abs path to "%lens-extension/manifest.json" +export type ExtensionId = string | ExtensionPackageJsonPath; +export type ExtensionPackageJsonPath = string; export type ExtensionVersion = string | number; export type ExtensionManifest = typeof extensionManifest & ExtensionModel; export class LensExtension implements ExtensionModel { public id: ExtensionId; public updateUrl: string; + protected disposers: Function[] = []; @observable name = ""; @observable description = ""; @@ -19,7 +22,7 @@ export class LensExtension implements ExtensionModel { @observable manifest: ExtensionManifest; @observable manifestPath: string; @observable isEnabled = false; - @observable.ref runtime: Partial = {}; + @observable.ref runtime: LensRuntimeRendererEnv; constructor(model: ExtensionModel, manifest: ExtensionManifest) { this.importModel(model, manifest); @@ -41,14 +44,27 @@ export class LensExtension implements ExtensionModel { this.isEnabled = true; this.runtime = runtime; console.log(`[EXTENSION]: enabled ${this.name}@${this.version}`, this.getMeta()); + this.onActivate(); } async disable() { + this.onDeactivate(); this.isEnabled = false; - this.runtime = {}; + this.runtime = null; + this.disposers.forEach(cleanUp => cleanUp()); + this.disposers.length = 0; console.log(`[EXTENSION]: disabled ${this.name}@${this.version}`, this.getMeta()); } + // todo: add more hooks + protected onActivate() { + // mock + } + + protected onDeactivate() { + // mock + } + // todo async install(downloadUrl?: string) { return; @@ -76,7 +92,7 @@ export class LensExtension implements ExtensionModel { } toJSON(): ExtensionModel { - return { + return toJS({ id: this.id, name: this.name, version: this.version, @@ -84,6 +100,16 @@ export class LensExtension implements ExtensionModel { manifestPath: this.manifestPath, enabled: this.isEnabled, updateUrl: this.updateUrl, + }, { + recurseEverything: true, + }) + } + + // Runtime helpers + protected registerPage(params: PageRegistration, autoDisable = true) { + const dispose = this.runtime.dynamicPages.register(params); + if (autoDisable) { + this.disposers.push(dispose); } } } diff --git a/src/extensions/lens-runtime.ts b/src/extensions/lens-runtime.ts index e456bb435f..5fdc4c35eb 100644 --- a/src/extensions/lens-runtime.ts +++ b/src/extensions/lens-runtime.ts @@ -1,19 +1,26 @@ -// Lens runtime for injecting to extension on activation -import { apiManager } from "../renderer/api/api-manager"; +// Lens renderer runtime params available to extensions after activation + import logger from "../main/logger"; -import { dynamicPages } from "../renderer/components/cluster-manager/register-page"; +import { dynamicPages } from "./register-page"; +import { MainLayout } from "../renderer/components/layout/main-layout"; +import { navigate } from "../renderer/navigation"; export interface LensRuntimeRendererEnv { - apiManager: typeof apiManager; + navigate: typeof navigate; logger: typeof logger; dynamicPages: typeof dynamicPages -} - -// todo: expose more public runtime apis: stores, managers, etc. -export function getLensRuntime(): LensRuntimeRendererEnv { - return { - apiManager, - logger, - dynamicPages, + components: { + MainLayout: typeof MainLayout + } +} + +export function getLensRuntime(): LensRuntimeRendererEnv { + return { + logger, + navigate, + dynamicPages, + components: { + MainLayout // fixme: refactor, import as pure component from "@lens/extensions" + } } } diff --git a/src/extensions/register-page.tsx b/src/extensions/register-page.tsx new file mode 100644 index 0000000000..34317c1d93 --- /dev/null +++ b/src/extensions/register-page.tsx @@ -0,0 +1,46 @@ +// Extensions-api -> Dynamic pages + +import { computed, observable } from "mobx"; +import React from "react"; +import type { IconProps } from "../renderer/components/icon"; + +export enum DynamicPageType { + GLOBAL = "lens-scope", + CLUSTER = "cluster-view-scope", +} + +export interface PageRegistration { + path: string; // route-path + menuTitle: string; + type: DynamicPageType; + components: PageComponents; +} + +export interface PageComponents { + Page: React.ComponentType; + MenuIcon: React.ComponentType; +} + +export class PagesStore { + protected pages = observable.array([], { deep: false }); + + @computed get globalPages() { + return this.pages.filter(page => page.type === DynamicPageType.GLOBAL); + } + + @computed get clusterPages() { + return this.pages.filter(page => page.type === DynamicPageType.CLUSTER); + } + + // todo: verify paths to avoid collision with existing pages + register(params: PageRegistration) { + this.pages.push(params); + return () => { + this.pages.replace( + this.pages.filter(page => page.components !== params.components) + ) + }; + } +} + +export const dynamicPages = new PagesStore(); diff --git a/src/renderer/bootstrap.tsx b/src/renderer/bootstrap.tsx index e4b24c3035..ee4dd6dedf 100644 --- a/src/renderer/bootstrap.tsx +++ b/src/renderer/bootstrap.tsx @@ -10,6 +10,7 @@ import { i18nStore } from "./i18n"; import { themeStore } from "./theme.store"; import { App } from "./components/app"; import { LensApp } from "./lens-app"; +import { getLensRuntime } from "../extensions/lens-runtime"; type AppComponent = React.ComponentType & { init?(): void; @@ -32,6 +33,7 @@ export async function bootstrap(App: AppComponent) { // init app's dependencies if any if (App.init) { await App.init(); + extensionStore.autoEnableOnLoad(getLensRuntime); } render(, rootElem); } diff --git a/src/renderer/components/app.tsx b/src/renderer/components/app.tsx index 23e71e2be9..a878fd47b7 100755 --- a/src/renderer/components/app.tsx +++ b/src/renderer/components/app.tsx @@ -35,6 +35,7 @@ import { getHostedCluster, getHostedClusterId } from "../../common/cluster-store import logger from "../../main/logger"; import { clusterIpc } from "../../common/cluster-ipc"; import { webFrame } from "electron"; +import { dynamicPages } from "../../extensions/register-page"; @observer export class App extends React.Component { @@ -71,6 +72,9 @@ export class App extends React.Component { + {dynamicPages.clusterPages.map(({ path, components: { Page } }) => { + return + })} diff --git a/src/renderer/components/cluster-manager/cluster-manager.tsx b/src/renderer/components/cluster-manager/cluster-manager.tsx index 987f22658d..10ee59f467 100644 --- a/src/renderer/components/cluster-manager/cluster-manager.tsx +++ b/src/renderer/components/cluster-manager/cluster-manager.tsx @@ -15,6 +15,7 @@ import { Extensions, extensionsRoute } from "../+extensions"; import { clusterViewRoute, clusterViewURL, getMatchedCluster, getMatchedClusterId } from "./cluster-view.route"; import { clusterStore } from "../../../common/cluster-store"; import { hasLoadedView, initView, lensViews, refreshViews } from "./lens-views"; +import { dynamicPages } from "../../../extensions/register-page"; @observer export class ClusterManager extends React.Component { @@ -62,6 +63,9 @@ export class ClusterManager extends React.Component { + {dynamicPages.globalPages.map(({ path, components: { Page } }) => { + return + })} diff --git a/src/renderer/components/cluster-manager/clusters-menu.tsx b/src/renderer/components/cluster-manager/clusters-menu.tsx index 495ce2176a..30edfc22c7 100644 --- a/src/renderer/components/cluster-manager/clusters-menu.tsx +++ b/src/renderer/components/cluster-manager/clusters-menu.tsx @@ -10,7 +10,7 @@ import { ClusterId, clusterStore } from "../../../common/cluster-store"; import { workspaceStore } from "../../../common/workspace-store"; import { ClusterIcon } from "../cluster-icon"; import { Icon } from "../icon"; -import { cssNames, IClassName, autobind } from "../../utils"; +import { autobind, cssNames, IClassName } from "../../utils"; import { Badge } from "../badge"; import { navigate } from "../../navigation"; import { addClusterURL } from "../+add-cluster"; @@ -20,8 +20,8 @@ import { Tooltip } from "../tooltip"; import { ConfirmDialog } from "../confirm-dialog"; import { clusterIpc } from "../../../common/cluster-ipc"; import { clusterViewURL, getMatchedClusterId } from "./cluster-view.route"; -import { DragDropContext, Droppable, Draggable, DropResult, DroppableProvided, DraggableProvided } from "react-beautiful-dnd"; -import { dynamicPages } from "./register-page"; +import { DragDropContext, Draggable, DraggableProvided, Droppable, DroppableProvided, DropResult } from "react-beautiful-dnd"; +import { dynamicPages } from "../../../extensions/register-page"; interface Props { className?: IClassName; @@ -143,15 +143,14 @@ export class ClustersMenu extends React.Component { Add Cluster - + {newContexts.size > 0 && ( - new} /> + new}/> )}
- {Array.from(dynamicPages.all).map(([path, { MenuIcon }]) => { - if (!MenuIcon) return; - return navigate(path)}/> + {dynamicPages.globalPages.map(({ path, components: { MenuIcon } }) => { + return navigate(path)}/> })}
diff --git a/src/renderer/components/cluster-manager/register-page.ts b/src/renderer/components/cluster-manager/register-page.ts deleted file mode 100644 index 71a6345cae..0000000000 --- a/src/renderer/components/cluster-manager/register-page.ts +++ /dev/null @@ -1,28 +0,0 @@ -// Dynamic pages - -import React from "react"; -import { observable } from "mobx"; -import type { IconProps } from "../icon"; - -export interface PageComponents { - Main: React.ComponentType; - MenuIcon: React.ComponentType; -} - -export class PagesStore { - all = observable.map(); - - getComponents(path: string): PageComponents | null { - return this.all.get(path); - } - - register(path: string, components: PageComponents) { - this.all.set(path, components); - } - - unregister(path: string) { - this.all.delete(path); - } -} - -export const dynamicPages = new PagesStore(); diff --git a/src/renderer/components/layout/main-layout.tsx b/src/renderer/components/layout/main-layout.tsx index def55961fa..8672e4ba23 100755 --- a/src/renderer/components/layout/main-layout.tsx +++ b/src/renderer/components/layout/main-layout.tsx @@ -17,7 +17,7 @@ export interface TabRoute extends RouteProps { url: string; } -interface Props { +export interface MainLayoutProps { className?: any; tabs?: TabRoute[]; footer?: React.ReactNode; @@ -27,7 +27,7 @@ interface Props { } @observer -export class MainLayout extends React.Component { +export class MainLayout extends React.Component { public storage = createStorage("main_layout", { pinnedSidebar: true }); @observable isPinned = this.storage.get().pinnedSidebar; diff --git a/src/renderer/components/layout/sidebar.tsx b/src/renderer/components/layout/sidebar.tsx index d46a1548cf..91cd4940a6 100644 --- a/src/renderer/components/layout/sidebar.tsx +++ b/src/renderer/components/layout/sidebar.tsx @@ -28,6 +28,7 @@ import { CrdList, crdResourcesRoute, crdRoute, crdURL } from "../+custom-resourc import { CustomResources } from "../+custom-resources/custom-resources"; import { navigation } from "../../navigation"; import { isAllowedResource } from "../../../common/rbac" +import { dynamicPages } from "../../../extensions/register-page"; const SidebarContext = React.createContext({ pinned: false }); type SidebarContextValue = { @@ -183,6 +184,18 @@ export class Sidebar extends React.Component { > {this.renderCustomResources()} + {dynamicPages.clusterPages.map(({ path, menuTitle, components: { MenuIcon } }) => { + return ( + } + /> + ) + })} diff --git a/src/renderer/lens-app.tsx b/src/renderer/lens-app.tsx index 7c1f228fd3..9b4fffc6a1 100644 --- a/src/renderer/lens-app.tsx +++ b/src/renderer/lens-app.tsx @@ -11,15 +11,9 @@ import { ErrorBoundary } from "./components/error-boundary"; import { WhatsNew, whatsNewRoute } from "./components/+whats-new"; import { Notifications } from "./components/notifications"; import { ConfirmDialog } from "./components/confirm-dialog"; -import { extensionStore } from "../extensions/extension-store"; -import { getLensRuntime } from "../extensions/lens-runtime"; @observer export class LensApp extends React.Component { - componentDidMount() { - extensionStore.autoEnableOnLoad(getLensRuntime); - } - render() { return (