Skip to content

Commit

Permalink
feat(app) an AdminPage for admin plugins
Browse files Browse the repository at this point in the history
This change adds a Administration navigation tab and page intended for
plugins that are for administration of an Janus/RHDH instance.  This
commit adds two routes /admin/rbac and /admin/plugins and related
mountpoints so that dynamic plugins can contribute UI components to this
page.  The page can also deal with a few edge cases and will also not be
visible should no plugins be configured to show up on it.

Signed-off-by: Stan Lewis <gashcrumb@gmail.com>
  • Loading branch information
gashcrumb committed Feb 5, 2024
1 parent 412418c commit 9b7df3a
Show file tree
Hide file tree
Showing 7 changed files with 403 additions and 6 deletions.
5 changes: 5 additions & 0 deletions .changeset/young-numbers-whisper.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'app': patch
---

Add an Administration tab and related AdminPage component to act as a holder for dynamic plugins that focus on administrative tasks
17 changes: 17 additions & 0 deletions dynamic-plugins.default.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -498,6 +498,23 @@ plugins:
- package: ./dynamic-plugins/dist/janus-idp-backstage-scaffolder-backend-module-quay-dynamic

- package: ./dynamic-plugins/dist/janus-idp-backstage-scaffolder-backend-module-regex-dynamic

- package: ./dynamic-plugins/dist/janus-idp-backstage-plugin-dynamic-plugins-info
disabled: true
pluginConfig:
dynamicPlugins:
frontend:
janus-idp.backstage-plugin-dynamic-plugins-info:
dynamicRoutes:
- path: /admin/plugins
importName: DynamicPluginsInfo
mountPoints:
- mountPoint: admin.page.plugins/cards
importName: DynamicPluginsInfo
config:
layout:
gridColumn: "1 / -1"
width: 100vw

- package: ./dynamic-plugins/dist/janus-idp-plugin-rbac
disabled: true
Expand Down
1 change: 1 addition & 0 deletions dynamic-plugins/imports/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"@janus-idp/backstage-plugin-aap-backend": "1.4.12",
"@janus-idp/backstage-plugin-acr": "1.2.21",
"@janus-idp/backstage-plugin-analytics-provider-segment": "1.2.5",
"@janus-idp/backstage-plugin-dynamic-plugins-info": "1.0.0",
"@janus-idp/backstage-plugin-jfrog-artifactory": "1.2.21",
"@janus-idp/backstage-plugin-keycloak-backend": "1.7.13",
"@janus-idp/backstage-plugin-nexus-repository-manager": "1.4.21",
Expand Down
11 changes: 11 additions & 0 deletions packages/app/src/components/AppBase/AppBase.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { Route } from 'react-router-dom';
import DynamicRootContext from '../DynamicRoot/DynamicRootContext';
import { Root } from '../Root';
import { settingsPage } from '../UserSettings/SettingsPages';
import { AdminPage } from '../admin/AdminPage';
import { entityPage } from '../catalog/EntityPage';
import { HomePage } from '../home/HomePage';
import { LearningPaths } from '../learningPaths/LearningPathsPage';
Expand Down Expand Up @@ -67,6 +68,16 @@ const AppBase = () => {
</Route>
<Route path="/catalog-graph" element={<CatalogGraphPage />} />
<Route path="/learning-paths" element={<LearningPaths />} />
<Route path="/admin" element={<AdminPage />} />
{dynamicRoutes
.filter(({ path }) => path.startsWith('/admin'))
.map(({ path }) => (
<Route
key={`admin-path-${path}`}
path={path}
element={<AdminPage />}
/>
))}
{dynamicRoutes.map(
({ Component, staticJSXContent, path, config: { props } }) => (
<Route
Expand Down
24 changes: 18 additions & 6 deletions packages/app/src/components/Root/Root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
UserSettingsSignInAvatar,
} from '@backstage/plugin-user-settings';
import CreateComponentIcon from '@mui/icons-material/AddCircleOutline';
import AdminPanelSettingsOutlinedIcon from '@mui/icons-material/AdminPanelSettingsOutlined';
import AppsIcon from '@mui/icons-material/Apps';
import ExtensionIcon from '@mui/icons-material/Extension';
import HomeIcon from '@mui/icons-material/Home';
Expand All @@ -23,7 +24,7 @@ import { makeStyles } from 'tss-react/mui';
import React, { PropsWithChildren, useContext } from 'react';
import { SidebarLogo } from './SidebarLogo';
import DynamicRootContext from '../DynamicRoot/DynamicRootContext';
import { useApp } from '@backstage/core-plugin-api';
import { IconComponent, useApp } from '@backstage/core-plugin-api';

const useStyles = makeStyles()({
sidebarItem: {
Expand Down Expand Up @@ -57,7 +58,7 @@ export const MenuIcon = ({ icon }: { icon: string }) => {
};

export const Root = ({ children }: PropsWithChildren<{}>) => {
const { dynamicRoutes } = useContext(DynamicRootContext);
const { dynamicRoutes, mountPoints } = useContext(DynamicRootContext);
return (
<SidebarPage>
<Sidebar>
Expand All @@ -70,22 +71,22 @@ export const Root = ({ children }: PropsWithChildren<{}>) => {
{/* Global nav, not org-specific */}
<SideBarItemWrapper icon={HomeIcon as any} to="/" text="Home" />
<SideBarItemWrapper
icon={AppsIcon as any}
icon={AppsIcon as IconComponent}
to="catalog"
text="Catalog"
/>
<SideBarItemWrapper
icon={ExtensionIcon as any}
icon={ExtensionIcon as IconComponent}
to="api-docs"
text="APIs"
/>
<SideBarItemWrapper
icon={SchoolIcon as any}
icon={SchoolIcon as IconComponent}
to="learning-paths"
text="Learning Paths"
/>
<SideBarItemWrapper
icon={CreateComponentIcon as any}
icon={CreateComponentIcon as IconComponent}
to="create"
text="Create..."
/>
Expand All @@ -108,6 +109,17 @@ export const Root = ({ children }: PropsWithChildren<{}>) => {
</SidebarGroup>
<SidebarSpace />
<SidebarDivider />
{Object.keys(mountPoints).some(scope =>
scope.startsWith('admin.page'),
) ? (
<SideBarItemWrapper
icon={AdminPanelSettingsOutlinedIcon as IconComponent}
to="/admin"
text="Administration"
/>
) : (
<></>
)}
<SidebarGroup
label="Settings"
icon={<UserSettingsSignInAvatar />}
Expand Down
261 changes: 261 additions & 0 deletions packages/app/src/components/admin/AdminPage.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,261 @@
import React, { Fragment } from 'react';

import initializeRemotePlugins from '../../utils/dynamicUI/initializeRemotePlugins';
import { createPlugin, createRouteRef } from '@backstage/core-plugin-api';
import { removeScalprum } from '@scalprum/core';
import * as useAsync from 'react-use/lib/useAsync';
import { renderWithEffects } from '@backstage/test-utils';
import AppBase from '../AppBase/AppBase';
import { act } from 'react-dom/test-utils';

const DynamicRoot = React.lazy(() => import('../DynamicRoot/DynamicRoot'));

const MockApp = () => (
<React.Suspense fallback={null}>
<DynamicRoot
apis={[]}
afterInit={async () =>
Promise.resolve({
default: () => <AppBase />,
})
}
/>
</React.Suspense>
);

// Swap out the app's BrowserRouter and provide tests a
// means to set the initial history
let initialEntries = ['/'];

const reactRouter = require('react-router-dom');

const { MemoryRouter } = reactRouter;

const MockRouter = ({ children }: any) => (
<MemoryRouter initialEntries={[...initialEntries]}>{children}</MemoryRouter>
);
MockRouter.propTypes = { ...MemoryRouter.propTypes };
reactRouter.BrowserRouter = MockRouter;

jest.mock('@scalprum/core', () => ({
...jest.requireActual('@scalprum/core'),
getScalprum: jest.fn().mockReturnValue({ api: {} }),
}));

jest.mock('@scalprum/react-core', () => ({
...jest.requireActual('@scalprum/react-core'),
ScalprumProvider: jest
.fn()
.mockImplementation(({ children }) => <>{children}</>),
useScalprum: jest
.fn()
.mockReturnValue({ initialized: true, pluginStore: [] }),
}));

jest.mock('react-use/lib/useAsync', () => ({
default: () => ({}),
__esModule: true,
}));

jest.mock('@backstage/app-defaults', () => ({
...jest.requireActual('@backstage/app-defaults'),
__esModule: true,
}));

// Remove the sign-in page
jest.mock('../DynamicRoot/defaultAppComponents', () => ({
default: {},
__esModule: true,
}));

// Simplify the home page
jest.mock('../home/HomePage', () => ({
HomePage: () => <></>,
__esModule: true,
}));

// Ensure the correct configuration is picked up by the rendered app
jest.mock('@backstage/config', () => {
const oldModule = jest.requireActual('@backstage/config');
const OldConfigReader = oldModule.ConfigReader;
const FakeConfigReader = class {
_instance: any = undefined;
constructor(args: any) {
this._instance = new OldConfigReader(args);
}
static fromConfigs(args: any) {
const answer = OldConfigReader.fromConfigs([
...[Array.isArray(args) ? args : []],
...(process.env.APP_CONFIG as any),
]);
return answer;
}
};
return {
...oldModule,
ConfigReader: FakeConfigReader,
__esModule: true,
};
});

const mockInitializeRemotePlugins = jest.fn() as jest.MockedFunction<
typeof initializeRemotePlugins
>;
jest.mock('../../utils/dynamicUI/initializeRemotePlugins', () => ({
default: mockInitializeRemotePlugins,
__esModule: true,
}));

const mockProcessEnv = (dynamicPluginsConfig: { [key: string]: any }) => ({
NODE_ENV: 'test',
APP_CONFIG: [
{
data: {
app: { title: 'Test' },
backend: { baseUrl: 'http://localhost:7007' },
techdocs: {
storageUrl: 'http://localhost:7007/api/techdocs/static/docs',
},
auth: { environment: 'development' },
dynamicPlugins: {
frontend: dynamicPluginsConfig,
},
},
context: 'test',
},
] as any,
});

const consoleSpy = jest.spyOn(console, 'warn');

describe('AdminPage', () => {
beforeEach(() => {
removeScalprum();
mockInitializeRemotePlugins.mockResolvedValue({
'test-plugin': {
PluginRoot: {
default: Fragment,
testPlugin: createPlugin({
id: 'test-plugin',
routes: { root: createRouteRef({ id: 'test-plugin' }) },
}),
TestComponent: Fragment,
isTestConditionTrue: () => true,
isTestConditionFalse: () => false,
TestComponentWithStaticJSX: {
element: ({ children }) => <>{children}</>,
staticJSXContent: <div />,
},
},
},
});
jest
.spyOn(useAsync, 'default')
.mockReturnValue({ loading: false, value: {} });
});

afterEach(() => {
consoleSpy.mockReset();
});

it('Should not be available when not configured', async () => {
process.env = mockProcessEnv({
'test-plugin': {
dynamicRoutes: [],
mountPoints: [],
},
});
initialEntries = ['/'];
const rendered = await renderWithEffects(<MockApp />);
expect(rendered.baseElement).toBeInTheDocument();
const home = rendered.queryByText('Home');
const administration = rendered.queryByText('Administration');
expect(home).not.toBeNull();
expect(administration).toBeNull();
});

it('Should be available when configured', async () => {
process.env = mockProcessEnv({
'test-plugin': {
dynamicRoutes: [{ path: '/admin/plugins' }],
mountPoints: [{ mountPoint: 'admin.page.plugins/cards' }],
},
});
initialEntries = ['/'];
const rendered = await renderWithEffects(<MockApp />);
expect(rendered.baseElement).toBeInTheDocument();
const home = rendered.queryByText('Home');
const administration = rendered.queryByText('Administration');
expect(home).not.toBeNull();
expect(administration).not.toBeNull();
});

it('Should route to the plugin tab when configured', async () => {
process.env = mockProcessEnv({
'test-plugin': {
dynamicRoutes: [{ path: '/admin/plugins' }],
mountPoints: [{ mountPoint: 'admin.page.plugins/cards' }],
},
});
initialEntries = ['/'];
const rendered = await renderWithEffects(<MockApp />);
expect(rendered.baseElement).toBeInTheDocument();
await act(() => {
rendered.getByText('Administration').click();
});
const plugins = rendered.queryByText('Plugins');
expect(plugins).not.toBeNull();
});

it('Should route to the rbac tab when configured', async () => {
process.env = mockProcessEnv({
'test-plugin': {
dynamicRoutes: [{ path: '/admin/rbac' }],
mountPoints: [{ mountPoint: 'admin.page.rbac/cards' }],
},
});
initialEntries = ['/'];
const rendered = await renderWithEffects(<MockApp />);
expect(rendered.baseElement).toBeInTheDocument();
await act(() => {
rendered.getByText('Administration').click();
});
const rbac = rendered.queryByText('RBAC');
expect(rbac).not.toBeNull();
});

it("Should fail back to the default tab if the currently routed tab doesn't match the configuration", async () => {
process.env = mockProcessEnv({
'test-plugin': {
dynamicRoutes: [{ path: '/admin/rbac' }],
mountPoints: [{ mountPoint: 'admin.page.rbac/cards' }],
},
});
initialEntries = ['/admin/plugins'];
const rendered = await renderWithEffects(<MockApp />);
// When debugging this test it can be handy to see the entire rendered output
// process.stdout.write(`${prettyDOM(rendered.baseElement, 900000)}`);
expect(rendered.baseElement).toBeInTheDocument();
expect(rendered.getByText('RBAC')).toBeInTheDocument();
});

it('Should fail with an error page if routed to but no configuration is defined', async () => {
process.env = mockProcessEnv({
'test-plugin': {
dynamicRoutes: [],
mountPoints: [],
},
});
initialEntries = ['/admin/plugins'];
const rendered = await renderWithEffects(<MockApp />);
// When debugging this test it can be handy to see the entire rendered output
// process.stdout.write(`${prettyDOM(rendered.baseElement, 900000)}`);
expect(rendered.baseElement).toBeInTheDocument();
const errorComponent = rendered.getByTestId('error');
expect(
errorComponent!.textContent!.indexOf(
'No admin mount points are configured',
) !== -1,
).toBeTruthy();
});
});
Loading

0 comments on commit 9b7df3a

Please sign in to comment.