From a4de003ae61c94bece19b11a7f71ae9b92f328de Mon Sep 17 00:00:00 2001 From: Stan Lewis Date: Wed, 27 Aug 2025 08:34:40 -0400 Subject: [PATCH] chore(app): extract out config parsing This change extracts out the frontend configuration parsing functions into a separate app-utils package, to prepare making the old RHDH dynamic frontend configuration available to the new frontend system configuration bridge when it is implemented. Signed-off-by: Stan Lewis --- .rhdh/docker/Dockerfile | 1 + docker/Dockerfile | 1 + packages/app-utils/.eslintrc.js | 12 + packages/app-utils/.lintstagedrc.json | 4 + packages/app-utils/.prettierignore | 2 + packages/app-utils/.prettierrc.js | 20 ++ packages/app-utils/package.json | 77 +++++ .../src/components/MenuIcon}/MenuIcon.tsx | 0 .../src/components/MenuIcon/index.ts | 1 + packages/app-utils/src/components/index.ts | 1 + .../config}/extractDynamicConfig.test.ts | 8 +- .../dynamic/config}/extractDynamicConfig.ts | 279 ++---------------- .../extractDynamicConfigFrontend.test.ts | 2 +- .../config}/extractDynamicConfigFrontend.ts | 40 ++- .../app-utils/src/dynamic/config/index.ts | 4 + .../dynamic/config}/overrideBaseUrlConfigs.ts | 4 +- .../app-utils/src/dynamic/config/types.ts | 238 +++++++++++++++ .../app-utils/src/dynamic/config/utils.ts | 58 ++++ packages/app-utils/src/dynamic/index.ts | 3 + .../src/dynamic/routes}/bindAppRoutes.ts | 7 +- .../app-utils/src/dynamic/routes/index.ts | 1 + packages/app-utils/src/dynamic/utils/index.ts | 16 + packages/app-utils/src/index.ts | 2 + packages/app-utils/tsconfig.json | 10 + packages/app-utils/turbo.json | 6 + packages/app/package.json | 4 +- .../DynamicRoot/DynamicRoot.test.tsx | 6 +- .../components/DynamicRoot/DynamicRoot.tsx | 124 +++----- .../components/DynamicRoot/ScalprumRoot.tsx | 15 +- .../components/Root/ApplicationHeaders.tsx | 7 +- packages/app/src/components/Root/Root.tsx | 2 +- .../components/UserSettings/SettingsPages.tsx | 2 +- .../ContextMenuAwareEntityLayout.tsx | 3 +- .../app/src/components/search/SearchPage.tsx | 2 +- .../app/src/hooks/useLanguagePreference.ts | 3 +- packages/app/src/types/types.ts | 37 --- .../dynamicUI/initializeRemotePlugins.ts | 3 +- .../app/src/utils/language/language.test.ts | 3 +- packages/app/src/utils/language/language.ts | 2 +- .../translationResourceGenerator.ts | 2 +- .../translationResourceProcessor.ts | 3 +- packages/plugin-utils/src/types.ts | 88 ++++-- yarn.lock | 38 ++- 43 files changed, 673 insertions(+), 468 deletions(-) create mode 100644 packages/app-utils/.eslintrc.js create mode 100644 packages/app-utils/.lintstagedrc.json create mode 100644 packages/app-utils/.prettierignore create mode 100644 packages/app-utils/.prettierrc.js create mode 100644 packages/app-utils/package.json rename packages/{app/src/components/Root => app-utils/src/components/MenuIcon}/MenuIcon.tsx (100%) create mode 100644 packages/app-utils/src/components/MenuIcon/index.ts create mode 100644 packages/app-utils/src/components/index.ts rename packages/{app/src/utils/dynamicUI => app-utils/src/dynamic/config}/extractDynamicConfig.test.ts (98%) rename packages/{app/src/utils/dynamicUI => app-utils/src/dynamic/config}/extractDynamicConfig.ts (54%) rename packages/{app/src/utils/dynamicUI => app-utils/src/dynamic/config}/extractDynamicConfigFrontend.test.ts (98%) rename packages/{app/src/utils/dynamicUI => app-utils/src/dynamic/config}/extractDynamicConfigFrontend.ts (96%) create mode 100644 packages/app-utils/src/dynamic/config/index.ts rename packages/{app/src/utils/dynamicUI => app-utils/src/dynamic/config}/overrideBaseUrlConfigs.ts (93%) create mode 100644 packages/app-utils/src/dynamic/config/types.ts create mode 100644 packages/app-utils/src/dynamic/config/utils.ts create mode 100644 packages/app-utils/src/dynamic/index.ts rename packages/{app/src/utils/dynamicUI => app-utils/src/dynamic/routes}/bindAppRoutes.ts (93%) create mode 100644 packages/app-utils/src/dynamic/routes/index.ts create mode 100644 packages/app-utils/src/dynamic/utils/index.ts create mode 100644 packages/app-utils/src/index.ts create mode 100644 packages/app-utils/tsconfig.json create mode 100644 packages/app-utils/turbo.json diff --git a/.rhdh/docker/Dockerfile b/.rhdh/docker/Dockerfile index 7f67c54112..8987d4baf6 100644 --- a/.rhdh/docker/Dockerfile +++ b/.rhdh/docker/Dockerfile @@ -73,6 +73,7 @@ COPY $EXTERNAL_SOURCE_NESTED/packages/theme-wrapper/package.json ./packages/them COPY $EXTERNAL_SOURCE_NESTED/packages/plugin-utils/package.json ./packages/plugin-utils/package.json COPY $EXTERNAL_SOURCE_NESTED/packages/backend/package.json ./packages/backend/package.json COPY $EXTERNAL_SOURCE_NESTED/packages/app/package.json ./packages/app/package.json +COPY $EXTERNAL_SOURCE_NESTED/packages/app-utils/package.json ./packages/app-utils/package.json COPY $EXTERNAL_SOURCE_NESTED/packages/app-next/package.json ./packages/app-next/package.json COPY $EXTERNAL_SOURCE_NESTED/package.json ./package.json # END COPY package.json files diff --git a/docker/Dockerfile b/docker/Dockerfile index 2768800f21..3b80dc3243 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -73,6 +73,7 @@ COPY $EXTERNAL_SOURCE_NESTED/packages/theme-wrapper/package.json ./packages/them COPY $EXTERNAL_SOURCE_NESTED/packages/plugin-utils/package.json ./packages/plugin-utils/package.json COPY $EXTERNAL_SOURCE_NESTED/packages/backend/package.json ./packages/backend/package.json COPY $EXTERNAL_SOURCE_NESTED/packages/app/package.json ./packages/app/package.json +COPY $EXTERNAL_SOURCE_NESTED/packages/app-utils/package.json ./packages/app-utils/package.json COPY $EXTERNAL_SOURCE_NESTED/packages/app-next/package.json ./packages/app-next/package.json COPY $EXTERNAL_SOURCE_NESTED/package.json ./package.json # END COPY package.json files diff --git a/packages/app-utils/.eslintrc.js b/packages/app-utils/.eslintrc.js new file mode 100644 index 0000000000..07630f21af --- /dev/null +++ b/packages/app-utils/.eslintrc.js @@ -0,0 +1,12 @@ +const backstageConfig = require('@backstage/cli/config/eslint-factory')( + __dirname, +); + +module.exports = { + ...backstageConfig, + rules: { + ...backstageConfig.rules, + 'react/react-in-jsx-scope': 'off', + 'react/jsx-uses-react': 'off', + }, +}; diff --git a/packages/app-utils/.lintstagedrc.json b/packages/app-utils/.lintstagedrc.json new file mode 100644 index 0000000000..14b2263def --- /dev/null +++ b/packages/app-utils/.lintstagedrc.json @@ -0,0 +1,4 @@ +{ + "*": "prettier --ignore-unknown --write", + "*.{js,jsx,ts,tsx,mjs,cjs}": "backstage-cli package lint --fix" +} diff --git a/packages/app-utils/.prettierignore b/packages/app-utils/.prettierignore new file mode 100644 index 0000000000..3f6fff7b2b --- /dev/null +++ b/packages/app-utils/.prettierignore @@ -0,0 +1,2 @@ +dist +coverage \ No newline at end of file diff --git a/packages/app-utils/.prettierrc.js b/packages/app-utils/.prettierrc.js new file mode 100644 index 0000000000..83c5a44066 --- /dev/null +++ b/packages/app-utils/.prettierrc.js @@ -0,0 +1,20 @@ +// @ts-check + +/** @type {import("@ianvs/prettier-plugin-sort-imports").PrettierConfig} */ +module.exports = { + ...require('@backstage/cli/config/prettier.json'), + plugins: ['@ianvs/prettier-plugin-sort-imports'], + importOrder: [ + '^react(.*)$', + '', + '^@backstage/(.*)$', + '', + '', + '', + '^@janus-idp/(.*)$', + '', + '', + '', + '^[.]', + ], +}; diff --git a/packages/app-utils/package.json b/packages/app-utils/package.json new file mode 100644 index 0000000000..0e7fb20ccf --- /dev/null +++ b/packages/app-utils/package.json @@ -0,0 +1,77 @@ +{ + "name": "@red-hat-developer-hub/app-utils", + "version": "1.0.0", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "publishConfig": { + "access": "public", + "main": "dist/index.esm.js", + "module": "dist/index.esm.js", + "types": "dist/index.d.ts" + }, + "backstage": { + "role": "common-library", + "supported-versions": "1.0.0", + "pluginId": "app-utils", + "pluginPackage": "@red-hat-developer-hub/app-utils", + "pluginPackages": [ + "@red-hat-developer-hub/app-utils" + ] + }, + "scripts": { + "build": "backstage-cli package build", + "clean": "backstage-cli package clean", + "test": "backstage-cli package test --passWithNoTests --coverage", + "lint:check": "backstage-cli package lint", + "lint:fix": "backstage-cli package lint --fix", + "tsc": "tsc", + "prettier:check": "prettier --ignore-unknown --check .", + "prettier:fix": "prettier --ignore-unknown --write ." + }, + "dependencies": { + "@backstage/catalog-model": "1.7.5", + "@backstage/config": "1.3.3", + "@backstage/core-app-api": "1.18.0", + "@backstage/core-plugin-api": "1.10.9", + "@backstage/plugin-api-docs": "0.12.10", + "@backstage/plugin-catalog": "1.31.2", + "@backstage/plugin-catalog-import": "0.13.4", + "@backstage/plugin-org": "0.6.43", + "@backstage/plugin-scaffolder": "1.34.0", + "@mui/material": "5.18.0", + "lodash": "4.17.21" + }, + "devDependencies": { + "@backstage/cli": "0.34.1", + "@backstage/test-utils": "1.7.11", + "@testing-library/dom": "9.3.4", + "@testing-library/jest-dom": "6.9.1", + "@testing-library/react": "14.3.1", + "@testing-library/react-hooks": "8.0.1", + "@types/node": "22.18.11", + "@types/react": "18.3.26", + "@types/react-dom": "18.3.7", + "prettier": "3.6.2", + "react": "18.3.1", + "typescript": "5.9.3" + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + }, + "files": [ + "dist" + ], + "peerDependencies": { + "react": "16.13.1 || ^17.0.0 || ^18.2.0" + } +} diff --git a/packages/app/src/components/Root/MenuIcon.tsx b/packages/app-utils/src/components/MenuIcon/MenuIcon.tsx similarity index 100% rename from packages/app/src/components/Root/MenuIcon.tsx rename to packages/app-utils/src/components/MenuIcon/MenuIcon.tsx diff --git a/packages/app-utils/src/components/MenuIcon/index.ts b/packages/app-utils/src/components/MenuIcon/index.ts new file mode 100644 index 0000000000..56100005b2 --- /dev/null +++ b/packages/app-utils/src/components/MenuIcon/index.ts @@ -0,0 +1 @@ +export * from './MenuIcon'; diff --git a/packages/app-utils/src/components/index.ts b/packages/app-utils/src/components/index.ts new file mode 100644 index 0000000000..56100005b2 --- /dev/null +++ b/packages/app-utils/src/components/index.ts @@ -0,0 +1 @@ +export * from './MenuIcon'; diff --git a/packages/app/src/utils/dynamicUI/extractDynamicConfig.test.ts b/packages/app-utils/src/dynamic/config/extractDynamicConfig.test.ts similarity index 98% rename from packages/app/src/utils/dynamicUI/extractDynamicConfig.test.ts rename to packages/app-utils/src/dynamic/config/extractDynamicConfig.test.ts index 94a3e653b8..4880ed70aa 100644 --- a/packages/app/src/utils/dynamicUI/extractDynamicConfig.test.ts +++ b/packages/app-utils/src/dynamic/config/extractDynamicConfig.test.ts @@ -1,10 +1,8 @@ import { Entity } from '@backstage/catalog-model'; -import extractDynamicConfig, { - conditionsArrayMapper, - configIfToCallable, - DynamicPluginConfig, -} from './extractDynamicConfig'; +import { extractDynamicConfig } from './extractDynamicConfig'; +import { DynamicPluginConfig } from './types'; +import { conditionsArrayMapper, configIfToCallable } from './utils'; describe('conditionsArrayMapper', () => { it.each([ diff --git a/packages/app/src/utils/dynamicUI/extractDynamicConfig.ts b/packages/app-utils/src/dynamic/config/extractDynamicConfig.ts similarity index 54% rename from packages/app/src/utils/dynamicUI/extractDynamicConfig.ts rename to packages/app-utils/src/dynamic/config/extractDynamicConfig.ts index df807c0497..3cf44471dd 100644 --- a/packages/app/src/utils/dynamicUI/extractDynamicConfig.ts +++ b/packages/app-utils/src/dynamic/config/extractDynamicConfig.ts @@ -1,216 +1,29 @@ -import { Entity } from '@backstage/catalog-model'; -import { ApiHolder } from '@backstage/core-plugin-api'; -import { isKind } from '@backstage/plugin-catalog'; - +import { extractMenuItems } from './extractDynamicConfigFrontend'; import { - MountPointConfigRaw, - MountPointConfigRawIf, + AnalyticsApiExtension, + ApiFactory, + AppIcon, + BindingTarget, + DynamicConfig, + DynamicPluginConfig, + DynamicRoute, + DynamicTranslationResource, + EntityTabEntry, + MountPoint, + PluginModule, + ProviderSetting, RouteBinding, -} from '@red-hat-developer-hub/plugin-utils'; - -import { hasAnnotation, isType } from '../../components/catalog/utils'; -import { DynamicTranslationResource } from '../../types/types'; -import { extractMenuItems } from './extractDynamicConfigFrontend'; - -export type DynamicRouteMenuItem = - | { - text: string; - textKey?: string; - icon: string; - parent?: string; - priority?: number; - enabled?: boolean; - } - | { - module?: string; - importName: string; - config?: { - props?: Record; - }; - }; - -export type MenuItemConfig = { - icon?: string; - title?: string; - priority?: number; - parent?: string; -}; - -export type MenuItem = { - name: string; - title: string; - titleKey?: string; - icon: string; - children: MenuItem[]; - priority?: number; - to?: string; - parent?: string; - enabled?: boolean; -}; - -export type DynamicRoute = { - scope: string; - module: string; - importName: string; - path: string; - menuItem?: DynamicRouteMenuItem; - config?: { - props?: Record; - }; -}; - -type PluginModule = { - scope: string; - module: string; -}; - -type MountPoint = { - scope: string; - mountPoint: string; - module: string; - importName: string; - config?: MountPointConfigRaw; -}; - -type AppIcon = { - scope: string; - name: string; - module: string; - importName: string; -}; - -type BindingTarget = { - scope: string; - name: string; - module: string; - importName: string; -}; - -type ApiFactory = { - scope: string; - module: string; - importName: string; -}; - -type AnalyticsApiExtension = { - scope: string; - module: string; - importName: string; -}; - -type ScaffolderFieldExtension = { - scope: string; - module: string; - importName: string; -}; - -type TechdocsAddon = { - scope: string; - module: string; - importName: string; - config?: { - props?: Record; - }; -}; - -type EntityTab = { - mountPoint: string; - path: string; - title: string; - titleKey?: string; - pariority?: number; -}; - -type EntityTabEntry = { - scope: string; - mountPoint: string; - path: string; - title: string; - titleKey?: string; - priority?: number; -}; - -type ThemeEntry = { - scope: string; - module: string; - id: string; - title: string; - variant: 'light' | 'dark'; - icon: string; - importName: string; -}; - -type SignInPageEntry = { - scope: string; - module: string; - importName: string; -}; - -type ProviderSetting = { - title: string; - description: string; - provider: string; -}; - -type CustomProperties = { - pluginModule?: string; - dynamicRoutes?: { - importName?: string; - module?: string; - scope?: string; - path: string; - menuItem?: DynamicRouteMenuItem; - }[]; - menuItems?: { [key: string]: MenuItemConfig }; - routeBindings?: { - targets: BindingTarget[]; - bindings: RouteBinding[]; - }; - entityTabs?: EntityTab[]; - mountPoints?: MountPoint[]; - appIcons?: AppIcon[]; - apiFactories?: ApiFactory[]; - analyticsApiExtensions?: AnalyticsApiExtension[]; - providerSettings?: ProviderSetting[]; - scaffolderFieldExtensions?: ScaffolderFieldExtension[]; - signInPage: SignInPageEntry; - techdocsAddons?: TechdocsAddon[]; - themes?: ThemeEntry[]; - translationResources?: DynamicTranslationResource[]; -}; - -export type FrontendConfig = { - [key: string]: CustomProperties; -}; - -export type DynamicPluginConfig = { - frontend?: FrontendConfig; -}; - -type DynamicConfig = { - pluginModules: PluginModule[]; - apiFactories: ApiFactory[]; - analyticsApiExtensions: AnalyticsApiExtension[]; - appIcons: AppIcon[]; - dynamicRoutes: DynamicRoute[]; - menuItems: MenuItem[]; - entityTabs: EntityTabEntry[]; - mountPoints: MountPoint[]; - providerSettings: ProviderSetting[]; - routeBindings: RouteBinding[]; - routeBindingTargets: BindingTarget[]; - scaffolderFieldExtensions: ScaffolderFieldExtension[]; - signInPages: SignInPageEntry[]; - techdocsAddons: TechdocsAddon[]; - themes: ThemeEntry[]; - translationResources: DynamicTranslationResource[]; -}; + ScaffolderFieldExtension, + SignInPageEntry, + TechdocsAddon, + ThemeEntry, +} from './types'; /** * Converts the dynamic plugin configuration structure to the data structure * required by the dynamic UI, substituting in any defaults as needed */ -function extractDynamicConfig( +export function extractDynamicConfig( dynamicPlugins: DynamicPluginConfig = { frontend: {} }, ) { const frontend = dynamicPlugins.frontend || {}; @@ -425,57 +238,3 @@ function extractDynamicConfig( return config; } - -/** - * Evaluate the supplied conditional map. Used to determine the visibility of - * tabs in the UI - * @param conditional - * @returns - */ -export function configIfToCallable(conditional: MountPointConfigRawIf) { - return (entity: Entity, context?: { apis: ApiHolder }) => { - if (conditional?.allOf) { - return conditional.allOf - .map(conditionsArrayMapper) - .every(f => f(entity, context)); - } - if (conditional?.anyOf) { - return conditional.anyOf - .map(conditionsArrayMapper) - .some(f => f(entity, context)); - } - if (conditional?.oneOf) { - return ( - conditional.oneOf - .map(conditionsArrayMapper) - .filter(f => f(entity, context)).length === 1 - ); - } - return true; - }; -} - -export function conditionsArrayMapper( - condition: - | { - [key: string]: string | string[]; - } - | Function, -): (entity: Entity, context?: { apis: ApiHolder }) => boolean { - if (typeof condition === 'function') { - return (entity: Entity, context?: { apis: ApiHolder }): boolean => - condition(entity, context); - } - 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/dynamicUI/extractDynamicConfigFrontend.test.ts b/packages/app-utils/src/dynamic/config/extractDynamicConfigFrontend.test.ts similarity index 98% rename from packages/app/src/utils/dynamicUI/extractDynamicConfigFrontend.test.ts rename to packages/app-utils/src/dynamic/config/extractDynamicConfigFrontend.test.ts index d5caae26c7..fc540a328e 100644 --- a/packages/app/src/utils/dynamicUI/extractDynamicConfigFrontend.test.ts +++ b/packages/app-utils/src/dynamic/config/extractDynamicConfigFrontend.test.ts @@ -1,9 +1,9 @@ -import { MenuItem } from './extractDynamicConfig'; import { buildTree, compareMenuItems, getNameFromPath, } from './extractDynamicConfigFrontend'; +import { MenuItem } from './types'; describe('getNameFromPath', () => { test.each([ diff --git a/packages/app/src/utils/dynamicUI/extractDynamicConfigFrontend.ts b/packages/app-utils/src/dynamic/config/extractDynamicConfigFrontend.ts similarity index 96% rename from packages/app/src/utils/dynamicUI/extractDynamicConfigFrontend.ts rename to packages/app-utils/src/dynamic/config/extractDynamicConfigFrontend.ts index cd090b387c..179f915ab6 100644 --- a/packages/app/src/utils/dynamicUI/extractDynamicConfigFrontend.ts +++ b/packages/app-utils/src/dynamic/config/extractDynamicConfigFrontend.ts @@ -1,30 +1,9 @@ -import { - FrontendConfig, - MenuItem, - MenuItemConfig, -} from './extractDynamicConfig'; - -export function getNameFromPath(path: string): string { - const trimmedPath = path.trim(); - const noLeadingTrailingSlashes = trimmedPath.startsWith('/') - ? trimmedPath.slice(1) - : trimmedPath; - - const cleanedPath = noLeadingTrailingSlashes.endsWith('/') - ? noLeadingTrailingSlashes.slice(0, -1) - : noLeadingTrailingSlashes; - - return cleanedPath.split('/').join('.'); -} +import { FrontendConfig, MenuItem, MenuItemConfig } from './types'; function isStaticPath(path: string): boolean { return !path.includes(':') && !path.includes('*'); } -export function compareMenuItems(a: MenuItem, b: MenuItem) { - return (b.priority ?? 0) - (a.priority ?? 0); -} - function convertMenuItemsRecordToArray( menuItemsRecord: Record, ): MenuItem[] { @@ -38,6 +17,23 @@ function convertMenuItemsRecordToArray( ); } +export function getNameFromPath(path: string): string { + const trimmedPath = path.trim(); + const noLeadingTrailingSlashes = trimmedPath.startsWith('/') + ? trimmedPath.slice(1) + : trimmedPath; + + const cleanedPath = noLeadingTrailingSlashes.endsWith('/') + ? noLeadingTrailingSlashes.slice(0, -1) + : noLeadingTrailingSlashes; + + return cleanedPath.split('/').join('.'); +} + +export function compareMenuItems(a: MenuItem, b: MenuItem) { + return (b.priority ?? 0) - (a.priority ?? 0); +} + export function buildTree(menuItemsArray: MenuItem[]): MenuItem[] { const itemMap: Record = {}; diff --git a/packages/app-utils/src/dynamic/config/index.ts b/packages/app-utils/src/dynamic/config/index.ts new file mode 100644 index 0000000000..ddd327907f --- /dev/null +++ b/packages/app-utils/src/dynamic/config/index.ts @@ -0,0 +1,4 @@ +export * from './extractDynamicConfig'; +export * from './types'; +export * from './overrideBaseUrlConfigs'; +export { configIfToCallable } from './utils'; diff --git a/packages/app/src/utils/dynamicUI/overrideBaseUrlConfigs.ts b/packages/app-utils/src/dynamic/config/overrideBaseUrlConfigs.ts similarity index 93% rename from packages/app/src/utils/dynamicUI/overrideBaseUrlConfigs.ts rename to packages/app-utils/src/dynamic/config/overrideBaseUrlConfigs.ts index fb0f487816..46a362beba 100644 --- a/packages/app/src/utils/dynamicUI/overrideBaseUrlConfigs.ts +++ b/packages/app-utils/src/dynamic/config/overrideBaseUrlConfigs.ts @@ -8,7 +8,7 @@ function createLocalBaseUrl(fullUrl: string): string { return url.toString().replace(/\/$/, ''); } -function overrideBaseUrlConfigs(inputConfigs: AppConfig[]): AppConfig[] { +export function overrideBaseUrlConfigs(inputConfigs: AppConfig[]): AppConfig[] { const urlConfigReader = ConfigReader.fromConfigs(inputConfigs); // In tests we may not have `app.baseUrl` or `backend.baseUrl`, to keep them optional @@ -56,5 +56,3 @@ function overrideBaseUrlConfigs(inputConfigs: AppConfig[]): AppConfig[] { return configs; } - -export default overrideBaseUrlConfigs; diff --git a/packages/app-utils/src/dynamic/config/types.ts b/packages/app-utils/src/dynamic/config/types.ts new file mode 100644 index 0000000000..32126e1e6e --- /dev/null +++ b/packages/app-utils/src/dynamic/config/types.ts @@ -0,0 +1,238 @@ +import { TranslationResource } from '@backstage/core-plugin-api/alpha'; + +export type RouteBinding = { + bindTarget: string; + bindMap: { + [target: string]: string; + }; +}; + +export type DynamicModuleEntry = { + scope: string; + module: string; +}; + +export type DynamicRouteMenuItem = + | { + text: string; + textKey?: string; + icon: string; + parent?: string; + priority?: number; + enabled?: boolean; + } + | { + module?: string; + importName: string; + config?: { + props?: Record; + }; + }; + +export type MenuItemConfig = { + icon?: string; + title?: string; + priority?: number; + parent?: string; +}; + +export type MenuItem = { + name: string; + title: string; + titleKey?: string; + icon: string; + children: MenuItem[]; + priority?: number; + to?: string; + parent?: string; + enabled?: boolean; +}; + +export type DynamicRoute = { + scope: string; + module: string; + importName: string; + path: string; + menuItem?: DynamicRouteMenuItem; + config?: { + props?: Record; + }; +}; + +export type PluginModule = DynamicModuleEntry; + +export type MountPointConfigBase = { + layout?: Record; + props?: Record; +}; + +export type MountPointConfigRawIf = { + [key in 'allOf' | 'oneOf' | 'anyOf']?: ( + | { + [key: string]: string | string[]; + } + | Function + )[]; +}; + +export type MountPointConfigRaw = MountPointConfigBase & { + if?: MountPointConfigRawIf; +}; + +export type MountPoint = DynamicModuleEntry & { + mountPoint: string; + importName: string; + config?: MountPointConfigRaw; +}; + +export type AppIcon = DynamicModuleEntry & { + name: string; + importName: string; +}; + +export type BindingTarget = DynamicModuleEntry & { + name: string; + importName: string; +}; + +export type ApiFactory = DynamicModuleEntry & { + importName: string; +}; + +export type AnalyticsApiExtension = DynamicModuleEntry & { + importName: string; +}; + +export type ScaffolderFieldExtension = DynamicModuleEntry & { + importName: string; +}; + +export type TechdocsAddon = DynamicModuleEntry & { + importName: string; + config?: { + props?: Record; + }; +}; + +export type EntityTab = { + mountPoint: string; + path: string; + title: string; + pariority?: number; +}; + +export type EntityTabEntry = { + scope: string; + mountPoint: string; + path: string; + title: string; + titleKey?: string; + priority?: number; +}; + +export type ThemeEntry = DynamicModuleEntry & { + id: string; + title: string; + variant: 'light' | 'dark'; + icon: string; + importName: string; +}; + +export type SignInPageEntry = DynamicModuleEntry & { + importName: string; +}; + +export type ProviderSetting = { + title: string; + description: string; + provider: string; +}; + +export type TranslationConfig = { + defaultLocale?: string; + locales: string[]; + overrides?: string[]; +}; + +export type JSONTranslationConfig = { + locale: string; + path: string; +}; + +export type DynamicTranslationResource = { + scope: string; + module: string; + importName: string; + ref?: string | null; + jsonTranslations?: JSONTranslationConfig[]; +}; + +// Types from Backstage core-plugin-api do not expose loader function type +// so we need to create our own internal types to access the loader function + +type InternalTranslationResourceLoader = () => Promise<{ + messages: { [key in string]: string | null }; +}>; + +export interface InternalTranslationResource + extends TranslationResource { + version: 'v1'; + resources: { + language: string; + loader: InternalTranslationResourceLoader; + }[]; +} + +export type CustomProperties = { + pluginModule?: string; + dynamicRoutes?: { + importName?: string; + module?: string; + scope?: string; + path: string; + menuItem?: DynamicRouteMenuItem; + }[]; + menuItems?: { [key: string]: MenuItemConfig }; + routeBindings?: { + targets: BindingTarget[]; + bindings: RouteBinding[]; + }; + entityTabs?: EntityTab[]; + mountPoints?: MountPoint[]; + appIcons?: AppIcon[]; + apiFactories?: ApiFactory[]; + analyticsApiExtensions?: AnalyticsApiExtension[]; + providerSettings?: ProviderSetting[]; + scaffolderFieldExtensions?: ScaffolderFieldExtension[]; + signInPage: SignInPageEntry; + techdocsAddons?: TechdocsAddon[]; + themes?: ThemeEntry[]; + translationResources?: DynamicTranslationResource[]; +}; + +export type FrontendConfig = { + [key: string]: CustomProperties; +}; + +export type DynamicPluginConfig = { + frontend?: FrontendConfig; +}; + +export type DynamicConfig = { + pluginModules: PluginModule[]; + apiFactories: ApiFactory[]; + analyticsApiExtensions: AnalyticsApiExtension[]; + appIcons: AppIcon[]; + dynamicRoutes: DynamicRoute[]; + menuItems: MenuItem[]; + entityTabs: EntityTabEntry[]; + mountPoints: MountPoint[]; + providerSettings: ProviderSetting[]; + routeBindings: RouteBinding[]; + routeBindingTargets: BindingTarget[]; + scaffolderFieldExtensions: ScaffolderFieldExtension[]; + signInPages: SignInPageEntry[]; + techdocsAddons: TechdocsAddon[]; + themes: ThemeEntry[]; + translationResources: DynamicTranslationResource[]; +}; diff --git a/packages/app-utils/src/dynamic/config/utils.ts b/packages/app-utils/src/dynamic/config/utils.ts new file mode 100644 index 0000000000..772613bb8c --- /dev/null +++ b/packages/app-utils/src/dynamic/config/utils.ts @@ -0,0 +1,58 @@ +import { type Entity } from '@backstage/catalog-model'; +import { ApiHolder } from '@backstage/core-plugin-api'; +import { isKind } from '@backstage/plugin-catalog'; + +import { hasAnnotation, isType } from '../utils'; +import { MountPointConfigRawIf } from './types'; + +/** + * Evaluate the supplied conditional map. Used to determine the visibility of + * tabs in the UI + * @param conditional + * @returns + */ +export function configIfToCallable(conditional: MountPointConfigRawIf) { + return (entity: Entity, context?: { apis: ApiHolder }) => { + if (conditional?.allOf) { + return conditional.allOf + .map(conditionsArrayMapper) + .every(f => f(entity, context)); + } + if (conditional?.anyOf) { + return conditional.anyOf + .map(conditionsArrayMapper) + .some(f => f(entity, context)); + } + if (conditional?.oneOf) { + return ( + conditional.oneOf + .map(conditionsArrayMapper) + .filter(f => f(entity, context)).length === 1 + ); + } + return true; + }; +} + +export function conditionsArrayMapper( + condition: + | { + [key: string]: string | string[]; + } + | Function, +): (entity: Entity, context?: { apis: ApiHolder }) => boolean { + if (typeof condition === 'function') { + return (entity: Entity, context?: { apis: ApiHolder }): boolean => + condition(entity, context); + } + 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; +} diff --git a/packages/app-utils/src/dynamic/index.ts b/packages/app-utils/src/dynamic/index.ts new file mode 100644 index 0000000000..11d2600e1d --- /dev/null +++ b/packages/app-utils/src/dynamic/index.ts @@ -0,0 +1,3 @@ +export * from './config'; +export * from './routes'; +export * from './utils'; diff --git a/packages/app/src/utils/dynamicUI/bindAppRoutes.ts b/packages/app-utils/src/dynamic/routes/bindAppRoutes.ts similarity index 93% rename from packages/app/src/utils/dynamicUI/bindAppRoutes.ts rename to packages/app-utils/src/dynamic/routes/bindAppRoutes.ts index cd8ffd8bb8..a323d16b20 100644 --- a/packages/app/src/utils/dynamicUI/bindAppRoutes.ts +++ b/packages/app-utils/src/dynamic/routes/bindAppRoutes.ts @@ -6,10 +6,11 @@ import { catalogImportPlugin } from '@backstage/plugin-catalog-import'; import { orgPlugin } from '@backstage/plugin-org'; import { scaffolderPlugin } from '@backstage/plugin-scaffolder'; -import { RouteBinding } from '@red-hat-developer-hub/plugin-utils'; import get from 'lodash/get'; -const bindAppRoutes = ( +import { RouteBinding } from '../config'; + +export const bindAppRoutes = ( bind: AppRouteBinder, routeBindingTargets: { [key: string]: BackstagePlugin<{}> }, routeBindings: RouteBinding[], @@ -55,5 +56,3 @@ const bindAppRoutes = ( } }); }; - -export default bindAppRoutes; diff --git a/packages/app-utils/src/dynamic/routes/index.ts b/packages/app-utils/src/dynamic/routes/index.ts new file mode 100644 index 0000000000..badf39628b --- /dev/null +++ b/packages/app-utils/src/dynamic/routes/index.ts @@ -0,0 +1 @@ +export * from './bindAppRoutes'; diff --git a/packages/app-utils/src/dynamic/utils/index.ts b/packages/app-utils/src/dynamic/utils/index.ts new file mode 100644 index 0000000000..ab9abdc3c6 --- /dev/null +++ b/packages/app-utils/src/dynamic/utils/index.ts @@ -0,0 +1,16 @@ +import { type Entity } from '@backstage/catalog-model'; + +export const isType = (types: string | string[]) => (entity: Entity) => { + if (!entity?.spec?.type) { + return false; + } + return typeof types === 'string' + ? entity?.spec?.type === types + : types.includes(entity.spec.type as string); +}; + +export const hasAnnotation = (keys: string) => (entity: Entity) => + Boolean(entity.metadata.annotations?.[keys]); + +export const hasLinks = (entity: Entity) => + Boolean(entity.metadata.links?.length); diff --git a/packages/app-utils/src/index.ts b/packages/app-utils/src/index.ts new file mode 100644 index 0000000000..1b273eed9c --- /dev/null +++ b/packages/app-utils/src/index.ts @@ -0,0 +1,2 @@ +export * from './components'; +export * from './dynamic'; diff --git a/packages/app-utils/tsconfig.json b/packages/app-utils/tsconfig.json new file mode 100644 index 0000000000..81abfe79e2 --- /dev/null +++ b/packages/app-utils/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "@backstage/cli/config/tsconfig.json", + "include": ["src"], + "exclude": ["node_modules"], + "compilerOptions": { + "jsx": "react-jsx", + "outDir": "../../dist-types/packages/app-utils", + "rootDir": "." + } +} diff --git a/packages/app-utils/turbo.json b/packages/app-utils/turbo.json new file mode 100644 index 0000000000..e0657421f2 --- /dev/null +++ b/packages/app-utils/turbo.json @@ -0,0 +1,6 @@ +{ + "extends": ["//"], + "tasks": { + "tsc": { "outputs": ["../../dist-types/packages/app-utils/**"] } + } +} diff --git a/packages/app/package.json b/packages/app/package.json index 0c4a4f101b..0e06405469 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -46,12 +46,12 @@ "@mui/icons-material": "5.18.0", "@mui/material": "5.18.0", "@mui/styled-engine": "5.18.0", + "@red-hat-developer-hub/app-utils": "workspace:*", "@red-hat-developer-hub/backstage-plugin-theme": "0.10.1", "@red-hat-developer-hub/backstage-plugin-translations": "0.0.2", - "@red-hat-developer-hub/plugin-utils": "1.0.0", + "@red-hat-developer-hub/plugin-utils": "workspace:*", "@scalprum/core": "0.8.3", "@scalprum/react-core": "0.9.5", - "lodash": "4.17.21", "react": "18.3.1", "react-dom": "18.3.1", "react-router-dom": "6.30.1", diff --git a/packages/app/src/components/DynamicRoot/DynamicRoot.test.tsx b/packages/app/src/components/DynamicRoot/DynamicRoot.test.tsx index 5e10cf9529..9fff334c96 100644 --- a/packages/app/src/components/DynamicRoot/DynamicRoot.test.tsx +++ b/packages/app/src/components/DynamicRoot/DynamicRoot.test.tsx @@ -1,4 +1,4 @@ -import { Fragment, lazy, Suspense, useContext } from 'react'; +import { Fragment, lazy, ReactNode, Suspense, useContext } from 'react'; import * as useAsync from 'react-use/lib/useAsync'; import * as appDefaults from '@backstage/app-defaults'; @@ -182,7 +182,9 @@ describe.skip('DynamicRoot', () => { isFooConditionTrue: () => true, isFooConditionFalse: () => false, FooComponentWithStaticJSX: { - element: ({ children }) => <>{children}, + element: ({ children }: { children?: ReactNode }) => ( + <>{children} + ), staticJSXContent:
, }, }, diff --git a/packages/app/src/components/DynamicRoot/DynamicRoot.tsx b/packages/app/src/components/DynamicRoot/DynamicRoot.tsx index 4e394dccf8..ee0bfdca8b 100644 --- a/packages/app/src/components/DynamicRoot/DynamicRoot.tsx +++ b/packages/app/src/components/DynamicRoot/DynamicRoot.tsx @@ -4,96 +4,57 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { createApp } from '@backstage/app-defaults'; import { BackstageApp, MultipleAnalyticsApi } from '@backstage/core-app-api'; import { - AnalyticsApi, analyticsApiRef, AnyApiFactory, AppComponents, - AppTheme, BackstagePlugin, - ConfigApi, configApiRef, createApiFactory, - IdentityApi, identityApiRef, } from '@backstage/core-plugin-api'; import { appLanguageApiRef, translationApiRef, - TranslationResource, } from '@backstage/core-plugin-api/alpha'; +import { + bindAppRoutes, + configIfToCallable, + DynamicPluginConfig, + DynamicRoute, + extractDynamicConfig, + MenuIcon, +} from '@red-hat-developer-hub/app-utils'; import { useThemes } from '@red-hat-developer-hub/backstage-plugin-theme'; import { I18nextTranslationApi } from '@red-hat-developer-hub/backstage-plugin-translations'; import DynamicRootContext, { + AnalyticsApiClass, + AppThemeProvider, ComponentRegistry, DynamicRootConfig, EntityTabOverrides, MountPointConfig, MountPoints, + RemotePlugins, ResolvedDynamicRoute, ResolvedDynamicRouteMenuItem, - ScaffolderFieldExtension, - TechdocsAddon, + ResolvedScaffolderFieldExtension, + ResolvedTechdocsAddon, + StaticPlugins, + TranslationConfig, } from '@red-hat-developer-hub/plugin-utils'; import { AppsConfig } from '@scalprum/core'; import { useScalprum } from '@scalprum/react-core'; -import { TranslationConfig } from '../../types/types'; -import bindAppRoutes from '../../utils/dynamicUI/bindAppRoutes'; -import extractDynamicConfig, { - configIfToCallable, - DynamicPluginConfig, - DynamicRoute, -} from '../../utils/dynamicUI/extractDynamicConfig'; import initializeRemotePlugins from '../../utils/dynamicUI/initializeRemotePlugins'; import { getDefaultLanguage } from '../../utils/language/language'; import { fetchOverrideTranslations } from '../../utils/translations/fetchOverrideTranslations'; import { staticTranslationConfigs } from '../../utils/translations/staticTranslationConfigs'; import { processAllTranslationResources } from '../../utils/translations/translationResourceProcessor'; -import { MenuIcon } from '../Root/MenuIcon'; import CommonIcons from './CommonIcons'; import defaultAppComponents from './defaultAppComponents'; import Loader from './Loader'; -export type RemotePlugins = { - [scope: string]: { - [module: string]: { - [importName: string]: - | React.ComponentType - | ((...args: any[]) => any) - | BackstagePlugin<{}> - | { - element: React.ComponentType; - staticJSXContent: - | React.ReactNode - | ((config: DynamicRootConfig) => React.ReactNode); - } - | AnyApiFactory - | AnalyticsApiClass - | TranslationResource; - }; - }; -}; - -type AnalyticsApiClass = { - fromConfig( - config: ConfigApi, - deps: { identityApi: IdentityApi }, - ): AnalyticsApi; -}; - -type AppThemeProvider = Partial & Omit; - -export type StaticPlugins = Record< - string, - { - plugin: BackstagePlugin; - module: - | React.ComponentType - | { [importName: string]: React.ComponentType }; - } ->; - export const DynamicRoot = ({ afterInit, apis: staticApis, @@ -207,8 +168,12 @@ export const DynamicRoot = ({ ); const allScopes = Object.values(remotePlugins); - const allModules = allScopes.flatMap(scope => Object.values(scope)); - const allImports = allModules.flatMap(module => Object.values(module)); + const allModules = allScopes.flatMap(scope => + Object.values(scope as Record), + ); + const allImports = allModules.flatMap(mod => + Object.values(mod as Record), + ); const remoteBackstagePlugins = allImports.filter(imported => { if (!imported) { return false; @@ -458,7 +423,7 @@ export const DynamicRoot = ({ ); const scaffolderFieldExtensionComponents = scaffolderFieldExtensions.reduce< - ScaffolderFieldExtension[] + ResolvedScaffolderFieldExtension[] >((acc, { scope, module, importName }) => { const extensionComponent = allPlugins[scope]?.[module]?.[importName]; if (extensionComponent) { @@ -477,29 +442,28 @@ export const DynamicRoot = ({ return acc; }, []); - const techdocsAddonComponents = techdocsAddons.reduce( - (acc, { scope, module, importName, config }) => { - const extensionComponent = allPlugins[scope]?.[module]?.[importName]; - if (extensionComponent) { - acc.push({ - scope, - module, - importName, - Component: extensionComponent as React.ComponentType, - config: { - ...config, - }, - }); - } else { - // eslint-disable-next-line no-console - console.warn( - `Plugin ${scope} is not configured properly: ${module}.${importName} not found, ignoring techdocsAddon: ${importName}`, - ); - } - return acc; - }, - [], - ); + const techdocsAddonComponents = techdocsAddons.reduce< + ResolvedTechdocsAddon[] + >((acc, { scope, module, importName, config }) => { + const extensionComponent = allPlugins[scope]?.[module]?.[importName]; + if (extensionComponent) { + acc.push({ + scope, + module, + importName, + Component: extensionComponent as React.ComponentType, + config: { + ...config, + }, + }); + } else { + // eslint-disable-next-line no-console + console.warn( + `Plugin ${scope} is not configured properly: ${module}.${importName} not found, ignoring techdocsAddon: ${importName}`, + ); + } + return acc; + }, []); const dynamicThemeProviders = pluginThemes.reduce( (acc, { scope, module, importName, icon, ...rest }) => { diff --git a/packages/app/src/components/DynamicRoot/ScalprumRoot.tsx b/packages/app/src/components/DynamicRoot/ScalprumRoot.tsx index 8bbe9b100c..14083d85c6 100644 --- a/packages/app/src/components/DynamicRoot/ScalprumRoot.tsx +++ b/packages/app/src/components/DynamicRoot/ScalprumRoot.tsx @@ -6,14 +6,19 @@ import { AppConfig } from '@backstage/config'; import { ConfigReader, defaultConfigLoader } from '@backstage/core-app-api'; import { AnyApiFactory } from '@backstage/core-plugin-api'; -import { DynamicRootConfig } from '@red-hat-developer-hub/plugin-utils'; +import { + DynamicPluginConfig, + overrideBaseUrlConfigs, +} from '@red-hat-developer-hub/app-utils'; +import { + DynamicRootConfig, + StaticPlugins, + TranslationConfig, +} from '@red-hat-developer-hub/plugin-utils'; import { AppsConfig } from '@scalprum/core'; import { ScalprumProvider } from '@scalprum/react-core'; -import { TranslationConfig } from '../../types/types'; -import { DynamicPluginConfig } from '../../utils/dynamicUI/extractDynamicConfig'; -import overrideBaseUrlConfigs from '../../utils/dynamicUI/overrideBaseUrlConfigs'; -import { DynamicRoot, StaticPlugins } from './DynamicRoot'; +import { DynamicRoot } from './DynamicRoot'; import Loader from './Loader'; export type ScalprumApiHolder = { diff --git a/packages/app/src/components/Root/ApplicationHeaders.tsx b/packages/app/src/components/Root/ApplicationHeaders.tsx index a752fec018..764db4a5af 100644 --- a/packages/app/src/components/Root/ApplicationHeaders.tsx +++ b/packages/app/src/components/Root/ApplicationHeaders.tsx @@ -8,10 +8,11 @@ import { import { ErrorBoundary } from '@backstage/core-components'; -import DynamicRootContext, { +import { MountPoint, MountPointConfigBase, -} from '@red-hat-developer-hub/plugin-utils'; +} from '@red-hat-developer-hub/app-utils'; +import DynamicRootContext from '@red-hat-developer-hub/plugin-utils'; type Position = 'above-main-content' | 'above-sidebar'; @@ -35,7 +36,7 @@ export const ApplicationHeaders = ({ position }: { position: Position }) => { const appHeaderMountPoints = useMemo(() => { const appHeaderMP = (mountPoints['application/header'] ?? - []) as ApplicationHeaderMountPoint[]; + []) as unknown as ApplicationHeaderMountPoint[]; return appHeaderMP.filter(({ config }) => config?.position === position); }, [mountPoints, position]); diff --git a/packages/app/src/components/Root/Root.tsx b/packages/app/src/components/Root/Root.tsx index 0eeb360f6c..ab6b396fbe 100644 --- a/packages/app/src/components/Root/Root.tsx +++ b/packages/app/src/components/Root/Root.tsx @@ -37,6 +37,7 @@ import Collapse from '@mui/material/Collapse'; import List from '@mui/material/List'; import ListItem from '@mui/material/ListItem'; import { styled, SxProps, Theme } from '@mui/material/styles'; +import { MenuIcon } from '@red-hat-developer-hub/app-utils'; import { ThemeConfig } from '@red-hat-developer-hub/backstage-plugin-theme'; import DynamicRootContext, { ResolvedMenuItem, @@ -45,7 +46,6 @@ import DynamicRootContext, { import { useLanguagePreference } from '../../hooks/useLanguagePreference'; import { useTranslation } from '../../hooks/useTranslation'; import { ApplicationHeaders } from './ApplicationHeaders'; -import { MenuIcon } from './MenuIcon'; import { SidebarLogo } from './SidebarLogo'; /** diff --git a/packages/app/src/components/UserSettings/SettingsPages.tsx b/packages/app/src/components/UserSettings/SettingsPages.tsx index 5bab036cff..39c82fb51b 100644 --- a/packages/app/src/components/UserSettings/SettingsPages.tsx +++ b/packages/app/src/components/UserSettings/SettingsPages.tsx @@ -16,7 +16,7 @@ import { } from '@backstage/plugin-user-settings'; import Star from '@mui/icons-material/Star'; -import { ProviderSetting } from '@red-hat-developer-hub/plugin-utils'; +import { ProviderSetting } from '@red-hat-developer-hub/app-utils'; import { oidcAuthApiRef } from '../../api/AuthApiRefs'; import { GeneralPage } from './GeneralPage'; diff --git a/packages/app/src/components/catalog/EntityPage/ContextMenuAwareEntityLayout.tsx b/packages/app/src/components/catalog/EntityPage/ContextMenuAwareEntityLayout.tsx index 0c217319d6..5f5dbcfdda 100644 --- a/packages/app/src/components/catalog/EntityPage/ContextMenuAwareEntityLayout.tsx +++ b/packages/app/src/components/catalog/EntityPage/ContextMenuAwareEntityLayout.tsx @@ -8,8 +8,9 @@ import { import { EntityLayout } from '@backstage/plugin-catalog'; +import { MenuIcon } from '@red-hat-developer-hub/app-utils'; + import getMountPointData from '../../../utils/dynamicUI/getMountPointData'; -import { MenuIcon } from '../../Root/MenuIcon'; const makeIcon = (iconName: string) => () => ; diff --git a/packages/app/src/components/search/SearchPage.tsx b/packages/app/src/components/search/SearchPage.tsx index f175cb6b54..4fed864f41 100644 --- a/packages/app/src/components/search/SearchPage.tsx +++ b/packages/app/src/components/search/SearchPage.tsx @@ -10,11 +10,11 @@ import { import Grid from '@mui/material/Grid'; import Paper from '@mui/material/Paper'; +import { MenuIcon } from '@red-hat-developer-hub/app-utils'; import { makeStyles } from 'tss-react/mui'; import { useTranslation } from '../../hooks/useTranslation'; import getMountPointData from '../../utils/dynamicUI/getMountPointData'; -import { MenuIcon } from '../Root/MenuIcon'; const useStyles = makeStyles()(theme => ({ searchBar: { diff --git a/packages/app/src/hooks/useLanguagePreference.ts b/packages/app/src/hooks/useLanguagePreference.ts index 627565486a..26c7d19b16 100644 --- a/packages/app/src/hooks/useLanguagePreference.ts +++ b/packages/app/src/hooks/useLanguagePreference.ts @@ -10,7 +10,8 @@ import { } from '@backstage/core-plugin-api'; import { appLanguageApiRef } from '@backstage/core-plugin-api/alpha'; -import { TranslationConfig } from '../types/types'; +import { TranslationConfig } from '@red-hat-developer-hub/plugin-utils'; + import { getDefaultLanguage } from '../utils/language/language'; const BUCKET = 'userSettings'; diff --git a/packages/app/src/types/types.ts b/packages/app/src/types/types.ts index 87002175d8..2d7b77e6ac 100644 --- a/packages/app/src/types/types.ts +++ b/packages/app/src/types/types.ts @@ -1,5 +1,3 @@ -import { TranslationResource } from '@backstage/core-plugin-api/alpha'; - export type LearningPathLink = { label: string; url: string; @@ -16,38 +14,3 @@ export type BuildInfo = { full?: boolean; overrideBuildInfo?: boolean; }; - -export type TranslationConfig = { - defaultLocale?: string; - locales: string[]; - overrides?: string[]; -}; - -export type JSONTranslationConfig = { - locale: string; - path: string; -}; - -export type DynamicTranslationResource = { - scope: string; - module: string; - importName: string; - ref?: string | null; - jsonTranslations?: JSONTranslationConfig[]; -}; - -// Types from Backstage core-plugin-api do not expose loader function type -// so we need to create our own internal types to access the loader function - -type InternalTranslationResourceLoader = () => Promise<{ - messages: { [key in string]: string | null }; -}>; - -export interface InternalTranslationResource - extends TranslationResource { - version: 'v1'; - resources: { - language: string; - loader: InternalTranslationResourceLoader; - }[]; -} diff --git a/packages/app/src/utils/dynamicUI/initializeRemotePlugins.ts b/packages/app/src/utils/dynamicUI/initializeRemotePlugins.ts index 6c06b5f4be..944181cc52 100644 --- a/packages/app/src/utils/dynamicUI/initializeRemotePlugins.ts +++ b/packages/app/src/utils/dynamicUI/initializeRemotePlugins.ts @@ -1,8 +1,7 @@ +import { RemotePlugins } from '@red-hat-developer-hub/plugin-utils'; import { AppsConfig, processManifest } from '@scalprum/core'; import { ScalprumState } from '@scalprum/react-core'; -import { RemotePlugins } from '../../components/DynamicRoot/DynamicRoot'; - // See packages/app/src/App.tsx const ignoreStaticPlugins = ['default.main-menu-items']; diff --git a/packages/app/src/utils/language/language.test.ts b/packages/app/src/utils/language/language.test.ts index e76da4afc6..d5b2bac16e 100644 --- a/packages/app/src/utils/language/language.test.ts +++ b/packages/app/src/utils/language/language.test.ts @@ -1,4 +1,5 @@ -import { TranslationConfig } from '../../types/types'; +import { TranslationConfig } from '@red-hat-developer-hub/plugin-utils'; + import { getDefaultLanguage } from './language'; // Mock navigator for testing diff --git a/packages/app/src/utils/language/language.ts b/packages/app/src/utils/language/language.ts index 35df8df6c1..2c79dc739c 100644 --- a/packages/app/src/utils/language/language.ts +++ b/packages/app/src/utils/language/language.ts @@ -1,4 +1,4 @@ -import { TranslationConfig } from '../../types/types'; +import { TranslationConfig } from '@red-hat-developer-hub/plugin-utils'; /** * Determines the default language: diff --git a/packages/app/src/utils/translations/translationResourceGenerator.ts b/packages/app/src/utils/translations/translationResourceGenerator.ts index 82e38a5bdb..5efc099237 100644 --- a/packages/app/src/utils/translations/translationResourceGenerator.ts +++ b/packages/app/src/utils/translations/translationResourceGenerator.ts @@ -5,7 +5,7 @@ import { TranslationResource, } from '@backstage/core-plugin-api/alpha'; -import { InternalTranslationResource } from '../../types/types'; +import { InternalTranslationResource } from '@red-hat-developer-hub/app-utils'; const createTranslationMessagesWrapper = ( ref: TranslationRef, diff --git a/packages/app/src/utils/translations/translationResourceProcessor.ts b/packages/app/src/utils/translations/translationResourceProcessor.ts index 1c14cfbe1d..128a84e67a 100644 --- a/packages/app/src/utils/translations/translationResourceProcessor.ts +++ b/packages/app/src/utils/translations/translationResourceProcessor.ts @@ -3,7 +3,8 @@ import { TranslationResource, } from '@backstage/core-plugin-api/alpha'; -import { InternalTranslationResource } from '../../types/types'; +import { InternalTranslationResource } from '@red-hat-developer-hub/app-utils'; + import { translationResourceGenerator } from './translationResourceGenerator'; export interface TranslationResourceWithRef { diff --git a/packages/plugin-utils/src/types.ts b/packages/plugin-utils/src/types.ts index f5afff0ac3..0f33178094 100644 --- a/packages/plugin-utils/src/types.ts +++ b/packages/plugin-utils/src/types.ts @@ -1,13 +1,52 @@ import { Entity } from '@backstage/catalog-model'; +import { + AnalyticsApi, + AnyApiFactory, + AppTheme, + BackstagePlugin, + ConfigApi, + IdentityApi, +} from '@backstage/core-plugin-api'; import { TranslationRef } from '@backstage/core-plugin-api/alpha'; -export type RouteBinding = { - bindTarget: string; - bindMap: { - [target: string]: string; +export type RemotePlugins = { + [scope: string]: { + [module: string]: { + [importName: string]: + | React.ComponentType + | ((...args: any[]) => any) + | BackstagePlugin<{}> + | { + element: React.ComponentType; + staticJSXContent: + | React.ReactNode + | ((config: DynamicRootConfig) => React.ReactNode); + } + | AnyApiFactory + | AnalyticsApiClass; + }; }; }; +export type AnalyticsApiClass = { + fromConfig( + config: ConfigApi, + deps: { identityApi: IdentityApi }, + ): AnalyticsApi; +}; + +export type AppThemeProvider = Partial & Omit; + +export type StaticPlugins = Record< + string, + { + plugin: BackstagePlugin; + module: + | React.ComponentType + | { [importName: string]: React.ComponentType }; + } +>; + export type ResolvedDynamicRouteMenuItem = | { text: string; @@ -46,29 +85,13 @@ export type ResolvedDynamicRoute = { }; }; -export type MountPointConfigBase = { +export type MountPointConfig = { layout?: Record; - props?: Record; -}; - -export type MountPointConfig = MountPointConfigBase & { if: (e: Entity) => boolean; + props?: Record; }; -export type MountPointConfigRawIf = { - [key in 'allOf' | 'oneOf' | 'anyOf']?: ( - | { - [key: string]: string | string[]; - } - | Function - )[]; -}; - -export type MountPointConfigRaw = MountPointConfigBase & { - if?: MountPointConfigRawIf; -}; - -export type MountPoint = { +export type ResolvedMountPoint = { Component: React.ComponentType; config?: MountPointConfig; staticJSXContent?: @@ -81,16 +104,16 @@ export type EntityTabOverrides = Record< { title: string; titleKey?: string; mountPoint: string; priority?: number } >; -export type MountPoints = Record; +export type MountPoints = Record; -export type ScaffolderFieldExtension = { +export type ResolvedScaffolderFieldExtension = { scope: string; module: string; importName: string; Component: React.ComponentType<{}>; }; -export type TechdocsAddon = { +export type ResolvedTechdocsAddon = { scope: string; module: string; importName: string; @@ -100,20 +123,25 @@ export type TechdocsAddon = { }; }; -export type ProviderSetting = { +export type ResolvedProviderSetting = { title: string; description: string; provider: string; }; +export type TranslationConfig = { + defaultLocale?: string; + locales: string[]; +}; + export type DynamicRootConfig = { dynamicRoutes: ResolvedDynamicRoute[]; entityTabOverrides: EntityTabOverrides; mountPoints: MountPoints; menuItems: ResolvedMenuItem[]; - providerSettings: ProviderSetting[]; - scaffolderFieldExtensions: ScaffolderFieldExtension[]; - techdocsAddons: TechdocsAddon[]; + providerSettings: ResolvedProviderSetting[]; + scaffolderFieldExtensions: ResolvedScaffolderFieldExtension[]; + techdocsAddons: ResolvedTechdocsAddon[]; translationRefs: TranslationRef[]; }; diff --git a/yarn.lock b/yarn.lock index 1b2abe5f55..640d560e0a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -13912,6 +13912,38 @@ __metadata: languageName: node linkType: hard +"@red-hat-developer-hub/app-utils@workspace:*, @red-hat-developer-hub/app-utils@workspace:packages/app-utils": + version: 0.0.0-use.local + resolution: "@red-hat-developer-hub/app-utils@workspace:packages/app-utils" + dependencies: + "@backstage/catalog-model": 1.7.5 + "@backstage/cli": 0.34.1 + "@backstage/config": 1.3.3 + "@backstage/core-app-api": 1.18.0 + "@backstage/core-plugin-api": 1.10.9 + "@backstage/plugin-api-docs": 0.12.10 + "@backstage/plugin-catalog": 1.31.2 + "@backstage/plugin-catalog-import": 0.13.4 + "@backstage/plugin-org": 0.6.43 + "@backstage/plugin-scaffolder": 1.34.0 + "@backstage/test-utils": 1.7.11 + "@mui/material": 5.18.0 + "@testing-library/dom": 9.3.4 + "@testing-library/jest-dom": 6.9.1 + "@testing-library/react": 14.3.1 + "@testing-library/react-hooks": 8.0.1 + "@types/node": 22.18.11 + "@types/react": 18.3.26 + "@types/react-dom": 18.3.7 + lodash: 4.17.21 + prettier: 3.6.2 + react: 18.3.1 + typescript: 5.9.3 + peerDependencies: + react: 16.13.1 || ^17.0.0 || ^18.2.0 + languageName: unknown + linkType: soft + "@red-hat-developer-hub/backstage-plugin-theme@npm:0.10.1": version: 0.10.1 resolution: "@red-hat-developer-hub/backstage-plugin-theme@npm:0.10.1" @@ -13963,7 +13995,7 @@ __metadata: languageName: node linkType: hard -"@red-hat-developer-hub/plugin-utils@1.0.0, @red-hat-developer-hub/plugin-utils@workspace:packages/plugin-utils": +"@red-hat-developer-hub/plugin-utils@workspace:*, @red-hat-developer-hub/plugin-utils@workspace:packages/plugin-utils": version: 0.0.0-use.local resolution: "@red-hat-developer-hub/plugin-utils@workspace:packages/plugin-utils" dependencies: @@ -18632,9 +18664,10 @@ __metadata: "@mui/icons-material": 5.18.0 "@mui/material": 5.18.0 "@mui/styled-engine": 5.18.0 + "@red-hat-developer-hub/app-utils": "workspace:*" "@red-hat-developer-hub/backstage-plugin-theme": 0.10.1 "@red-hat-developer-hub/backstage-plugin-translations": 0.0.2 - "@red-hat-developer-hub/plugin-utils": 1.0.0 + "@red-hat-developer-hub/plugin-utils": "workspace:*" "@scalprum/core": 0.8.3 "@scalprum/react-core": 0.9.5 "@scalprum/react-test-utils": 0.2.7 @@ -18646,7 +18679,6 @@ __metadata: "@types/node": 22.18.11 "@types/react": 18.3.26 "@types/react-dom": 18.3.7 - lodash: 4.17.21 prettier: 3.6.2 react: 18.3.1 react-dom: 18.3.1