diff --git a/.sonarcloud.properties b/.sonarcloud.properties new file mode 100644 index 0000000000..30f2a8d985 --- /dev/null +++ b/.sonarcloud.properties @@ -0,0 +1,2 @@ +# comma delimited path of files to exclude from copy/paste duplicate checking +sonar.cpd.exclusions=packages/app/src/components/catalog/EntityPage/defaultTabs.tsx diff --git a/packages/app/config.d.ts b/packages/app/config.d.ts index b2693d63a7..f093784c97 100644 --- a/packages/app/config.d.ts +++ b/packages/app/config.d.ts @@ -87,6 +87,11 @@ export interface Config { }; }[]; }; + entityTabs?: { + path: string; + title: string; + mountPoint: string; + }[]; mountPoints?: { mountPoint: string; module?: string; diff --git a/packages/app/src/components/AppBase/AppBase.tsx b/packages/app/src/components/AppBase/AppBase.tsx index b47521b89d..155a915881 100644 --- a/packages/app/src/components/AppBase/AppBase.tsx +++ b/packages/app/src/components/AppBase/AppBase.tsx @@ -22,7 +22,7 @@ import { LearningPaths } from '../learningPaths/LearningPathsPage'; import { SearchPage } from '../search/SearchPage'; const AppBase = () => { - const { AppProvider, AppRouter, dynamicRoutes } = + const { AppProvider, AppRouter, dynamicRoutes, entityTabOverrides } = useContext(DynamicRootContext); return ( @@ -41,7 +41,7 @@ const AppBase = () => { path="/catalog/:namespace/:kind/:name" element={} > - {entityPage} + {entityPage(entityTabOverrides)} import('./DynamicRoot')); @@ -67,7 +68,9 @@ const MockPage = () => { ); }; -const MockApp = () => ( +const loadAppConfig = async () => await defaultConfigLoader(); + +const MockApp = ({ appConfig }: { appConfig: AppConfig[] }) => ( ( }, }) } + appConfig={appConfig} + baseFrontendConfig={{ + context: '', + data: {}, + }} + scalprumConfig={{}} /> ); @@ -184,7 +193,8 @@ describe('DynamicRoot', () => { process.env = mockProcessEnv({ 'foo.bar': { dynamicRoutes: [{ path: '/foo' }] }, }); - const rendered = await renderWithEffects(); + const appConfig = await loadAppConfig(); + const rendered = await renderWithEffects(); await waitFor(async () => { expect(rendered.baseElement).toBeInTheDocument(); expect(rendered.getByTestId('isLoadingFinished')).toBeInTheDocument(); @@ -198,7 +208,8 @@ describe('DynamicRoot', () => { process.env = mockProcessEnv({ 'foo.bar': { dynamicRoutes: [{ path: '/foo' }, { path: '/bar' }] }, }); - const rendered = await renderWithEffects(); + const appConfig = await loadAppConfig(); + const rendered = await renderWithEffects(); await waitFor(async () => { expect(rendered.baseElement).toBeInTheDocument(); expect(rendered.getByTestId('isLoadingFinished')).toBeInTheDocument(); @@ -212,7 +223,8 @@ describe('DynamicRoot', () => { process.env = mockProcessEnv({ 'doesnt.exist': { dynamicRoutes: [{ path: '/foo' }] }, }); - const rendered = await renderWithEffects(); + const appConfig = await loadAppConfig(); + const rendered = await renderWithEffects(); await waitFor(async () => { expect(rendered.baseElement).toBeInTheDocument(); expect(rendered.getByTestId('isLoadingFinished')).toBeInTheDocument(); @@ -231,7 +243,8 @@ describe('DynamicRoot', () => { dynamicRoutes: [{ path: '/foo', importName: 'BarComponent' }], }, }); - const rendered = await renderWithEffects(); + const appConfig = await loadAppConfig(); + const rendered = await renderWithEffects(); await waitFor(async () => { expect(rendered.baseElement).toBeInTheDocument(); expect(rendered.getByTestId('isLoadingFinished')).toBeInTheDocument(); @@ -248,7 +261,8 @@ describe('DynamicRoot', () => { process.env = mockProcessEnv({ 'foo.bar': { dynamicRoutes: [{ path: '/foo', module: 'BarPlugin' }] }, }); - const rendered = await renderWithEffects(); + const appConfig = await loadAppConfig(); + const rendered = await renderWithEffects(); await waitFor(async () => { expect(rendered.baseElement).toBeInTheDocument(); expect(rendered.getByTestId('isLoadingFinished')).toBeInTheDocument(); @@ -269,7 +283,8 @@ describe('DynamicRoot', () => { ], }, }); - const rendered = await renderWithEffects(); + const appConfig = await loadAppConfig(); + const rendered = await renderWithEffects(); await waitFor(async () => { expect(rendered.baseElement).toBeInTheDocument(); expect(rendered.getByTestId('isLoadingFinished')).toBeInTheDocument(); @@ -285,7 +300,8 @@ describe('DynamicRoot', () => { process.env = mockProcessEnv({ 'foo.bar': { mountPoints: [{ mountPoint: 'a.b.c/cards' }] }, }); - const rendered = await renderWithEffects(); + const appConfig = await loadAppConfig(); + const rendered = await renderWithEffects(); await waitFor(async () => { expect(rendered.baseElement).toBeInTheDocument(); expect(rendered.getByTestId('isLoadingFinished')).toBeInTheDocument(); @@ -304,7 +320,8 @@ describe('DynamicRoot', () => { ], }, }); - const rendered = await renderWithEffects(); + const appConfig = await loadAppConfig(); + const rendered = await renderWithEffects(); await waitFor(async () => { expect(rendered.baseElement).toBeInTheDocument(); expect(rendered.getByTestId('isLoadingFinished')).toBeInTheDocument(); @@ -323,7 +340,8 @@ describe('DynamicRoot', () => { ], }, }); - const rendered = await renderWithEffects(); + const appConfig = await loadAppConfig(); + const rendered = await renderWithEffects(); await waitFor(async () => { expect(rendered.baseElement).toBeInTheDocument(); expect(rendered.getByTestId('isLoadingFinished')).toBeInTheDocument(); @@ -347,7 +365,8 @@ describe('DynamicRoot', () => { ], }, }); - const rendered = await renderWithEffects(); + const appConfig = await loadAppConfig(); + const rendered = await renderWithEffects(); await waitFor(async () => { expect(rendered.baseElement).toBeInTheDocument(); expect(rendered.getByTestId('isLoadingFinished')).toBeInTheDocument(); @@ -370,7 +389,8 @@ describe('DynamicRoot', () => { ], }, }); - const rendered = await renderWithEffects(); + const appConfig = await loadAppConfig(); + const rendered = await renderWithEffects(); await waitFor(async () => { expect(rendered.baseElement).toBeInTheDocument(); expect(rendered.getByTestId('isLoadingFinished')).toBeInTheDocument(); @@ -393,7 +413,8 @@ describe('DynamicRoot', () => { ], }, }); - const rendered = await renderWithEffects(); + const appConfig = await loadAppConfig(); + const rendered = await renderWithEffects(); await waitFor(async () => { expect(rendered.baseElement).toBeInTheDocument(); expect(rendered.getByTestId('isLoadingFinished')).toBeInTheDocument(); @@ -414,7 +435,8 @@ describe('DynamicRoot', () => { ], }, }); - const rendered = await renderWithEffects(); + const appConfig = await loadAppConfig(); + const rendered = await renderWithEffects(); await waitFor(async () => { expect(rendered.baseElement).toBeInTheDocument(); expect(rendered.getByTestId('isLoadingFinished')).toBeInTheDocument(); @@ -430,7 +452,8 @@ describe('DynamicRoot', () => { process.env = mockProcessEnv({ 'doesnt.exist': { mountPoints: [{ mountPoint: 'a.b.c/cards' }] }, }); - const rendered = await renderWithEffects(); + const appConfig = await loadAppConfig(); + const rendered = await renderWithEffects(); await waitFor(async () => { expect(rendered.baseElement).toBeInTheDocument(); expect(rendered.getByTestId('isLoadingFinished')).toBeInTheDocument(); @@ -451,7 +474,8 @@ describe('DynamicRoot', () => { ], }, }); - const rendered = await renderWithEffects(); + const appConfig = await loadAppConfig(); + const rendered = await renderWithEffects(); await waitFor(async () => { expect(rendered.baseElement).toBeInTheDocument(); expect(rendered.getByTestId('isLoadingFinished')).toBeInTheDocument(); @@ -470,7 +494,8 @@ describe('DynamicRoot', () => { mountPoints: [{ mountPoint: 'a.b.c/cards', module: 'BarPlugin' }], }, }); - const rendered = await renderWithEffects(); + const appConfig = await loadAppConfig(); + const rendered = await renderWithEffects(); await waitFor(async () => { expect(rendered.baseElement).toBeInTheDocument(); expect(rendered.getByTestId('isLoadingFinished')).toBeInTheDocument(); @@ -494,7 +519,8 @@ describe('DynamicRoot', () => { ], }, }); - const rendered = await renderWithEffects(); + const appConfig = await loadAppConfig(); + const rendered = await renderWithEffects(); await waitFor(async () => { expect(rendered.baseElement).toBeInTheDocument(); expect(rendered.getByTestId('isLoadingFinished')).toBeInTheDocument(); @@ -510,7 +536,8 @@ describe('DynamicRoot', () => { process.env = mockProcessEnv({ 'foo.bar': { appIcons: [{ name: 'fooIcon' }] }, }); - const rendered = await renderWithEffects(); + const appConfig = await loadAppConfig(); + const rendered = await renderWithEffects(); await waitFor(async () => { expect(rendered.baseElement).toBeInTheDocument(); expect(rendered.getByTestId('isLoadingFinished')).toBeInTheDocument(); @@ -524,7 +551,8 @@ describe('DynamicRoot', () => { process.env = mockProcessEnv({ 'foo.bar': { appIcons: [{ name: 'fooIcon' }, { name: 'foo2Icon' }] }, }); - const rendered = await renderWithEffects(); + const appConfig = await loadAppConfig(); + const rendered = await renderWithEffects(); await waitFor(async () => { expect(rendered.baseElement).toBeInTheDocument(); expect(rendered.getByTestId('isLoadingFinished')).toBeInTheDocument(); @@ -541,7 +569,8 @@ describe('DynamicRoot', () => { process.env = mockProcessEnv({ 'doesnt.exist': { appIcons: [{ name: 'fooIcon' }] }, }); - const rendered = await renderWithEffects(); + const appConfig = await loadAppConfig(); + const rendered = await renderWithEffects(); await waitFor(async () => { expect(rendered.baseElement).toBeInTheDocument(); expect(rendered.getByTestId('isLoadingFinished')).toBeInTheDocument(); @@ -572,7 +601,8 @@ describe('DynamicRoot', () => { }, }, }); - const rendered = await renderWithEffects(); + const appConfig = await loadAppConfig(); + const rendered = await renderWithEffects(); await waitFor(async () => { expect(rendered.baseElement).toBeInTheDocument(); expect(rendered.getByTestId('isLoadingFinished')).toBeInTheDocument(); @@ -612,7 +642,8 @@ describe('DynamicRoot', () => { }, }, }); - const rendered = await renderWithEffects(); + const appConfig = await loadAppConfig(); + const rendered = await renderWithEffects(); await waitFor(async () => { expect(rendered.baseElement).toBeInTheDocument(); expect(rendered.getByTestId('isLoadingFinished')).toBeInTheDocument(); @@ -650,7 +681,8 @@ describe('DynamicRoot', () => { }, }, }); - const rendered = await renderWithEffects(); + const appConfig = await loadAppConfig(); + const rendered = await renderWithEffects(); await waitFor(async () => { expect(rendered.baseElement).toBeInTheDocument(); expect(rendered.getByTestId('isLoadingFinished')).toBeInTheDocument(); @@ -667,7 +699,8 @@ describe('DynamicRoot', () => { apiFactories: [{ importName: 'fooPluginApi' }], }, }); - const rendered = await renderWithEffects(); + const appConfig = await loadAppConfig(); + const rendered = await renderWithEffects(); await waitFor(async () => { expect(rendered.baseElement).toBeInTheDocument(); expect(rendered.getByTestId('isLoadingFinished')).toBeInTheDocument(); @@ -687,7 +720,8 @@ describe('DynamicRoot', () => { apiFactories: [{ importName: 'barPluginApi' }], }, }); - const rendered = await renderWithEffects(); + const appConfig = await loadAppConfig(); + const rendered = await renderWithEffects(); await waitFor(async () => { expect(rendered.baseElement).toBeInTheDocument(); expect(rendered.getByTestId('isLoadingFinished')).toBeInTheDocument(); @@ -706,7 +740,8 @@ describe('DynamicRoot', () => { apiFactories: [{ importName: 'fooPluginApi', module: 'BarPlugin' }], }, }); - const rendered = await renderWithEffects(); + const appConfig = await loadAppConfig(); + const rendered = await renderWithEffects(); await waitFor(async () => { expect(rendered.baseElement).toBeInTheDocument(); expect(rendered.getByTestId('isLoadingFinished')).toBeInTheDocument(); diff --git a/packages/app/src/components/DynamicRoot/DynamicRoot.tsx b/packages/app/src/components/DynamicRoot/DynamicRoot.tsx index d6640f0f93..8b86673215 100644 --- a/packages/app/src/components/DynamicRoot/DynamicRoot.tsx +++ b/packages/app/src/components/DynamicRoot/DynamicRoot.tsx @@ -1,18 +1,12 @@ /* eslint-disable @typescript-eslint/no-shadow */ import React, { useCallback, useEffect, useRef, useState } from 'react'; - import { createApp } from '@backstage/app-defaults'; -import { - BackstageApp, - ConfigReader, - defaultConfigLoader, -} from '@backstage/core-app-api'; +import { BackstageApp } from '@backstage/core-app-api'; import { AnyApiFactory, BackstagePlugin } from '@backstage/core-plugin-api'; - import { AppsConfig, getScalprum } from '@scalprum/core'; -import { ScalprumProvider, useScalprum } from '@scalprum/react-core'; - +import { useScalprum } from '@scalprum/react-core'; import DynamicRootContext, { + ComponentRegistry, DynamicRootContextValue, RemotePlugins, ScalprumMountPoint, @@ -25,8 +19,6 @@ import initializeRemotePlugins from '../../utils/dynamicUI/initializeRemotePlugi import defaultThemes from './defaultThemes'; import defaultAppComponents from './defaultAppComponents'; import bindAppRoutes from '../../utils/dynamicUI/bindAppRoutes'; -import overrideBaseUrlConfigs from '../../utils/dynamicUI/overrideBaseUrlConfigs'; -import useAsync from 'react-use/lib/useAsync'; import Loader from './Loader'; import { AppConfig } from '@backstage/config'; @@ -40,9 +32,12 @@ export type StaticPlugins = Record< } >; -const DynamicRoot = ({ +type EntityTabMap = Record; + +export const DynamicRoot = ({ afterInit, apis: staticApis, + appConfig, baseFrontendConfig, staticPluginStore = {}, scalprumConfig, @@ -50,7 +45,8 @@ const DynamicRoot = ({ afterInit: () => Promise<{ default: React.ComponentType }>; // Static APIs apis: AnyApiFactory[]; - baseFrontendConfig?: AppConfig; + appConfig: AppConfig[]; + baseFrontendConfig: AppConfig; staticPluginStore?: StaticPlugins; scalprumConfig: AppsConfig; }) => { @@ -59,27 +55,23 @@ const DynamicRoot = ({ React.ComponentType | undefined >(undefined); // registry of remote components loaded at bootstrap - const [components, setComponents] = useState< - | { - AppProvider: React.ComponentType; - AppRouter: React.ComponentType; - dynamicRoutes: DynamicRootContextValue[]; - mountPoints: { [mountPoint: string]: ScalprumMountPoint[] }; - } - | undefined - >(); + const [components, setComponents] = useState(); const { initialized, pluginStore } = useScalprum(); // Fills registry of remote components const initializeRemoteModules = useCallback(async () => { const { + apiFactories, + appIcons, dynamicRoutes, + entityTabs, mountPoints, routeBindings, - appIcons, routeBindingTargets, - apiFactories, - } = await extractDynamicConfig(baseFrontendConfig); + } = await extractDynamicConfig({ + frontendAppConfig: baseFrontendConfig, + appConfig, + }); const requiredModules = [ ...routeBindingTargets.map(({ scope, module }) => ({ @@ -174,21 +166,6 @@ const DynamicRoot = ({ [], ); - if (!app.current) { - app.current = createApp({ - apis: [...staticApis, ...remoteApis], - bindRoutes({ bind }) { - bindAppRoutes(bind, resolvedRouteBindingTargets, routeBindings); - }, - icons, - plugins: Object.keys(staticPluginStore).map( - key => staticPluginStore[key].plugin, - ), - themes: defaultThemes, - components: defaultAppComponents, - }); - } - const providerMountPoints = mountPoints.reduce< { mountPoint: string; @@ -288,11 +265,41 @@ const DynamicRoot = ({ return acc; }, []); + const entityTabOverrides: EntityTabMap = entityTabs.reduce( + (acc: EntityTabMap, { path, title, mountPoint, scope }) => { + if (acc[path]) { + // eslint-disable-next-line no-console + console.warn( + `Plugin ${scope} is not configured properly: a tab has already been configured for "${path}", ignoring entry with title: "${title}" and mountPoint: "${mountPoint}"`, + ); + } else { + acc[path] = { title, mountPoint }; + } + return acc; + }, + {} as EntityTabMap, + ); + if (!app.current) { + const fullConfig = [baseFrontendConfig, ...appConfig]; + app.current = createApp({ + apis: [...staticApis, ...remoteApis], + bindRoutes({ bind }) { + bindAppRoutes(bind, resolvedRouteBindingTargets, routeBindings); + }, + icons, + plugins: Object.values(staticPluginStore).map(entry => entry.plugin), + themes: defaultThemes, + components: defaultAppComponents, + configLoader: async () => Promise.resolve(fullConfig), + }); + } + setComponents({ AppProvider: app.current.getProvider(), AppRouter: app.current.getRouter(), dynamicRoutes: dynamicRoutesComponents, mountPoints: mountPointComponents, + entityTabOverrides, }); afterInit().then(({ default: Component }) => { @@ -300,6 +307,7 @@ const DynamicRoot = ({ }); }, [ afterInit, + appConfig, baseFrontendConfig, pluginStore, scalprumConfig, @@ -324,65 +332,4 @@ const DynamicRoot = ({ ); }; -const ScalprumRoot = ({ - apis, - afterInit, - baseFrontendConfig, - plugins, -}: { - // Static APIs - apis: AnyApiFactory[]; - afterInit: () => Promise<{ default: React.ComponentType }>; - baseFrontendConfig?: AppConfig; - plugins?: StaticPlugins; -}) => { - const { loading, value } = useAsync(async () => { - const config = ConfigReader.fromConfigs( - overrideBaseUrlConfigs(await defaultConfigLoader()), - ); - - const baseUrl = config.get('backend.baseUrl'); - const scalprumConfig: AppsConfig = await fetch( - `${baseUrl}/api/scalprum/plugins`, - ).then(r => r.json()); - return { - baseUrl, - scalprumConfig, - }; - }); - - if (loading) { - return ; - } - - return ( - { - return { - ...manifest, - loadScripts: manifest.loadScripts.map( - (script: string) => - `${value?.baseUrl ?? ''}/api/scalprum/${ - manifest.name - }/${script}`, - ), - }; - }, - }, - }} - > - - - ); -}; - -export default ScalprumRoot; +export default DynamicRoot; diff --git a/packages/app/src/components/DynamicRoot/DynamicRootContext.tsx b/packages/app/src/components/DynamicRoot/DynamicRootContext.tsx index 1a93993e03..c97c82515f 100644 --- a/packages/app/src/components/DynamicRoot/DynamicRootContext.tsx +++ b/packages/app/src/components/DynamicRoot/DynamicRootContext.tsx @@ -74,18 +74,20 @@ export type RemotePlugins = { }; }; -const DynamicRootContext = createContext<{ +export type ComponentRegistry = { AppProvider: React.ComponentType; AppRouter: React.ComponentType; dynamicRoutes: DynamicRootContextValue[]; - mountPoints: { - [mountPoint: string]: ScalprumMountPoint[]; - }; -}>({ + mountPoints: { [mountPoint: string]: ScalprumMountPoint[] }; + entityTabOverrides: Record; +}; + +const DynamicRootContext = createContext({ AppProvider: () => null, AppRouter: () => null, dynamicRoutes: [], mountPoints: {}, + entityTabOverrides: {}, }); export default DynamicRootContext; diff --git a/packages/app/src/components/DynamicRoot/ScalprumRoot.tsx b/packages/app/src/components/DynamicRoot/ScalprumRoot.tsx new file mode 100644 index 0000000000..d4946a031c --- /dev/null +++ b/packages/app/src/components/DynamicRoot/ScalprumRoot.tsx @@ -0,0 +1,92 @@ +/* eslint-disable @typescript-eslint/no-shadow */ + +import React from 'react'; +import { ConfigReader, defaultConfigLoader } from '@backstage/core-app-api'; +import { AnyApiFactory } from '@backstage/core-plugin-api'; + +import { AppsConfig } from '@scalprum/core'; +import { ScalprumProvider } from '@scalprum/react-core'; + +import overrideBaseUrlConfigs from '../../utils/dynamicUI/overrideBaseUrlConfigs'; +import useAsync from 'react-use/lib/useAsync'; +import Loader from './Loader'; +import { AppConfig } from '@backstage/config'; +import { DynamicRoot, StaticPlugins } from './DynamicRoot'; + +const ScalprumRoot = ({ + apis, + afterInit, + baseFrontendConfig, + plugins, +}: { + // Static APIs + apis: AnyApiFactory[]; + afterInit: () => Promise<{ default: React.ComponentType }>; + baseFrontendConfig?: AppConfig; + plugins?: StaticPlugins; +}) => { + const { loading, value } = useAsync( + async (): Promise<{ + appConfig: AppConfig[]; + baseUrl: string; + scalprumConfig?: AppsConfig; + }> => { + const appConfig = overrideBaseUrlConfigs(await defaultConfigLoader()); + const reader = ConfigReader.fromConfigs(appConfig); + const baseUrl = reader.getString('backend.baseUrl'); + try { + const scalprumConfig: AppsConfig = await fetch( + `${baseUrl}/api/scalprum/plugins`, + ).then(r => r.json()); + return { + appConfig, + baseUrl, + scalprumConfig, + }; + } catch (err) { + // eslint-disable-next-line no-console + console.warn( + `Failed to fetch scalprum configuration: ${JSON.stringify(err)}`, + ); + return { + appConfig, + baseUrl, + scalprumConfig: {}, + }; + } + }, + ); + if (loading && !value) { + return ; + } + const { appConfig, baseUrl, scalprumConfig } = value || {}; + return ( + { + return { + ...manifest, + loadScripts: manifest.loadScripts.map( + (script: string) => + `${baseUrl ?? ''}/api/scalprum/${manifest.name}/${script}`, + ), + }; + }, + }, + }} + > + + + ); +}; + +export default ScalprumRoot; diff --git a/packages/app/src/components/DynamicRoot/index.ts b/packages/app/src/components/DynamicRoot/index.ts index da266e383b..8ef3ea885d 100644 --- a/packages/app/src/components/DynamicRoot/index.ts +++ b/packages/app/src/components/DynamicRoot/index.ts @@ -1 +1 @@ -export { default } from './DynamicRoot'; +export { default } from './ScalprumRoot'; diff --git a/packages/app/src/components/admin/AdminTabs.test.tsx b/packages/app/src/components/admin/AdminTabs.test.tsx index d950bb7175..db83fc58d1 100644 --- a/packages/app/src/components/admin/AdminTabs.test.tsx +++ b/packages/app/src/components/admin/AdminTabs.test.tsx @@ -1,16 +1,17 @@ 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'; +import { AppConfig } from '@backstage/config'; +import * as useAsync from 'react-use/lib/useAsync'; +import initializeRemotePlugins from '../../utils/dynamicUI/initializeRemotePlugins'; const DynamicRoot = React.lazy(() => import('../DynamicRoot/DynamicRoot')); const mockAppInner = () => ; -const MockApp = () => ( +const MockApp = ({ appConfig }: { appConfig: AppConfig[] }) => ( ( default: mockAppInner, }) } + appConfig={appConfig} + baseFrontendConfig={{ context: 'frontend', data: {} }} + scalprumConfig={{}} /> ); @@ -86,7 +90,7 @@ jest.mock('@backstage/config', () => { static fromConfigs(args: any) { const answer = OldConfigReader.fromConfigs([ ...[Array.isArray(args) ? args : []], - ...(process.env.APP_CONFIG as any), + ...(JSON.parse(process.env.APP_CONFIG || '[]') as Array), ]); return answer; } @@ -106,25 +110,22 @@ jest.mock('../../utils/dynamicUI/initializeRemotePlugins', () => ({ __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, - }, +const createAppConfig = (dynamicPluginsConfig: { [key: string]: any }) => [ + { + 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, -}); + context: 'test', + }, +]; const consoleSpy = jest.spyOn(console, 'warn'); @@ -143,7 +144,9 @@ describe('AdminTabs', () => { isTestConditionTrue: () => true, isTestConditionFalse: () => false, TestComponentWithStaticJSX: { - element: ({ children }) => <>{children}, + element: ({ children }: { children?: React.ReactNode }) => ( + <>{children} + ), staticJSXContent:
, }, }, @@ -159,14 +162,15 @@ describe('AdminTabs', () => { }); it('Should not be available when not configured', async () => { - process.env = mockProcessEnv({ + const appConfig = createAppConfig({ 'test-plugin': { dynamicRoutes: [], mountPoints: [], }, }); + process.env = { NODE_ENV: 'test', APP_CONFIG: JSON.stringify(appConfig) }; initialEntries = ['/']; - const rendered = await renderWithEffects(); + const rendered = await renderWithEffects(); expect(rendered.baseElement).toBeInTheDocument(); const home = rendered.queryByText('Home'); const administration = rendered.queryByText('Administration'); @@ -175,14 +179,15 @@ describe('AdminTabs', () => { }); it('Should be available when configured', async () => { - process.env = mockProcessEnv({ + const appConfig = createAppConfig({ 'test-plugin': { dynamicRoutes: [{ path: '/admin/plugins' }], mountPoints: [{ mountPoint: 'admin.page.plugins/cards' }], }, }); initialEntries = ['/']; - const rendered = await renderWithEffects(); + process.env = { NODE_ENV: 'test', APP_CONFIG: JSON.stringify(appConfig) }; + const rendered = await renderWithEffects(); expect(rendered.baseElement).toBeInTheDocument(); const home = rendered.queryByText('Home'); const administration = rendered.queryByText('Administration'); @@ -191,14 +196,15 @@ describe('AdminTabs', () => { }); it('Should route to the plugin tab when configured', async () => { - process.env = mockProcessEnv({ + const appConfig = createAppConfig({ 'test-plugin': { dynamicRoutes: [{ path: '/admin/plugins' }], mountPoints: [{ mountPoint: 'admin.page.plugins/cards' }], }, }); initialEntries = ['/']; - const rendered = await renderWithEffects(); + process.env = { NODE_ENV: 'test', APP_CONFIG: JSON.stringify(appConfig) }; + const rendered = await renderWithEffects(); expect(rendered.baseElement).toBeInTheDocument(); await act(() => { rendered.getByText('Administration').click(); @@ -208,14 +214,15 @@ describe('AdminTabs', () => { }); it('Should route to the rbac tab when configured', async () => { - process.env = mockProcessEnv({ + const appConfig = createAppConfig({ 'test-plugin': { dynamicRoutes: [{ path: '/admin/rbac' }], mountPoints: [{ mountPoint: 'admin.page.rbac/cards' }], }, }); initialEntries = ['/']; - const rendered = await renderWithEffects(); + process.env = { NODE_ENV: 'test', APP_CONFIG: JSON.stringify(appConfig) }; + const rendered = await renderWithEffects(); expect(rendered.baseElement).toBeInTheDocument(); await act(() => { rendered.getByText('Administration').click(); @@ -225,14 +232,15 @@ describe('AdminTabs', () => { }); it("Should fail back to the default tab if the currently routed tab doesn't match the configuration", async () => { - process.env = mockProcessEnv({ + const appConfig = createAppConfig({ 'test-plugin': { dynamicRoutes: [{ path: '/admin/rbac' }], mountPoints: [{ mountPoint: 'admin.page.rbac/cards' }], }, }); initialEntries = ['/admin/plugins']; - const rendered = await renderWithEffects(); + process.env = { NODE_ENV: 'test', APP_CONFIG: JSON.stringify(appConfig) }; + const rendered = await renderWithEffects(); // 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(); @@ -240,14 +248,15 @@ describe('AdminTabs', () => { }); it('Should fail with an error page if routed to but no configuration is defined', async () => { - process.env = mockProcessEnv({ + const appConfig = createAppConfig({ 'test-plugin': { dynamicRoutes: [], mountPoints: [], }, }); initialEntries = ['/admin/plugins']; - const rendered = await renderWithEffects(); + process.env = { NODE_ENV: 'test', APP_CONFIG: JSON.stringify(appConfig) }; + const rendered = await renderWithEffects(); // 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(); diff --git a/packages/app/src/components/catalog/EntityPage/ApiTabContent.tsx b/packages/app/src/components/catalog/EntityPage/ApiTabContent.tsx new file mode 100644 index 0000000000..8bda55d021 --- /dev/null +++ b/packages/app/src/components/catalog/EntityPage/ApiTabContent.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import { EntitySwitch, isKind } from '@backstage/plugin-catalog'; +import { isType } from '../utils'; +import Grid from '../Grid'; + +import { + EntityConsumedApisCard, + EntityProvidedApisCard, +} from '@backstage/plugin-api-docs'; + +export const ApiTabContent = () => ( + + isType('service')(e) && isKind('component')(e)}> + + + + + + + + +); diff --git a/packages/app/src/components/catalog/EntityPage/DefinitionTabContent.tsx b/packages/app/src/components/catalog/EntityPage/DefinitionTabContent.tsx new file mode 100644 index 0000000000..8db396b406 --- /dev/null +++ b/packages/app/src/components/catalog/EntityPage/DefinitionTabContent.tsx @@ -0,0 +1,16 @@ +import React from 'react'; + +import { EntitySwitch, isKind } from '@backstage/plugin-catalog'; +import Grid from '../Grid'; + +import { EntityApiDefinitionCard } from '@backstage/plugin-api-docs'; + +export const DefinitionTabContent = () => ( + + + + + + + +); diff --git a/packages/app/src/components/catalog/EntityPage/DependenciesTabContent.tsx b/packages/app/src/components/catalog/EntityPage/DependenciesTabContent.tsx new file mode 100644 index 0000000000..b15d60c408 --- /dev/null +++ b/packages/app/src/components/catalog/EntityPage/DependenciesTabContent.tsx @@ -0,0 +1,95 @@ +import React from 'react'; +import { + EntityConsumedApisCard, + EntityProvidedApisCard, +} from '@backstage/plugin-api-docs'; +import { + EntityDependsOnComponentsCard, + EntityDependsOnResourcesCard, + EntityHasSubcomponentsCard, + EntitySwitch, + isKind, +} from '@backstage/plugin-catalog'; +import { + Direction, + EntityCatalogGraphCard, +} from '@backstage/plugin-catalog-graph'; +import Grid from '../Grid'; + +export const DependenciesTabContent = () => ( + + + + + + + + + + + + + + + + + + + + + + +); diff --git a/packages/app/src/components/catalog/EntityPage/DiagramTabContent.tsx b/packages/app/src/components/catalog/EntityPage/DiagramTabContent.tsx new file mode 100644 index 0000000000..37171faf33 --- /dev/null +++ b/packages/app/src/components/catalog/EntityPage/DiagramTabContent.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import { + Direction, + EntityCatalogGraphCard, +} from '@backstage/plugin-catalog-graph'; +import { EntitySwitch, isKind } from '@backstage/plugin-catalog'; +import { + RELATION_API_CONSUMED_BY, + RELATION_API_PROVIDED_BY, + RELATION_CONSUMES_API, + RELATION_DEPENDENCY_OF, + RELATION_DEPENDS_ON, + RELATION_HAS_PART, + RELATION_PART_OF, + RELATION_PROVIDES_API, +} from '@backstage/catalog-model'; +import Grid from '../Grid'; + +export const DiagramTabContent = () => ( + + + + + + + +); diff --git a/packages/app/src/components/catalog/tab/tab.tsx b/packages/app/src/components/catalog/EntityPage/DynamicEntityTab.tsx similarity index 50% rename from packages/app/src/components/catalog/tab/tab.tsx rename to packages/app/src/components/catalog/EntityPage/DynamicEntityTab.tsx index 512003aa84..a5205f5173 100644 --- a/packages/app/src/components/catalog/tab/tab.tsx +++ b/packages/app/src/components/catalog/EntityPage/DynamicEntityTab.tsx @@ -5,29 +5,39 @@ import React from 'react'; import getMountPointData from '../../../utils/dynamicUI/getMountPointData'; import Grid from '../Grid'; -export type TabProps = { +export type DynamicEntityTabProps = { path: string; title: string; mountPoint: string; - if?: (e: Entity) => boolean; + if?: (entity: Entity) => boolean; children?: React.ReactNode; }; -const tab = ({ +/** + * Returns an configured route element suitable to use within an + * EntityLayout component that will load content based on the dynamic + * route and mount point configuration. Accepts a {@link DynamicEntityTabProps} + * Note - only call this as a function from within an EntityLayout + * component + * @param param0 + * @returns + */ +export const dynamicEntityTab = ({ path, title, mountPoint, children, if: condition, -}: TabProps) => ( +}: DynamicEntityTabProps) => ( - (condition ? condition(e) : Boolean(children)) || + if={entity => + (condition ? condition(entity) : Boolean(children)) || getMountPointData(`${mountPoint}/cards`) .flatMap(({ config }) => config.if) - .some(c => c(e)) + .some(cond => cond(entity)) } > {getMountPointData>( @@ -42,19 +52,19 @@ const tab = ({ React.ComponentType, React.ReactNode >(`${mountPoint}/cards`).map( - ({ Component, config, staticJSXContent }) => ( - - - - {staticJSXContent} - - - - ), + ({ Component, config, staticJSXContent }, index) => { + return ( + + + + {staticJSXContent} + + + + ); + }, )} , )} ); - -export default tab; diff --git a/packages/app/src/components/catalog/EntityPage/EntityPage.tsx b/packages/app/src/components/catalog/EntityPage/EntityPage.tsx new file mode 100644 index 0000000000..c1d2522a13 --- /dev/null +++ b/packages/app/src/components/catalog/EntityPage/EntityPage.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { EntityLayout } from '@backstage/plugin-catalog'; +import { dynamicEntityTab, DynamicEntityTabProps } from './DynamicEntityTab'; +import { defaultTabs, tabRules, tabChildren } from './defaultTabs'; + +/** + * Displays the tabs and content for a catalog entity + * *Note:* do not convert convert this to a component or wrap the return value + * @param entityTabOverrides + * @returns + */ +export const entityPage = ( + entityTabOverrides: Record< + string, + Omit + > = {}, +) => { + return ( + + {Object.entries({ ...defaultTabs, ...entityTabOverrides }).map( + ([path, config]) => { + return dynamicEntityTab({ + ...config, + path, + ...(tabRules[path] ? tabRules[path] : {}), + ...(tabChildren[path] ? tabChildren[path] : {}), + } as DynamicEntityTabProps); + }, + )} + + ); +}; diff --git a/packages/app/src/components/catalog/EntityPage/OverviewTabContent.tsx b/packages/app/src/components/catalog/EntityPage/OverviewTabContent.tsx new file mode 100644 index 0000000000..2c2e5348b0 --- /dev/null +++ b/packages/app/src/components/catalog/EntityPage/OverviewTabContent.tsx @@ -0,0 +1,266 @@ +import React from 'react'; +import { + EntityConsumingComponentsCard, + EntityHasApisCard, + EntityProvidingComponentsCard, +} from '@backstage/plugin-api-docs'; +import { + EntityAboutCard, + EntityHasComponentsCard, + EntityHasResourcesCard, + EntityHasSystemsCard, + EntityLinksCard, + EntityOrphanWarning, + EntityProcessingErrorsPanel, + EntityRelationWarning, + EntitySwitch, + hasCatalogProcessingErrors, + hasRelationWarnings, + isKind, + isOrphan, +} from '@backstage/plugin-catalog'; +import { hasLinks } from '../utils'; +import { EntityCatalogGraphCard } from '@backstage/plugin-catalog-graph'; +import { + EntityGroupProfileCard, + EntityMembersListCard, + EntityOwnershipCard, + EntityUserProfileCard, +} from '@backstage/plugin-org'; +import Grid from '../Grid'; + +export const OverviewTabContent = () => ( + <> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +); diff --git a/packages/app/src/components/catalog/EntityPage/defaultTabs.tsx b/packages/app/src/components/catalog/EntityPage/defaultTabs.tsx new file mode 100644 index 0000000000..55034efd9c --- /dev/null +++ b/packages/app/src/components/catalog/EntityPage/defaultTabs.tsx @@ -0,0 +1,125 @@ +import React from 'react'; +import { isType } from '../utils'; +import { isKind } from '@backstage/plugin-catalog'; +import { OverviewTabContent } from './OverviewTabContent'; +import { ApiTabContent } from './ApiTabContent'; +import { DependenciesTabContent } from './DependenciesTabContent'; +import { DefinitionTabContent } from './DefinitionTabContent'; +import { DiagramTabContent } from './DiagramTabContent'; +import { Entity } from '@backstage/catalog-model'; +import { DynamicEntityTabProps } from './DynamicEntityTab'; + +/** + * The default set of entity tabs in the default order + */ +export const defaultTabs: Record< + string, + Omit +> = { + '/': { + title: 'Overview', + mountPoint: 'entity.page.overview', + }, + '/topology': { + title: 'Topology', + mountPoint: 'entity.page.topology', + }, + '/issues': { + title: 'Issues', + mountPoint: 'entity.page.issues', + }, + '/pr': { + title: 'Pull/Merge Requests', + mountPoint: 'entity.page.pull-requests', + }, + '/ci': { + title: 'CI', + mountPoint: 'entity.page.ci', + }, + '/cd': { + title: 'CD', + mountPoint: 'entity.page.cd', + }, + '/kubernetes': { + title: 'Kubernetes', + mountPoint: 'entity.page.kubernetes', + }, + '/image-registry': { + title: 'Image Registry', + mountPoint: 'entity.page.image-registry', + }, + '/monitoring': { + title: 'Monitoring', + mountPoint: 'entity.page.monitoring', + }, + '/lighthouse': { + title: 'Lighthouse', + mountPoint: 'entity.page.lighthouse', + }, + '/api': { + title: 'Api', + mountPoint: 'entity.page.api', + }, + '/dependencies': { + title: 'Dependencies', + mountPoint: 'entity.page.dependencies', + }, + '/docs': { + title: 'Docs', + mountPoint: 'entity.page.docs', + }, + '/definition': { + title: 'Definition', + mountPoint: 'entity.page.definition', + }, + '/system': { + title: 'Diagram', + mountPoint: 'entity.page.diagram', + }, +}; + +/** + * Additional tab visibility rules for specific entity routes + */ +export const tabRules: Record< + string, + Omit +> = { + '/api': { + if: (entity: Entity) => + isType('service')(entity) && isKind('component')(entity), + }, + '/dependencies': { + if: isKind('component'), + }, + '/definition': { + if: isKind('api'), + }, + '/system': { + if: isKind('system'), + }, +}; + +/** + * Additional child elements to be rendered at specific entity routes + */ +export const tabChildren: Record< + string, + Omit +> = { + '/': { + children: , + }, + '/api': { + children: , + }, + '/dependencies': { + children: , + }, + '/definition': { + children: , + }, + '/system': { + children: , + }, +}; diff --git a/packages/app/src/components/catalog/EntityPage/index.tsx b/packages/app/src/components/catalog/EntityPage/index.tsx index d9b4d3dcd0..5007aba393 100644 --- a/packages/app/src/components/catalog/EntityPage/index.tsx +++ b/packages/app/src/components/catalog/EntityPage/index.tsx @@ -1,525 +1 @@ -import React from 'react'; -import { - EntityApiDefinitionCard, - EntityConsumedApisCard, - EntityConsumingComponentsCard, - EntityHasApisCard, - EntityProvidedApisCard, - EntityProvidingComponentsCard, -} from '@backstage/plugin-api-docs'; -import { - EntityAboutCard, - EntityDependsOnComponentsCard, - EntityDependsOnResourcesCard, - EntityHasComponentsCard, - EntityHasResourcesCard, - EntityHasSubcomponentsCard, - EntityHasSystemsCard, - EntityLayout, - EntityLinksCard, - EntityOrphanWarning, - EntityProcessingErrorsPanel, - EntityRelationWarning, - EntitySwitch, - hasCatalogProcessingErrors, - hasRelationWarnings, - isKind, - isOrphan, -} from '@backstage/plugin-catalog'; -import tab from '../tab'; -import { hasLinks, isType } from '../utils'; -import { - Direction, - EntityCatalogGraphCard, -} from '@backstage/plugin-catalog-graph'; -import { - EntityGroupProfileCard, - EntityMembersListCard, - EntityOwnershipCard, - EntityUserProfileCard, -} from '@backstage/plugin-org'; -import { - RELATION_API_CONSUMED_BY, - RELATION_API_PROVIDED_BY, - RELATION_CONSUMES_API, - RELATION_DEPENDENCY_OF, - RELATION_DEPENDS_ON, - RELATION_HAS_PART, - RELATION_PART_OF, - RELATION_PROVIDES_API, -} from '@backstage/catalog-model'; -import Grid from '../Grid'; - -export const entityPage = ( - - {tab({ - path: '/', - title: 'Overview', - mountPoint: 'entity.page.overview', - children: ( - <> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ), - })} - - {tab({ - path: '/topology', - title: 'Topology', - mountPoint: 'entity.page.topology', - })} - - {tab({ - path: '/issues', - title: 'Issues', - mountPoint: 'entity.page.issues', - })} - - {tab({ - path: '/pr', - title: 'Pull/Merge Requests', - mountPoint: 'entity.page.pull-requests', - })} - - {tab({ - path: '/ci', - title: 'CI', - mountPoint: 'entity.page.ci', - })} - - {tab({ - path: '/cd', - title: 'CD', - mountPoint: 'entity.page.cd', - })} - - {tab({ - path: '/kubernetes', - title: 'Kubernetes', - mountPoint: 'entity.page.kubernetes', - })} - - {tab({ - path: '/image-registry', - title: 'Image Registry', - mountPoint: 'entity.page.image-registry', - })} - - {tab({ - path: '/monitoring', - title: 'Monitoring', - mountPoint: 'entity.page.monitoring', - })} - - {tab({ - path: '/lighthouse', - title: 'Lighthouse', - mountPoint: 'entity.page.lighthouse', - })} - - {tab({ - path: '/api', - title: 'Api', - mountPoint: 'entity.page.api', - if: e => isType('service')(e) && isKind('component')(e), - children: ( - - isType('service')(e) && isKind('component')(e)} - > - - - - - - - - - ), - })} - - {tab({ - path: '/dependencies', - title: 'Dependencies', - mountPoint: 'entity.page.dependencies', - if: isKind('component'), - children: ( - - - - - - - - - - - - - - - - - - - - - - - ), - })} - - {tab({ - path: '/docs', - title: 'Docs', - mountPoint: 'entity.page.docs', - })} - - {tab({ - path: '/definition', - title: 'Definition', - mountPoint: 'entity.page.definition', - if: isKind('api'), - children: ( - - - - - - - - ), - })} - - {tab({ - path: '/diagram', - title: 'Diagram', - mountPoint: 'entity.page.diagram', - if: isKind('system'), - children: ( - - - - - - - - ), - })} - -); +export { entityPage } from './EntityPage'; diff --git a/packages/app/src/components/catalog/tab/index.ts b/packages/app/src/components/catalog/tab/index.ts deleted file mode 100644 index bd1dff4eb0..0000000000 --- a/packages/app/src/components/catalog/tab/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from './tab'; diff --git a/packages/app/src/utils/dynamicUI/extractDynamicConfig.test.ts b/packages/app/src/utils/dynamicUI/extractDynamicConfig.test.ts index 8ee9bea78a..02d86a9e89 100644 --- a/packages/app/src/utils/dynamicUI/extractDynamicConfig.test.ts +++ b/packages/app/src/utils/dynamicUI/extractDynamicConfig.test.ts @@ -3,18 +3,8 @@ import extractDynamicConfig, { conditionsArrayMapper, configIfToCallable, } from './extractDynamicConfig'; -import { defaultConfigLoader } from '@backstage/core-app-api'; import { Entity } from '@backstage/catalog-model'; -jest.mock('@backstage/core-app-api', () => ({ - ...jest.requireActual('@backstage/core-app-api'), - defaultConfigLoader: jest.fn(), -})); - -const mockedDefaultConfigLoader = defaultConfigLoader as jest.MockedFunction< - typeof defaultConfigLoader ->; - describe('conditionsArrayMapper', () => { it.each([ ['always true', true, () => true], @@ -154,11 +144,13 @@ describe('extractDynamicConfig', () => { { dynamicPlugins: { frontend: {} } }, ], ])('returns empty data when %s', async (_, source) => { - mockedDefaultConfigLoader.mockResolvedValue([source] as AppConfig[]); - const config = await extractDynamicConfig(); + const config = await extractDynamicConfig({ + appConfig: [source] as AppConfig[], + }); expect(config).toEqual({ routeBindings: [], dynamicRoutes: [], + entityTabs: [], mountPoints: [], appIcons: [], routeBindingTargets: [], @@ -439,19 +431,21 @@ describe('extractDynamicConfig', () => { }, ], ])('parses %s', async (_, source, output) => { - mockedDefaultConfigLoader.mockResolvedValue([ - { - context: 'foo', - data: { - dynamicPlugins: { frontend: { 'janus-idp.plugin-foo': source } }, + const config = await extractDynamicConfig({ + appConfig: [ + { + context: 'foo', + data: { + dynamicPlugins: { frontend: { 'janus-idp.plugin-foo': source } }, + }, }, - }, - ] as AppConfig[]); - const config = await extractDynamicConfig(); + ] as AppConfig[], + }); expect(config).toEqual({ routeBindings: [], routeBindingTargets: [], dynamicRoutes: [], + entityTabs: [], mountPoints: [], appIcons: [], apiFactories: [], diff --git a/packages/app/src/utils/dynamicUI/extractDynamicConfig.ts b/packages/app/src/utils/dynamicUI/extractDynamicConfig.ts index 64defa1134..f517c647a1 100644 --- a/packages/app/src/utils/dynamicUI/extractDynamicConfig.ts +++ b/packages/app/src/utils/dynamicUI/extractDynamicConfig.ts @@ -1,4 +1,3 @@ -import { defaultConfigLoader } from '@backstage/core-app-api'; import { Entity } from '@backstage/catalog-model'; import { isKind } from '@backstage/plugin-catalog'; import { hasAnnotation, isType } from '../../components/catalog/utils'; @@ -10,17 +9,6 @@ import { ScalprumMountPointConfigRawIf, } from '../../components/DynamicRoot/DynamicRootContext'; -type AppConfig = { - context: string; - data: { - dynamicPlugins?: { - frontend?: { - [key: string]: CustomProperties; - }; - }; - }; -}; - type DynamicRoute = { scope: string; module: string; @@ -60,6 +48,19 @@ type ApiFactory = { importName: string; }; +type EntityTab = { + mountPoint: string; + path: string; + title: string; +}; + +type EntityTabEntry = { + scope: string; + mountPoint: string; + path: string; + title: string; +}; + type CustomProperties = { dynamicRoutes?: (DynamicModuleEntry & { importName?: string; @@ -70,49 +71,37 @@ type CustomProperties = { targets: BindingTarget[]; bindings: RouteBinding[]; }; + entityTabs?: EntityTab[]; mountPoints?: MountPoint[]; appIcons?: AppIcon[]; apiFactories?: ApiFactory[]; }; -export const conditionsArrayMapper = ( - condition: - | { - [key: string]: string | string[]; - } - | Function, -) => { - if (typeof condition === 'function') { - return (entity: Entity) => Boolean(condition(entity)); - } - if (condition.isKind) { - return isKind(condition.isKind); - } - if (condition.isType) { - return isType(condition.isType); - } - if (condition.hasAnnotation) { - return hasAnnotation(condition.hasAnnotation as string); - } - return () => false; +type AppConfig = { + context: string; + data: { + dynamicPlugins?: { + frontend?: { + [key: string]: CustomProperties; + }; + }; + }; }; -export const configIfToCallable = - (conditional: ScalprumMountPointConfigRawIf) => (e: Entity) => { - if (conditional?.allOf) { - return conditional.allOf.map(conditionsArrayMapper).every(f => f(e)); - } - if (conditional?.anyOf) { - return conditional.anyOf.map(conditionsArrayMapper).some(f => f(e)); - } - if (conditional?.oneOf) { - return ( - conditional.oneOf.map(conditionsArrayMapper).filter(f => f(e)) - .length === 1 - ); - } - return true; - }; +type ExtractDynamicConfigProps = { + appConfig?: AppConfig[]; + frontendAppConfig?: AppConfig; +}; + +type DynamicConfig = { + apiFactories: ApiFactory[]; + appIcons: AppIcon[]; + dynamicRoutes: DynamicRoute[]; + entityTabs: EntityTabEntry[]; + mountPoints: MountPoint[]; + routeBindings: RouteBinding[]; + routeBindingTargets: BindingTarget[]; +}; /** * Converts all available configuration sources into the data structures @@ -122,22 +111,23 @@ export const configIfToCallable = * * @param frontendAppConfig */ -async function extractDynamicConfig( - frontendAppConfig: AppConfig = { context: '', data: {} }, -) { - const appConfigs = (await defaultConfigLoader()) || []; +async function extractDynamicConfig({ + appConfig = [], + frontendAppConfig = { context: '', data: {} }, +}: ExtractDynamicConfigProps) { const initialDynamicConfig = appConfigsToDynamicConfig([frontendAppConfig]); - const dynamicConfig = appConfigsToDynamicConfig(appConfigs, { - routeBindings: initialDynamicConfig.routeBindings, + const dynamicConfig = appConfigsToDynamicConfig(appConfig, { + apiFactories: initialDynamicConfig.apiFactories, + appIcons: initialDynamicConfig.appIcons, dynamicRoutes: initialDynamicConfig.dynamicRoutes.filter(dynamicRoute => - doesConfigContain(dynamicRoute, 'dynamicRoutes', appConfigs), + doesConfigContain(dynamicRoute, 'dynamicRoutes', appConfig), ), + entityTabs: initialDynamicConfig.entityTabs, mountPoints: initialDynamicConfig.mountPoints.filter(mountPoint => - doesConfigContain(mountPoint, 'mountPoints', appConfigs), + doesConfigContain(mountPoint, 'mountPoints', appConfig), ), - appIcons: initialDynamicConfig.appIcons, + routeBindings: initialDynamicConfig.routeBindings, routeBindingTargets: initialDynamicConfig.routeBindingTargets, - apiFactories: initialDynamicConfig.apiFactories, }); return dynamicConfig; } @@ -151,30 +141,17 @@ async function extractDynamicConfig( */ function appConfigsToDynamicConfig( appConfigs: AppConfig[], - initialDynamicConfig: { - routeBindings: RouteBinding[]; - routeBindingTargets: BindingTarget[]; - dynamicRoutes: DynamicRoute[]; - appIcons: AppIcon[]; - mountPoints: MountPoint[]; - apiFactories: ApiFactory[]; - } = { - routeBindings: [], + initialDynamicConfig: DynamicConfig = { + apiFactories: [], + appIcons: [], dynamicRoutes: [], + entityTabs: [], mountPoints: [], - appIcons: [], + routeBindings: [], routeBindingTargets: [], - apiFactories: [], }, ) { - return appConfigs.reduce<{ - routeBindings: RouteBinding[]; - routeBindingTargets: BindingTarget[]; - dynamicRoutes: DynamicRoute[]; - appIcons: AppIcon[]; - mountPoints: MountPoint[]; - apiFactories: ApiFactory[]; - }>((acc, { data }) => { + return appConfigs.reduce((acc, { data }) => { if (data?.dynamicPlugins?.frontend) { acc.dynamicRoutes.push( ...Object.entries(data.dynamicPlugins.frontend).reduce( @@ -269,6 +246,20 @@ function appConfigsToDynamicConfig( [], ), ); + + acc.entityTabs.push( + ...Object.entries(data.dynamicPlugins.frontend).reduce< + EntityTabEntry[] + >((accEntityTabs, [scope, { entityTabs }]) => { + accEntityTabs.push( + ...(entityTabs ?? []).map(entityTab => ({ + ...entityTab, + scope, + })), + ); + return accEntityTabs; + }, []), + ); } return acc; }, initialDynamicConfig); @@ -300,4 +291,50 @@ function doesConfigContain( }, true); } +/** + * Evaluate the supplied conditional map. Used to determine the visibility of + * tabs in the UI + * @param conditional + * @returns + */ +export function configIfToCallable(conditional: ScalprumMountPointConfigRawIf) { + return (e: Entity) => { + if (conditional?.allOf) { + return conditional.allOf.map(conditionsArrayMapper).every(f => f(e)); + } + if (conditional?.anyOf) { + return conditional.anyOf.map(conditionsArrayMapper).some(f => f(e)); + } + if (conditional?.oneOf) { + return ( + conditional.oneOf.map(conditionsArrayMapper).filter(f => f(e)) + .length === 1 + ); + } + return true; + }; +} + +export function conditionsArrayMapper( + condition: + | { + [key: string]: string | string[]; + } + | Function, +) { + if (typeof condition === 'function') { + return (entity: Entity) => Boolean(condition(entity)); + } + if (condition.isKind) { + return isKind(condition.isKind); + } + if (condition.isType) { + return isType(condition.isType); + } + if (condition.hasAnnotation) { + return hasAnnotation(condition.hasAnnotation as string); + } + return () => false; +} + export default extractDynamicConfig; diff --git a/packages/app/src/utils/test/TestRoot.tsx b/packages/app/src/utils/test/TestRoot.tsx index 55a484a30b..a992fb1e5c 100644 --- a/packages/app/src/utils/test/TestRoot.tsx +++ b/packages/app/src/utils/test/TestRoot.tsx @@ -40,6 +40,7 @@ const TestRoot = ({ children }: PropsWithChildren<{}>) => { AppRouter: current.getRouter(), dynamicRoutes: [], mountPoints: {}, + entityTabOverrides: {}, }} > {children} diff --git a/showcase-docs/dynamic-plugins.md b/showcase-docs/dynamic-plugins.md index 889121b0df..1e771255c9 100644 --- a/showcase-docs/dynamic-plugins.md +++ b/showcase-docs/dynamic-plugins.md @@ -542,7 +542,7 @@ This configuration allows you to bind to existing plugins and their routes as we This section aims to allow users dynamic extension of [Catalog Components](https://backstage.io/docs/plugins/composability/#catalog-components), but can be used to extend additional views in the future as well. -Mount points are defined identifiers available across the applications. These points specifically allow users to extend existing pages with additional content. +Mount points are defined identifiers available across the application. These points specifically allow users to extend existing pages with additional content. The following mount points are available: @@ -615,7 +615,57 @@ Each mount point supports additional configuration: - `hasAnnotation`: Accepts a string or a list of string with annotation keys. For example `hasAnnotation: my-annotation` will render the component only for entities that have `metadata.annotations['my-annotation']` defined. - condition imported from the plugin's `module`: Must be function name exported from the same `module` within the plugin. For example `isMyPluginAvailable` will render the component only if `isMyPluginAvailable` function returns `true`. The function must have following signature: `(e: Entity) => boolean` -#### Provide additional Utility APIs +#### Customizing and Adding Entity tabs + +Out of the box the frontend system provides an opinionated set of tabs for catalog entity views. This set of tabs can be further customized and extended as needed via the `entityTabs` configuration: + +```yaml +# app-config.yaml +dynamicPlugins: + frontend: + : # same as `scalprum.name` key in plugin's `package.json` + entityTabs: + # Adding a new tab + - path: /new-path + title: My New Tab + mountPoint: entity.page.my-new-tab + # Change an existing tab's title or mount point + - path: / + title: General + mountPoint: entity.page.overview #this can be customized too +``` + +Each entity tab entry requires the following attributes: + +- `path`: Specifies the sub-path route in the catalog where this tab will be available +- `title`: The title that is displayed to the user +- `mountPoint`: The base mount point name that will be available on the tab. This name will be expanded to create two mount points per tab, one appended with `/context` and the second appended with `/cards`. + +Dynamic frontend plugins can then be configured to target the mount points exposed by the `entityTabs` configuration. + +Here are the default catalog entity routes in the default order: + +| Route | Title | Mount Point | Entity Kind | +| ----------------- | ------------------- | ---------------------------- | ------------------------------------ | +| `/` | Overview | `entity.page.overview` | Any | +| `/topology` | Topology | `entity.page.topology` | Any | +| `/issues` | Issues | `entity.page.issues` | Any | +| `/pr` | Pull/Merge Requests | `entity.page.pull-requests` | Any | +| `/ci` | CI | `entity.page.ci` | Any | +| `/cd` | CD | `entity.page.cd` | Any | +| `/kubernetes` | Kubernetes | `entity.page.kubernetes` | Any | +| `/image-registry` | Image Registry | `entity.page.image-registry` | Any | +| `/monitoring` | Monitoring | `entity.page.monitoring` | Any | +| `/lighthouse` | Lighthouse | `entity.page.lighthouse` | Any | +| `/api` | Api | `entity.page.api` | `kind: Service` or `kind: Component` | +| `/dependencies` | Dependencies | `entity.page.dependencies` | `kind: Component` | +| `/docs` | Docs | `entity.page.docs` | Any | +| `/definition` | Definition | `entity.page.definition` | `kind: API` | +| `/system` | Diagram | `entity.page.diagram` | `kind: System` | + +The visibility of a tab is derived from the kind of entity that is being displayed along with the visibility guidance mentioned in "[Using mount points](#using-mount-points)". + +### Provide additional Utility APIs Backstage offers an Utility API mechanism that provide ways for plugins to communicate during their entire life cycle. Utility APIs are registered as: