Skip to content

Commit

Permalink
feat: system level extensions (#10758)
Browse files Browse the repository at this point in the history
* feat: sidebar extensions

Signed-off-by: Remington Breeze <remington@breeze.software>
  • Loading branch information
rbreeze authored Oct 3, 2022
1 parent 76fe1d5 commit d211113
Show file tree
Hide file tree
Showing 6 changed files with 182 additions and 74 deletions.
71 changes: 52 additions & 19 deletions docs/developer-guide/ui-extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Make sure to use 'argoproj.io'/'Application' as group/kind and an extension will be used to render the application-level tab.
58 changes: 50 additions & 8 deletions ui/src/app/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,24 @@ 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<RouteComponentProps<any>>; noLayout?: boolean}} = {
type Routes = {[path: string]: {component: React.ComponentType<RouteComponentProps<any>>; noLayout?: boolean; extension?: boolean}};

const routes: Routes = {
'/login': {component: login.component as any, noLayout: true},
'/applications': {component: applications.component},
'/settings': {component: settings.component},
'/user-info': {component: userInfo.component},
'/help': {component: help.component}
};

const navItems = [
interface NavItem {
title: string;
tooltip?: string;
path: string;
iconClassName: string;
}

const navItems: NavItem[] = [
{
title: 'Applications',
tooltip: 'Manage your applications, and diagnose health problems.',
Expand Down Expand Up @@ -97,7 +106,7 @@ requests.onError.subscribe(async err => {
}
});

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
Expand All @@ -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() {
Expand Down Expand Up @@ -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 = () => (
<>
<Helmet>
<title>{extension.title} - Argo CD</title>
</Helmet>
<extension.component />
</>
);
extendedRoutes[extension.path] = {
component: component as React.ComponentType<React.ComponentProps<any>>,
extension: true
};
}

this.setState({...this.state, navItems: extendedNavItems, routes: extendedRoutes, extensionsLoaded: true});
}

public render() {
Expand Down Expand Up @@ -176,8 +214,8 @@ export class App extends React.Component<{}, {popupProps: PopupProps; showVersio
<Router history={history}>
<Switch>
<Redirect exact={true} path='/' to='/applications' />
{Object.keys(routes).map(path => {
const route = routes[path];
{Object.keys(this.routes).map(path => {
const route = this.routes[path];
return (
<Route
key={path}
Expand All @@ -190,7 +228,11 @@ export class App extends React.Component<{}, {popupProps: PopupProps; showVersio
) : (
<DataLoader load={() => services.viewPreferences.getPreferences()}>
{pref => (
<Layout onVersionClick={() => this.setState({showVersionPanel: true})} navItems={navItems} theme={pref.theme}>
<Layout
onVersionClick={() => this.setState({showVersionPanel: true})}
navItems={this.navItems}
pref={pref}
isExtension={route.extension}>
<Banner>
<route.component {...routeProps} />
</Banner>
Expand All @@ -202,7 +244,7 @@ export class App extends React.Component<{}, {popupProps: PopupProps; showVersio
/>
);
})}
<Redirect path='*' to='/' />
{this.state.extensionsLoaded && <Redirect path='*' to='/' />}
</Switch>
</Router>
</Provider>
Expand Down
15 changes: 15 additions & 0 deletions ui/src/app/shared/components/layout/layout.scss
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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;
}
}
}
12 changes: 7 additions & 5 deletions ui/src/app/shared/components/layout/layout.tsx
Original file line number Diff line number Diff line change
@@ -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) => (
<div className={props.theme ? 'theme-' + props.theme : 'theme-light'}>
<div className='cd-layout'>
<Sidebar onVersionClick={props.onVersionClick} navItems={props.navItems} />
{props.children}
<div className={props.pref.theme ? 'theme-' + props.pref.theme : 'theme-light'}>
<div className={`cd-layout ${props.isExtension ? 'cd-layout--extension' : ''}`}>
<Sidebar onVersionClick={props.onVersionClick} navItems={props.navItems} pref={props.pref} />
<div className={`cd-layout__content ${props.pref.hideSidebar ? 'cd-layout__content--sb-collapsed' : 'cd-layout__content--sb-expanded'}`}>{props.children}</div>
</div>
</div>
);
22 changes: 20 additions & 2 deletions ui/src/app/shared/services/extensions-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,18 @@ import * as minimatch from 'minimatch';
import {Application, ApplicationTree, State} from '../models';

const extensions = {
resourceExtentions: new Array<ResourceTabExtension>()
resourceExtentions: new Array<ResourceTabExtension>(),
systemLevelExtensions: new Array<SystemLevelExtension>()
};

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() {
Expand All @@ -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<ExtensionComponentProps>;
export type SystemExtensionComponent = React.ComponentType;

export interface Extension {
component: ExtensionComponent;
Expand All @@ -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);
Loading

0 comments on commit d211113

Please sign in to comment.