From d2111136c79969cf9b01f748f9b082f826eedb60 Mon Sep 17 00:00:00 2001 From: Remington Breeze Date: Mon, 3 Oct 2022 15:23:36 -0700 Subject: [PATCH] feat: system level extensions (#10758) * feat: sidebar extensions Signed-off-by: Remington Breeze --- docs/developer-guide/ui-extensions.md | 71 ++++++++++++----- ui/src/app/app.tsx | 58 ++++++++++++-- .../app/shared/components/layout/layout.scss | 15 ++++ .../app/shared/components/layout/layout.tsx | 12 +-- .../app/shared/services/extensions-service.ts | 22 +++++- ui/src/app/sidebar/sidebar.tsx | 78 +++++++++---------- 6 files changed, 182 insertions(+), 74 deletions(-) diff --git a/docs/developer-guide/ui-extensions.md b/docs/developer-guide/ui-extensions.md index b088bb72ccadf..2c25748beb148 100644 --- a/docs/developer-guide/ui-extensions.md +++ b/docs/developer-guide/ui-extensions.md @@ -18,9 +18,9 @@ The extension should provide a React component that is responsible for rendering Instead extension should use the `react` global variable. You can leverage `externals` setting if you are using webpack: ```js - externals: { - react: 'React' - } +externals: { + react: "React"; +} ``` ## Resource Tab Extensions @@ -33,32 +33,65 @@ The resource tab extension should be registered using the `extensionsAPI.registe registerResourceExtension(component: ExtensionComponent, group: string, kind: string, tabTitle: string) ``` -* `component: ExtensionComponent` is a React component that receives the following properties: +- `component: ExtensionComponent` is a React component that receives the following properties: - * application: Application - Argo CD Application resource; - * resource: State - the kubernetes resource object; - * tree: ApplicationTree - includes list of all resources that comprise the application; + - application: Application - Argo CD Application resource; + - resource: State - the kubernetes resource object; + - tree: ApplicationTree - includes list of all resources that comprise the application; - See properties interfaces in [models.ts](https://github.com/argoproj/argo-cd/blob/master/ui/src/app/shared/models.ts) + See properties interfaces in [models.ts](https://github.com/argoproj/argo-cd/blob/master/ui/src/app/shared/models.ts) -* `group: string` - the glob expression that matches the group of the resource; note: use globstar (`**`) to match all groups including empty string; -* `kind: string` - the glob expression that matches the kind of the resource; -* `tabTitle: string` - the extension tab title. -* `opts: Object` - additional options: - * `icon: string` - the class name the represents the icon from the [https://fontawesome.com/](https://fontawesome.com/) library (e.g. 'fa-calendar-alt'); +- `group: string` - the glob expression that matches the group of the resource; note: use globstar (`**`) to match all groups including empty string; +- `kind: string` - the glob expression that matches the kind of the resource; +- `tabTitle: string` - the extension tab title. +- `opts: Object` - additional options: + - `icon: string` - the class name the represents the icon from the [https://fontawesome.com/](https://fontawesome.com/) library (e.g. 'fa-calendar-alt'); Below is an example of a resource tab extension: ```javascript ((window) => { - const component = () => { - return React.createElement( 'div', {}, 'Hello World' ); - }; - window.extensionsAPI.registerResourceExtension(component, '*', '*', 'Nice extension'); -})(window) + const component = () => { + return React.createElement("div", {}, "Hello World"); + }; + window.extensionsAPI.registerResourceExtension( + component, + "*", + "*", + "Nice extension" + ); +})(window); +``` + +## System Level Extensions + +Argo CD allows you to add new items to the sidebar that will be displayed as a new page with a custom component when clicked. The system level extension should be registered using the `extensionsAPI.registerSystemLevelExtension` method: + +```typescript +registerSystemLevelExtension(component: ExtensionComponent, title: string, options: {icon?: string}) +``` + +Below is an example of a simple system level extension: + +```typescript +((window) => { + const component = () => { + return React.createElement( + "div", + { style: { padding: "10px" } }, + "Hello World" + ); + }; + window.extensionsAPI.registerSystemLevelExtension( + component, + "Test Ext", + "/hello", + "fa-flask" + ); +})(window); ``` ## Application Tab Extensions Since the Argo CD Application is a Kubernetes resource, application tabs can be the same as any other resource tab. -Make sure to use 'argoproj.io'/'Application' as group/kind and an extension will be used to render the application-level tab. \ No newline at end of file +Make sure to use 'argoproj.io'/'Application' as group/kind and an extension will be used to render the application-level tab. diff --git a/ui/src/app/app.tsx b/ui/src/app/app.tsx index c3c25e3ec4254..277adff0dfbd9 100644 --- a/ui/src/app/app.tsx +++ b/ui/src/app/app.tsx @@ -23,7 +23,9 @@ const base = bases.length > 0 ? bases[0].getAttribute('href') || '/' : '/'; export const history = createBrowserHistory({basename: base}); requests.setBaseHRef(base); -const routes: {[path: string]: {component: React.ComponentType>; noLayout?: boolean}} = { +type Routes = {[path: string]: {component: React.ComponentType>; noLayout?: boolean; extension?: boolean}}; + +const routes: Routes = { '/login': {component: login.component as any, noLayout: true}, '/applications': {component: applications.component}, '/settings': {component: settings.component}, @@ -31,7 +33,14 @@ const routes: {[path: string]: {component: React.ComponentType { } }); -export class App extends React.Component<{}, {popupProps: PopupProps; showVersionPanel: boolean; error: Error}> { +export class App extends React.Component<{}, {popupProps: PopupProps; showVersionPanel: boolean; error: Error; navItems: NavItem[]; routes: Routes; extensionsLoaded: boolean}> { public static childContextTypes = { history: PropTypes.object, apis: PropTypes.object @@ -110,13 +119,17 @@ export class App extends React.Component<{}, {popupProps: PopupProps; showVersio private popupManager: PopupManager; private notificationsManager: NotificationsManager; private navigationManager: NavigationManager; + private navItems: NavItem[]; + private routes: Routes; constructor(props: {}) { super(props); - this.state = {popupProps: null, error: null, showVersionPanel: false}; + this.state = {popupProps: null, error: null, showVersionPanel: false, navItems: [], routes: null, extensionsLoaded: false}; this.popupManager = new PopupManager(); this.notificationsManager = new NotificationsManager(); this.navigationManager = new NavigationManager(history); + this.navItems = navItems; + this.routes = routes; } public async componentDidMount() { @@ -144,6 +157,31 @@ export class App extends React.Component<{}, {popupProps: PopupProps; showVersio link.type = 'text/css'; document.head.appendChild(link); } + + const systemExtensions = services.extensions.getSystemExtensions(); + const extendedNavItems = this.navItems; + const extendedRoutes = this.routes; + for (const extension of systemExtensions) { + extendedNavItems.push({ + title: extension.title, + path: extension.path, + iconClassName: `fa ${extension.icon}` + }); + const component = () => ( + <> + + {extension.title} - Argo CD + + + + ); + extendedRoutes[extension.path] = { + component: component as React.ComponentType>, + extension: true + }; + } + + this.setState({...this.state, navItems: extendedNavItems, routes: extendedRoutes, extensionsLoaded: true}); } public render() { @@ -176,8 +214,8 @@ export class App extends React.Component<{}, {popupProps: PopupProps; showVersio - {Object.keys(routes).map(path => { - const route = routes[path]; + {Object.keys(this.routes).map(path => { + const route = this.routes[path]; return ( services.viewPreferences.getPreferences()}> {pref => ( - this.setState({showVersionPanel: true})} navItems={navItems} theme={pref.theme}> + this.setState({showVersionPanel: true})} + navItems={this.navItems} + pref={pref} + isExtension={route.extension}> @@ -202,7 +244,7 @@ export class App extends React.Component<{}, {popupProps: PopupProps; showVersio /> ); })} - + {this.state.extensionsLoaded && } diff --git a/ui/src/app/shared/components/layout/layout.scss b/ui/src/app/shared/components/layout/layout.scss index 9a9946a7c9bc8..ad01b89e65bf4 100644 --- a/ui/src/app/shared/components/layout/layout.scss +++ b/ui/src/app/shared/components/layout/layout.scss @@ -1,5 +1,6 @@ @import 'node_modules/argo-ui/src/styles/config'; @import 'node_modules/argo-ui/src/styles/theme'; +@import '../../config.scss'; .theme-light, .theme-dark { @@ -35,4 +36,18 @@ } } } + + &__content { + width: 100%; + } + + &--extension { + .cd-layout__content--sb-expanded { + padding-left: $sidebar-width; + } + + .cd-layout__content--sb-collapsed { + padding-left: $collapsed-sidebar-width; + } + } } diff --git a/ui/src/app/shared/components/layout/layout.tsx b/ui/src/app/shared/components/layout/layout.tsx index 938b410ec2a82..2b9c448f5150b 100644 --- a/ui/src/app/shared/components/layout/layout.tsx +++ b/ui/src/app/shared/components/layout/layout.tsx @@ -1,20 +1,22 @@ import * as React from 'react'; import {Sidebar} from '../../../sidebar/sidebar'; +import {ViewPreferences} from '../../services'; require('./layout.scss'); export interface LayoutProps { navItems: Array<{path: string; iconClassName: string; title: string}>; onVersionClick?: () => void; - theme?: string; children?: React.ReactNode; + pref: ViewPreferences; + isExtension?: boolean; } export const Layout = (props: LayoutProps) => ( -
-
- - {props.children} +
+
+ +
{props.children}
); diff --git a/ui/src/app/shared/services/extensions-service.ts b/ui/src/app/shared/services/extensions-service.ts index 60d9410f3bf70..8b7ea20b42e7c 100644 --- a/ui/src/app/shared/services/extensions-service.ts +++ b/ui/src/app/shared/services/extensions-service.ts @@ -4,13 +4,18 @@ import * as minimatch from 'minimatch'; import {Application, ApplicationTree, State} from '../models'; const extensions = { - resourceExtentions: new Array() + resourceExtentions: new Array(), + systemLevelExtensions: new Array() }; function registerResourceExtension(component: ExtensionComponent, group: string, kind: string, tabTitle: string, opts?: {icon: string}) { extensions.resourceExtentions.push({component, group, kind, title: tabTitle, icon: opts?.icon}); } +function registerSystemLevelExtension(component: ExtensionComponent, title: string, path: string, icon: string) { + extensions.systemLevelExtensions.push({component, title, icon, path}); +} + let legacyInitialized = false; function initLegacyExtensions() { @@ -33,7 +38,15 @@ export interface ResourceTabExtension { icon?: string; } +export interface SystemLevelExtension { + title: string; + component: SystemExtensionComponent; + icon?: string; + path?: string; +} + export type ExtensionComponent = React.ComponentType; +export type SystemExtensionComponent = React.ComponentType; export interface Extension { component: ExtensionComponent; @@ -51,12 +64,17 @@ export class ExtensionsService { const items = extensions.resourceExtentions.filter(extension => minimatch(group, extension.group) && minimatch(kind, extension.kind)).slice(); return items.sort((a, b) => a.title.localeCompare(b.title)); } + + public getSystemExtensions(): SystemLevelExtension[] { + return extensions.systemLevelExtensions.slice(); + } } ((window: any) => { // deprecated: kept for backwards compatibility window.extensions = {resources: {}}; window.extensionsAPI = { - registerResourceExtension + registerResourceExtension, + registerSystemLevelExtension }; })(window); diff --git a/ui/src/app/sidebar/sidebar.tsx b/ui/src/app/sidebar/sidebar.tsx index 2d4ae3b8584b5..54ec3008fc3ac 100644 --- a/ui/src/app/sidebar/sidebar.tsx +++ b/ui/src/app/sidebar/sidebar.tsx @@ -1,14 +1,15 @@ -import {DataLoader, Tooltip} from 'argo-ui'; +import {Tooltip} from 'argo-ui'; import {useData} from 'argo-ui/v2'; import * as React from 'react'; import {Context} from '../shared/context'; -import {services} from '../shared/services'; +import {services, ViewPreferences} from '../shared/services'; require('./sidebar.scss'); interface SidebarProps { onVersionClick: () => void; navItems: {path: string; iconClassName: string; title: string; tooltip?: string}[]; + pref: ViewPreferences; } export const SIDEBAR_TOOLS_ID = 'sidebar-tools'; @@ -33,45 +34,42 @@ export const Sidebar = (props: SidebarProps) => { const locationPath = context.history.location.pathname; return ( - services.viewPreferences.getPreferences()}> - {pref => ( -
-
- Argo {!pref.hideSidebar && 'Argo CD'} -
-
- {loading ? 'Loading...' : error?.state ? 'Unknown' : version?.Version || 'Unknown'} -
- {(props.navItems || []).map(item => ( - -
context.history.push(item.path)}> - -
- - {!pref.hideSidebar && item.title} -
-
+
+
+ Argo {!props.pref.hideSidebar && 'Argo CD'} +
+
+ {loading ? 'Loading...' : error?.state ? 'Unknown' : version?.Version || 'Unknown'} +
+ {(props.navItems || []).map(item => ( + +
context.history.push(item.path)}> + +
+ + {!props.pref.hideSidebar && item.title}
- - ))} -
services.viewPreferences.updatePreferences({...pref, hideSidebar: !pref.hideSidebar})} className='sidebar__collapse-button'> - +
-
-
- )} - + + ))} +
services.viewPreferences.updatePreferences({...props.pref, hideSidebar: !props.pref.hideSidebar})} className='sidebar__collapse-button'> + +
+
+
); };