From c324751332bf5dc9ae9c2469e269345f5567675c Mon Sep 17 00:00:00 2001 From: Stephanie Swaney Date: Thu, 7 Aug 2025 16:02:10 -0500 Subject: [PATCH 01/69] feat: add optional enrichVisit function onto VisitListener to add fields to customize chips Signed-off-by: Stephanie Swaney feat: add VisitInput which has visit before auto saved fields, used to enrich in consuming app Signed-off-by: Stephanie Swaney chore: Rename ItemCategoryContext to VisitDisplayContext fix: rename the remainder to VisitDisplayContext from ItemCategoryContext Signed-off-by: Stephanie Swaney test: Add tests for VisitListener component --- plugins/home/report-alpha.api.md | 2 +- plugins/home/report.api.md | 48 ++++++ .../home/src/components/VisitList/Context.tsx | 138 +++++++++++++++++ .../src/components/VisitList/ItemCategory.tsx | 45 +----- .../src/components/VisitList/ItemDetail.tsx | 3 + .../src/components/VisitList/VisitList.tsx | 3 + .../home/src/components/VisitList/index.ts | 9 +- .../src/components/VisitListener.test.tsx | 143 +++++++++++++++++- plugins/home/src/components/VisitListener.tsx | 50 +++++- plugins/home/src/components/index.ts | 1 + .../VisitedByType/Context.tsx | 16 +- .../VisitedByType/VisitedByType.tsx | 2 +- 12 files changed, 400 insertions(+), 60 deletions(-) create mode 100644 plugins/home/src/components/VisitList/Context.tsx diff --git a/plugins/home/report-alpha.api.md b/plugins/home/report-alpha.api.md index d2e5c22ef8..83568c0e45 100644 --- a/plugins/home/report-alpha.api.md +++ b/plugins/home/report-alpha.api.md @@ -105,7 +105,6 @@ export default _default; export const homeTranslationRef: TranslationRef< 'home', { - readonly 'starredEntities.noStarredEntitiesMessage': 'Click the star beside an entity name to add it to this list!'; readonly 'addWidgetDialog.title': 'Add new widget to dashboard'; readonly 'customHomepageButtons.clearAll': 'Clear all'; readonly 'customHomepageButtons.edit': 'Edit'; @@ -124,6 +123,7 @@ export const homeTranslationRef: TranslationRef< readonly 'quickStart.title': 'Onboarding'; readonly 'quickStart.description': 'Get started with Backstage'; readonly 'quickStart.learnMoreLinkTitle': 'Learn more'; + readonly 'starredEntities.noStarredEntitiesMessage': 'Click the star beside an entity name to add it to this list!'; readonly 'visitedByType.action.viewMore': 'View more'; readonly 'visitedByType.action.viewLess': 'View less'; readonly 'featuredDocsCard.empty.title': 'No documents to show'; diff --git a/plugins/home/report.api.md b/plugins/home/report.api.md index dd33b952ee..2f70f1a9c9 100644 --- a/plugins/home/report.api.md +++ b/plugins/home/report.api.md @@ -117,6 +117,12 @@ export type FeaturedDocsCardProps = { subLinkText?: string; }; +// @public +export type GetChipColorFunction = (visit: Visit) => string; + +// @public +export type GetLabelFunction = (visit: Visit) => string; + // @public export const HeaderWorldClock: (props: { clockConfigs: ClockConfig[]; @@ -245,6 +251,9 @@ export type ToolkitContentProps = { tools: Tool[]; }; +// @public +export const useVisitDisplay: () => VisitDisplayContextValue; + // @public export type Visit = { id: string; @@ -255,6 +264,31 @@ export type Visit = { entityRef?: string; }; +// @public +export interface VisitDisplayContextValue { + // (undocumented) + getChipColor: GetChipColorFunction; + // (undocumented) + getLabel: GetLabelFunction; +} + +// @public +export const VisitDisplayProvider: ({ + children, + getChipColor, + getLabel, +}: VisitDisplayProviderProps) => JSX_2.Element; + +// @public +export interface VisitDisplayProviderProps { + // (undocumented) + children: ReactNode; + // (undocumented) + getChipColor?: GetChipColorFunction; + // (undocumented) + getLabel?: GetLabelFunction; +} + // @public (undocumented) export type VisitedByTypeKind = 'recent' | 'top'; @@ -267,15 +301,29 @@ export type VisitedByTypeProps = { kind: VisitedByTypeKind; }; +// @public +export type VisitEnrichmentFunction = ( + visit: VisitInput, +) => Record | Promise>; + +// @public +export type VisitInput = { + name: string; + pathname: string; + entityRef?: string; +}; + // @public export const VisitListener: ({ children, toEntityRef, visitName, + enrichVisit, }: { children?: ReactNode; toEntityRef?: ({ pathname }: { pathname: string }) => string | undefined; visitName?: ({ pathname }: { pathname: string }) => string; + enrichVisit?: VisitEnrichmentFunction; }) => JSX.Element; // @public diff --git a/plugins/home/src/components/VisitList/Context.tsx b/plugins/home/src/components/VisitList/Context.tsx new file mode 100644 index 0000000000..299d88dc14 --- /dev/null +++ b/plugins/home/src/components/VisitList/Context.tsx @@ -0,0 +1,138 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createContext, useContext, ReactNode } from 'react'; +import { CompoundEntityRef, parseEntityRef } from '@backstage/catalog-model'; +import { colorVariants } from '@backstage/theme'; +import { Visit } from '../../api/VisitsApi'; + +/** + * Type definition for the chip color function + * @public + */ +export type GetChipColorFunction = (visit: Visit) => string; + +/** + * Type definition for the label function + * @public + */ +export type GetLabelFunction = (visit: Visit) => string; + +/** + * Context value interface + * @public + */ +export interface VisitDisplayContextValue { + getChipColor: GetChipColorFunction; + getLabel: GetLabelFunction; +} + +/** + * Props for the VisitDisplayProvider + * @public + */ +export interface VisitDisplayProviderProps { + children: ReactNode; + getChipColor?: GetChipColorFunction; + getLabel?: GetLabelFunction; +} + +// Default implementations +const getColorByIndex = (index: number) => { + const variants = Object.keys(colorVariants); + const variantIndex = index % variants.length; + return colorVariants[variants[variantIndex]][0]; +}; + +const maybeEntity = (visit: Visit): CompoundEntityRef | undefined => { + try { + return parseEntityRef(visit?.entityRef ?? ''); + } catch (e) { + return undefined; + } +}; + +const defaultGetChipColor: GetChipColorFunction = (visit: Visit): string => { + const defaultColor = getColorByIndex(0); + const entity = maybeEntity(visit); + if (!entity) return defaultColor; + + // IDEA: Use or replicate useAllKinds hook thus supporting all software catalog + // registered kinds. See: + // plugins/catalog-react/src/components/EntityKindPicker/kindFilterUtils.ts + // Provide extension point to register your own color code. + const entityKinds = [ + 'component', + 'template', + 'api', + 'group', + 'user', + 'resource', + 'system', + 'domain', + 'location', + ]; + const foundIndex = entityKinds.indexOf( + entity.kind.toLocaleLowerCase('en-US'), + ); + return foundIndex === -1 ? defaultColor : getColorByIndex(foundIndex + 1); +}; + +const defaultGetLabel: GetLabelFunction = (visit: Visit): string => { + const entity = maybeEntity(visit); + return (entity?.kind ?? 'Other').toLocaleLowerCase('en-US'); +}; + +// Create the context +const VisitDisplayContext = createContext({ + getChipColor: defaultGetChipColor, + getLabel: defaultGetLabel, +}); + +/** + * Provider component for VisitDisplay customization + * @public + */ +export const VisitDisplayProvider = ({ + children, + getChipColor = defaultGetChipColor, + getLabel = defaultGetLabel, +}: VisitDisplayProviderProps) => { + const value: VisitDisplayContextValue = { + getChipColor, + getLabel, + }; + + return ( + + {children} + + ); +}; + +/** + * Hook to use the VisitDisplay context + * @public + */ +export const useVisitDisplay = (): VisitDisplayContextValue => { + const context = useContext(VisitDisplayContext); + if (!context) { + throw new Error( + 'useVisitDisplay must be used within a VisitDisplayProvider', + ); + } + return context; +}; diff --git a/plugins/home/src/components/VisitList/ItemCategory.tsx b/plugins/home/src/components/VisitList/ItemCategory.tsx index 7eaf54183d..140d9ada68 100644 --- a/plugins/home/src/components/VisitList/ItemCategory.tsx +++ b/plugins/home/src/components/VisitList/ItemCategory.tsx @@ -16,9 +16,8 @@ import Chip from '@material-ui/core/Chip'; import { makeStyles } from '@material-ui/core/styles'; -import { colorVariants } from '@backstage/theme'; import { Visit } from '../../api/VisitsApi'; -import { CompoundEntityRef, parseEntityRef } from '@backstage/catalog-model'; +import { useVisitDisplay } from './Context'; const useStyles = makeStyles(theme => ({ chip: { @@ -27,53 +26,17 @@ const useStyles = makeStyles(theme => ({ margin: 0, }, })); -const maybeEntity = (visit: Visit): CompoundEntityRef | undefined => { - try { - return parseEntityRef(visit?.entityRef ?? ''); - } catch (e) { - return undefined; - } -}; -const getColorByIndex = (index: number) => { - const variants = Object.keys(colorVariants); - const variantIndex = index % variants.length; - return colorVariants[variants[variantIndex]][0]; -}; -const getChipColor = (entity: CompoundEntityRef | undefined): string => { - const defaultColor = getColorByIndex(0); - if (!entity) return defaultColor; - - // IDEA: Use or replicate useAllKinds hook thus supporting all software catalog - // registered kinds. See: - // plugins/catalog-react/src/components/EntityKindPicker/kindFilterUtils.ts - // Provide extension point to register your own color code. - const entityKinds = [ - 'component', - 'template', - 'api', - 'group', - 'user', - 'resource', - 'system', - 'domain', - 'location', - ]; - const foundIndex = entityKinds.indexOf( - entity.kind.toLocaleLowerCase('en-US'), - ); - return foundIndex === -1 ? defaultColor : getColorByIndex(foundIndex + 1); -}; export const ItemCategory = ({ visit }: { visit: Visit }) => { const classes = useStyles(); - const entity = maybeEntity(visit); + const { getChipColor, getLabel } = useVisitDisplay(); return ( ); }; diff --git a/plugins/home/src/components/VisitList/ItemDetail.tsx b/plugins/home/src/components/VisitList/ItemDetail.tsx index 93194639cc..163e593808 100644 --- a/plugins/home/src/components/VisitList/ItemDetail.tsx +++ b/plugins/home/src/components/VisitList/ItemDetail.tsx @@ -39,6 +39,9 @@ const ItemDetailTimeAgo = ({ visit }: { visit: Visit }) => { ); }; +/** + * @internal + */ export type ItemDetailType = 'time-ago' | 'hits'; export const ItemDetail = ({ diff --git a/plugins/home/src/components/VisitList/VisitList.tsx b/plugins/home/src/components/VisitList/VisitList.tsx index 19d5a6fb88..75b3aa3291 100644 --- a/plugins/home/src/components/VisitList/VisitList.tsx +++ b/plugins/home/src/components/VisitList/VisitList.tsx @@ -24,6 +24,9 @@ import { VisitListEmpty } from './VisitListEmpty'; import { VisitListFew } from './VisitListFew'; import { VisitListSkeleton } from './VisitListSkeleton'; +/** + * @internal + */ export const VisitList = ({ detailType, visits = [], diff --git a/plugins/home/src/components/VisitList/index.ts b/plugins/home/src/components/VisitList/index.ts index 2d9513893b..d9e58fbf6d 100644 --- a/plugins/home/src/components/VisitList/index.ts +++ b/plugins/home/src/components/VisitList/index.ts @@ -14,4 +14,11 @@ * limitations under the License. */ -export { VisitList } from './VisitList'; +// Public API exports +export { VisitDisplayProvider, useVisitDisplay } from './Context'; +export type { + GetChipColorFunction, + GetLabelFunction, + VisitDisplayContextValue, + VisitDisplayProviderProps, +} from './Context'; diff --git a/plugins/home/src/components/VisitListener.test.tsx b/plugins/home/src/components/VisitListener.test.tsx index 7f7db95a9e..c9de01b45f 100644 --- a/plugins/home/src/components/VisitListener.test.tsx +++ b/plugins/home/src/components/VisitListener.test.tsx @@ -15,7 +15,7 @@ */ import { TestApiProvider, renderInTestApp } from '@backstage/test-utils'; import { Visit, visitsApiRef } from '../api'; -import { VisitListener } from './VisitListener'; +import { VisitListener, VisitEnrichmentFunction } from './VisitListener'; import { waitFor } from '@testing-library/react'; const visits: Array = [ @@ -130,4 +130,145 @@ describe('', () => { }), ); }); + + describe('requestId tests', () => { + beforeEach(() => { + // Mock requestAnimationFrame to execute immediately + global.requestAnimationFrame = jest.fn(callback => { + callback(0); + return 1; + }); + global.cancelAnimationFrame = jest.fn(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('uses requestAnimationFrame to defer visit saving', async () => { + const pathname = '/catalog/default/component/test-component'; + + await renderInTestApp( + + + , + { routeEntries: [pathname] }, + ); + + expect(global.requestAnimationFrame).toHaveBeenCalledTimes(1); + await waitFor(() => expect(mockVisitsApi.save).toHaveBeenCalledTimes(1)); + }); + + it('saves base visit when no enrichment function is provided', async () => { + const pathname = '/catalog/default/component/test-component'; + + await renderInTestApp( + + + , + { routeEntries: [pathname] }, + ); + + await waitFor(() => + expect(mockVisitsApi.save).toHaveBeenCalledWith({ + visit: { + pathname, + entityRef: 'component:default/test-component', + name: 'test-component', + }, + }), + ); + }); + + it('enriches visit with additional data when enrichVisit function is provided', async () => { + const pathname = '/catalog/default/component/test-component'; + const enrichVisit: VisitEnrichmentFunction = jest.fn(async _visit => ({ + customProperty: 'custom-value', + category: 'test-category', + priority: 1, + })); + + await renderInTestApp( + + + , + { routeEntries: [pathname] }, + ); + + await waitFor(() => { + expect(enrichVisit).toHaveBeenCalledWith({ + pathname, + entityRef: 'component:default/test-component', + name: 'test-component', + }); + expect(mockVisitsApi.save).toHaveBeenCalledWith({ + visit: { + pathname, + entityRef: 'component:default/test-component', + name: 'test-component', + customProperty: 'custom-value', + category: 'test-category', + priority: 1, + }, + }); + }); + }); + + it('handles synchronous enrichment function', async () => { + const pathname = '/catalog/default/component/test-component'; + const enrichVisit: VisitEnrichmentFunction = jest.fn(_visit => ({ + syncProperty: 'sync-value', + })); + + await renderInTestApp( + + + , + { routeEntries: [pathname] }, + ); + + await waitFor(() => { + expect(enrichVisit).toHaveBeenCalledWith({ + pathname, + entityRef: 'component:default/test-component', + name: 'test-component', + }); + expect(mockVisitsApi.save).toHaveBeenCalledWith({ + visit: { + pathname, + entityRef: 'component:default/test-component', + name: 'test-component', + syncProperty: 'sync-value', + }, + }); + }); + }); + + it('enrichment function can override base visit properties', async () => { + const pathname = '/catalog/default/component/test-component'; + const enrichVisit: VisitEnrichmentFunction = jest.fn(async _visit => ({ + name: 'Overridden Name', + entityRef: 'overridden:ref/value', + customField: 'additional-data', + })); + + await renderInTestApp( + + + , + { routeEntries: [pathname] }, + ); + + await waitFor(() => { + expect(mockVisitsApi.save).toHaveBeenCalledWith({ + visit: { + pathname, + name: 'Overridden Name', + entityRef: 'overridden:ref/value', + customField: 'additional-data', + }, + }); + }); + }); + }); }); diff --git a/plugins/home/src/components/VisitListener.tsx b/plugins/home/src/components/VisitListener.tsx index ee4f4558c1..e7a8cf4a22 100644 --- a/plugins/home/src/components/VisitListener.tsx +++ b/plugins/home/src/components/VisitListener.tsx @@ -74,6 +74,25 @@ const getVisitName = return document.title; }; +/** + * @public + * Type definition for visit data before it's saved (without auto-generated fields) + */ +export type VisitInput = { + name: string; + pathname: string; + entityRef?: string; +}; + +/** + * @public + * Type definition for the visit enrichment function + * This allows adding custom properties to visits at save time + */ +export type VisitEnrichmentFunction = ( + visit: VisitInput, +) => Record | Promise>; + /** * @public * Component responsible for listening to location changes and calling @@ -83,29 +102,46 @@ export const VisitListener = ({ children, toEntityRef, visitName, + enrichVisit, }: { children?: ReactNode; toEntityRef?: ({ pathname }: { pathname: string }) => string | undefined; visitName?: ({ pathname }: { pathname: string }) => string; + enrichVisit?: VisitEnrichmentFunction; }): JSX.Element => { const visitsApi = useApi(visitsApiRef); const { pathname } = useLocation(); const toEntityRefImpl = toEntityRef ?? getToEntityRef(); const visitNameImpl = visitName ?? getVisitName(); + useEffect(() => { // Wait for the browser to finish with paint with the assumption react // has finished with dom reconciliation. - const requestId = requestAnimationFrame(() => { + const requestId = requestAnimationFrame(async () => { + const baseVisit = { + name: visitNameImpl({ pathname }), + pathname, + entityRef: toEntityRefImpl({ pathname }), + }; + + let visitToSave = baseVisit; + + if (enrichVisit) { + try { + const enrichedData = await enrichVisit(baseVisit); + visitToSave = { ...baseVisit, ...enrichedData }; + } catch (error) { + // If enrichment fails, save the base visit without enrichment + visitToSave = baseVisit; + } + } + visitsApi.save({ - visit: { - name: visitNameImpl({ pathname }), - pathname, - entityRef: toEntityRefImpl({ pathname }), - }, + visit: visitToSave, }); }); return () => cancelAnimationFrame(requestId); - }, [visitsApi, pathname, toEntityRefImpl, visitNameImpl]); + }, [visitsApi, pathname, toEntityRefImpl, visitNameImpl, enrichVisit]); return <>{children}; }; diff --git a/plugins/home/src/components/index.ts b/plugins/home/src/components/index.ts index a6a4148e36..90a921babc 100644 --- a/plugins/home/src/components/index.ts +++ b/plugins/home/src/components/index.ts @@ -17,3 +17,4 @@ export { HomepageCompositionRoot } from './HomepageCompositionRoot'; export * from './CustomHomepage'; export * from './VisitListener'; +export * from './VisitList'; diff --git a/plugins/home/src/homePageComponents/VisitedByType/Context.tsx b/plugins/home/src/homePageComponents/VisitedByType/Context.tsx index 572f897aee..1f3c8cc0f3 100644 --- a/plugins/home/src/homePageComponents/VisitedByType/Context.tsx +++ b/plugins/home/src/homePageComponents/VisitedByType/Context.tsx @@ -25,23 +25,23 @@ import { import { Visit } from '../../api/VisitsApi'; import { VisitedByTypeKind } from './Content'; -export type ContextValueOnly = { +export type ContextValueOnly = { collapsed: boolean; numVisitsOpen: number; numVisitsTotal: number; - visits: Array; + visits: Array; loading: boolean; kind: VisitedByTypeKind; }; -export type ContextValue = ContextValueOnly & { +export type ContextValue = ContextValueOnly & { setCollapsed: Dispatch>; setNumVisitsOpen: Dispatch>; setNumVisitsTotal: Dispatch>; - setVisits: Dispatch>>; + setVisits: Dispatch>>; setLoading: Dispatch>; setKind: Dispatch>; - setContext: Dispatch>; + setContext: Dispatch>>; }; const defaultContextValueOnly: ContextValueOnly = { @@ -79,9 +79,9 @@ const getFilteredSet = })); export const ContextProvider = ({ children }: { children: JSX.Element }) => { - const [context, setContext] = useState( - defaultContextValueOnly, - ); + const [context, setContext] = useState({ + ...defaultContextValueOnly, + }); const { setCollapsed, setNumVisitsOpen, diff --git a/plugins/home/src/homePageComponents/VisitedByType/VisitedByType.tsx b/plugins/home/src/homePageComponents/VisitedByType/VisitedByType.tsx index 5c76af57d3..7f0d7e8c94 100644 --- a/plugins/home/src/homePageComponents/VisitedByType/VisitedByType.tsx +++ b/plugins/home/src/homePageComponents/VisitedByType/VisitedByType.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import { VisitList } from '../../components/VisitList'; +import { VisitList } from '../../components/VisitList/VisitList'; import { useContext } from './Context'; export const VisitedByType = () => { From c2e0020b978eaf43a3ee6d6478e365c48740dd6f Mon Sep 17 00:00:00 2001 From: Rajib Quayum Date: Mon, 11 Aug 2025 14:00:28 -0400 Subject: [PATCH 02/69] feat: adds transform path and can save to VisitListener, fixes and adds tests Signed-off-by: Rajib Quayum chore: update API reports --- plugins/home/report.api.md | 18 + .../src/components/VisitListener.test.tsx | 322 +++++++++++------- plugins/home/src/components/VisitListener.tsx | 75 +++- 3 files changed, 281 insertions(+), 134 deletions(-) diff --git a/plugins/home/report.api.md b/plugins/home/report.api.md index 2f70f1a9c9..3e6ff5de04 100644 --- a/plugins/home/report.api.md +++ b/plugins/home/report.api.md @@ -264,6 +264,13 @@ export type Visit = { entityRef?: string; }; +// @public +export type VisitCanSaveFunction = ({ + pathname, +}: { + pathname: string; +}) => boolean; + // @public export interface VisitDisplayContextValue { // (undocumented) @@ -319,11 +326,15 @@ export const VisitListener: ({ toEntityRef, visitName, enrichVisit, + transformPathname, + canSave, }: { children?: ReactNode; toEntityRef?: ({ pathname }: { pathname: string }) => string | undefined; visitName?: ({ pathname }: { pathname: string }) => string; enrichVisit?: VisitEnrichmentFunction; + transformPathname?: VisitTransformPathnameFunction; + canSave?: VisitCanSaveFunction; }) => JSX.Element; // @public @@ -382,6 +393,13 @@ export type VisitsWebStorageApiOptions = { errorApi: ErrorApi; }; +// @public +export type VisitTransformPathnameFunction = ({ + pathname, +}: { + pathname: string; +}) => string; + // @public export const WelcomeTitle: ({ language, diff --git a/plugins/home/src/components/VisitListener.test.tsx b/plugins/home/src/components/VisitListener.test.tsx index c9de01b45f..1661dc202c 100644 --- a/plugins/home/src/components/VisitListener.test.tsx +++ b/plugins/home/src/components/VisitListener.test.tsx @@ -49,7 +49,33 @@ const mockVisitsApi = { }; describe('', () => { - afterEach(jest.resetAllMocks); + beforeEach(() => { + jest + .spyOn(window, 'requestAnimationFrame') + .mockImplementation((cb: FrameRequestCallback): number => { + cb(0); + return 0; + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + jest.resetAllMocks(); + }); + + it('uses requestAnimationFrame to defer visit saving', async () => { + const pathname = '/catalog/default/component/test-component'; + + await renderInTestApp( + + + , + { routeEntries: [pathname] }, + ); + + expect(window.requestAnimationFrame).toHaveBeenCalledTimes(1); + await waitFor(() => expect(mockVisitsApi.save).toHaveBeenCalledTimes(1)); + }); it('registers a visit', async () => { const pathname = '/catalog/default/component/playback-order'; @@ -131,144 +157,182 @@ describe('', () => { ); }); - describe('requestId tests', () => { - beforeEach(() => { - // Mock requestAnimationFrame to execute immediately - global.requestAnimationFrame = jest.fn(callback => { - callback(0); - return 1; - }); - global.cancelAnimationFrame = jest.fn(); - }); + it('saves base visit when no enrichment function is provided', async () => { + const pathname = '/catalog/default/component/test-component'; - afterEach(() => { - jest.restoreAllMocks(); - }); + await renderInTestApp( + + + , + { routeEntries: [pathname] }, + ); - it('uses requestAnimationFrame to defer visit saving', async () => { - const pathname = '/catalog/default/component/test-component'; - - await renderInTestApp( - - - , - { routeEntries: [pathname] }, - ); - - expect(global.requestAnimationFrame).toHaveBeenCalledTimes(1); - await waitFor(() => expect(mockVisitsApi.save).toHaveBeenCalledTimes(1)); - }); - - it('saves base visit when no enrichment function is provided', async () => { - const pathname = '/catalog/default/component/test-component'; - - await renderInTestApp( - - - , - { routeEntries: [pathname] }, - ); - - await waitFor(() => - expect(mockVisitsApi.save).toHaveBeenCalledWith({ - visit: { - pathname, - entityRef: 'component:default/test-component', - name: 'test-component', - }, - }), - ); - }); - - it('enriches visit with additional data when enrichVisit function is provided', async () => { - const pathname = '/catalog/default/component/test-component'; - const enrichVisit: VisitEnrichmentFunction = jest.fn(async _visit => ({ - customProperty: 'custom-value', - category: 'test-category', - priority: 1, - })); - - await renderInTestApp( - - - , - { routeEntries: [pathname] }, - ); - - await waitFor(() => { - expect(enrichVisit).toHaveBeenCalledWith({ + await waitFor(() => + expect(mockVisitsApi.save).toHaveBeenCalledWith({ + visit: { pathname, entityRef: 'component:default/test-component', name: 'test-component', - }); - expect(mockVisitsApi.save).toHaveBeenCalledWith({ - visit: { - pathname, - entityRef: 'component:default/test-component', - name: 'test-component', - customProperty: 'custom-value', - category: 'test-category', - priority: 1, - }, - }); + }, + }), + ); + }); + + it('enriches visit with additional data when enrichVisit function is provided', async () => { + const pathname = '/catalog/default/component/test-component'; + const enrichVisit: VisitEnrichmentFunction = jest.fn(async _visit => ({ + customProperty: 'custom-value', + category: 'test-category', + priority: 1, + })); + + await renderInTestApp( + + + , + { routeEntries: [pathname] }, + ); + + await waitFor(() => { + expect(enrichVisit).toHaveBeenCalledWith({ + pathname, + entityRef: 'component:default/test-component', + name: 'test-component', }); - }); - - it('handles synchronous enrichment function', async () => { - const pathname = '/catalog/default/component/test-component'; - const enrichVisit: VisitEnrichmentFunction = jest.fn(_visit => ({ - syncProperty: 'sync-value', - })); - - await renderInTestApp( - - - , - { routeEntries: [pathname] }, - ); - - await waitFor(() => { - expect(enrichVisit).toHaveBeenCalledWith({ + expect(mockVisitsApi.save).toHaveBeenCalledWith({ + visit: { pathname, entityRef: 'component:default/test-component', name: 'test-component', - }); - expect(mockVisitsApi.save).toHaveBeenCalledWith({ - visit: { - pathname, - entityRef: 'component:default/test-component', - name: 'test-component', - syncProperty: 'sync-value', - }, - }); - }); - }); - - it('enrichment function can override base visit properties', async () => { - const pathname = '/catalog/default/component/test-component'; - const enrichVisit: VisitEnrichmentFunction = jest.fn(async _visit => ({ - name: 'Overridden Name', - entityRef: 'overridden:ref/value', - customField: 'additional-data', - })); - - await renderInTestApp( - - - , - { routeEntries: [pathname] }, - ); - - await waitFor(() => { - expect(mockVisitsApi.save).toHaveBeenCalledWith({ - visit: { - pathname, - name: 'Overridden Name', - entityRef: 'overridden:ref/value', - customField: 'additional-data', - }, - }); + customProperty: 'custom-value', + category: 'test-category', + priority: 1, + }, }); }); }); + + it('handles synchronous enrichment function', async () => { + const pathname = '/catalog/default/component/test-component'; + const enrichVisit: VisitEnrichmentFunction = jest.fn(_visit => ({ + syncProperty: 'sync-value', + })); + + await renderInTestApp( + + + , + { routeEntries: [pathname] }, + ); + + await waitFor(() => { + expect(enrichVisit).toHaveBeenCalledWith({ + pathname, + entityRef: 'component:default/test-component', + name: 'test-component', + }); + expect(mockVisitsApi.save).toHaveBeenCalledWith({ + visit: { + pathname, + entityRef: 'component:default/test-component', + name: 'test-component', + syncProperty: 'sync-value', + }, + }); + }); + }); + + it('enrichment function can override base visit properties', async () => { + const pathname = '/catalog/default/component/test-component'; + const enrichVisit: VisitEnrichmentFunction = jest.fn(async _visit => ({ + name: 'Overridden Name', + entityRef: 'overridden:ref/value', + customField: 'additional-data', + })); + + await renderInTestApp( + + + , + { routeEntries: [pathname] }, + ); + + await waitFor(() => { + expect(mockVisitsApi.save).toHaveBeenCalledWith({ + visit: { + pathname, + name: 'Overridden Name', + entityRef: 'overridden:ref/value', + customField: 'additional-data', + }, + }); + }); + }); + + it('is able to override transformPathname and change the pathname', async () => { + const pathname = '/catalog/default/component/playback-order-2/sub-path'; + + const transformPathnameOverride = ({ + pathname: mypathname, + }: { + pathname: string; + }) => mypathname.replace('/sub-path', ''); + + await renderInTestApp( + + + , + { routeEntries: [pathname] }, + ); + + await waitFor(() => + expect(mockVisitsApi.save).toHaveBeenCalledWith({ + visit: { + pathname: '/catalog/default/component/playback-order-2', + entityRef: 'component:default/playback-order-2', + name: 'playback-order-2', + }, + }), + ); + }); + + it('is able to override canSave and save under set conditions', async () => { + const pathname = '/catalog'; + + const canSaveOverride = ({ pathname: path }: { pathname: string }) => + path === '/catalog'; + + await renderInTestApp( + + + , + { routeEntries: [pathname] }, + ); + + await waitFor(() => + expect(mockVisitsApi.save).toHaveBeenCalledWith({ + visit: { + pathname, + entityRef: undefined, + name: 'catalog', + }, + }), + ); + }); + + it('is able to override canSave and not save under set conditions', async () => { + const pathname = '/catalog'; + + const canSaveOverride = ({ pathname: path }: { pathname: string }) => + path !== '/catalog'; + + await renderInTestApp( + + + , + { routeEntries: [pathname] }, + ); + + await waitFor(() => expect(mockVisitsApi.save).not.toHaveBeenCalled()); + }); }); diff --git a/plugins/home/src/components/VisitListener.tsx b/plugins/home/src/components/VisitListener.tsx index e7a8cf4a22..534e6805b2 100644 --- a/plugins/home/src/components/VisitListener.tsx +++ b/plugins/home/src/components/VisitListener.tsx @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { ReactNode, useEffect } from 'react'; +import { ReactNode, useEffect, useRef } from 'react'; import { useLocation } from 'react-router-dom'; @@ -93,6 +93,48 @@ export type VisitEnrichmentFunction = ( visit: VisitInput, ) => Record | Promise>; +/** + * @public + * Type definition for the transform pathname function + * This allows transforming the pathname before it is considered for any other processing + */ +export type VisitTransformPathnameFunction = ({ + pathname, +}: { + pathname: string; +}) => string; + +/** + * @internal + * Default implementation of visit pathname transform function + */ +const getTransformPathname = + (): VisitTransformPathnameFunction => + ({ pathname }: { pathname: string }): string => { + return pathname; + }; + +/** + * @public + * Type definition for the can save function + * This allows checking whether a visit can be saved + */ +export type VisitCanSaveFunction = ({ + pathname, +}: { + pathname: string; +}) => boolean; + +/** + * @internal + * Default implementation of visit can save function + */ +const getCanSave = + (): VisitCanSaveFunction => + (_: { pathname: string }): boolean => { + return true; + }; + /** * @public * Component responsible for listening to location changes and calling @@ -103,25 +145,40 @@ export const VisitListener = ({ toEntityRef, visitName, enrichVisit, + transformPathname, + canSave, }: { children?: ReactNode; toEntityRef?: ({ pathname }: { pathname: string }) => string | undefined; visitName?: ({ pathname }: { pathname: string }) => string; enrichVisit?: VisitEnrichmentFunction; + transformPathname?: VisitTransformPathnameFunction; + canSave?: VisitCanSaveFunction; }): JSX.Element => { + const previousVisitPathname = useRef(''); const visitsApi = useApi(visitsApiRef); const { pathname } = useLocation(); const toEntityRefImpl = toEntityRef ?? getToEntityRef(); const visitNameImpl = visitName ?? getVisitName(); + const transformPathnameImpl = transformPathname ?? getTransformPathname(); + const canSaveImpl = canSave ?? getCanSave(); useEffect(() => { + const visitPathname = transformPathnameImpl({ pathname }); + if (previousVisitPathname.current === visitPathname) { + return () => {}; + } + previousVisitPathname.current = visitPathname; + if (!canSaveImpl({ pathname: visitPathname })) { + return () => {}; + } // Wait for the browser to finish with paint with the assumption react // has finished with dom reconciliation. const requestId = requestAnimationFrame(async () => { const baseVisit = { - name: visitNameImpl({ pathname }), - pathname, - entityRef: toEntityRefImpl({ pathname }), + name: visitNameImpl({ pathname: visitPathname }), + pathname: visitPathname, + entityRef: toEntityRefImpl({ pathname: visitPathname }), }; let visitToSave = baseVisit; @@ -141,7 +198,15 @@ export const VisitListener = ({ }); }); return () => cancelAnimationFrame(requestId); - }, [visitsApi, pathname, toEntityRefImpl, visitNameImpl, enrichVisit]); + }, [ + visitsApi, + pathname, + toEntityRefImpl, + visitNameImpl, + enrichVisit, + transformPathnameImpl, + canSaveImpl, + ]); return <>{children}; }; From 2ac5d29bf8c83f1932853085c5634cdb314be783 Mon Sep 17 00:00:00 2001 From: Stephanie Swaney Date: Thu, 14 Aug 2025 09:22:13 -0500 Subject: [PATCH 03/69] chore: add changeset Signed-off-by: Stephanie Swaney --- .changeset/eighty-mails-leave.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/eighty-mails-leave.md diff --git a/.changeset/eighty-mails-leave.md b/.changeset/eighty-mails-leave.md new file mode 100644 index 0000000000..4924b73056 --- /dev/null +++ b/.changeset/eighty-mails-leave.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-home': patch +--- + +Allow customization of VisitList with optional enrichVisit, transformPathname, canSave functions along with VisitDisplayProvider for colors, labels From a4fc27f92518351d63421751fd9981a227e0b871 Mon Sep 17 00:00:00 2001 From: Stephanie Swaney Date: Thu, 14 Aug 2025 13:56:23 -0500 Subject: [PATCH 04/69] docs: add info to home plugin readme on how to customize VisitList Signed-off-by: Stephanie Swaney --- plugins/home/README.md | 91 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/plugins/home/README.md b/plugins/home/README.md index 3498d6700f..c62a09c0b4 100644 --- a/plugins/home/README.md +++ b/plugins/home/README.md @@ -356,6 +356,97 @@ home: In order to validate the config you can use `backstage/cli config:check` +### Customizing the VisitList + +If you want more control over the recent and top visited lists, you can write your own functions to transform the path names and determine which visits to save. Pass them to the `VisitListener` with `transformPathname` and `canSave`. + +```tsx + +``` + +You can also add the `enrichVisit` function to put additional values on each `Visit`. The values could later be used to customize the chips in the `VisitList`. For example, you could add the entity `type` on the `Visit` so that `type` is used for labels instead of `kind`. + +```tsx +import { VisitListener, VisitInput } from '@backstage/plugin-home'; +import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog-react'; + +type EnrichedVisit = VisitInput & { + type?: string; +}; + +const createEnrichVisit = + (catalogApi: CatalogApi) => + async (visit: VisitInput): Promise => { + if (!visit.entityRef) { + return visit; + } + try { + const entity = await catalogApi.getEntityByRef(visit.entityRef); + const type = entity?.spec?.type?.toString(); + return { ...visit, type }; + } catch (error) { + return visit; + } + }; +// This example requires its own component in order to use hook to look up entity in catalog +const AppVisitListener = ({ children }: { children: React.ReactNode }) => { + const catalogApi = useApi(catalogApiRef); + const enrichVisit = createEnrichVisit(catalogApi); + + return ( + <> + + {children} + + ); +}; +``` + +To provide your own chip colors and/or labels for the recent and top visited lists, wrap the components in `VisitDisplayProvider` with `getChipColor` and `getChipLabel` functions. The colors provided will be used instead of the hard coded [colorVariants](https://github.com/backstage/backstage/blob/2da352043425bcab4c4422e4d2820c26c0a83382/packages/theme/src/base/pageTheme.ts#L46) provided via `@backstage/theme`. + +```tsx +import { + CustomHomepageGrid, + HomePageTopVisited, + HomePageRecentlyVisited, + VisitDisplayProvider, +} from '@backstage/plugin-home'; + +const getChipColor = (visit: any) => { + const type = visit.type; + switch (type) { + case 'application': + return '#b39ddb'; + case 'service': + return '#90caf9'; + case 'account': + return '#a5d6a7'; + case 'suite': + return '#fff59d'; + default: + return '#ef9a9a'; + } +}; + +const getChipLabel = (visit?: any) => { + return visit?.type ? visit.type : 'Other'; +}; + +export default function HomePage() { + return ( + + + + + + + ); +} +``` + ## Contributing ### Homepage Components From 446415d052957dce880c967c326ba19591f6fb0d Mon Sep 17 00:00:00 2001 From: Madhav Peri Date: Thu, 14 Aug 2025 15:13:38 -0500 Subject: [PATCH 05/69] chore: Add transformPathname and canSave functions documentation Signed-off-by: Madhav Peri --- plugins/home/README.md | 41 ++++++++++++++++++++++++++++++++++++++--- 1 file changed, 38 insertions(+), 3 deletions(-) diff --git a/plugins/home/README.md b/plugins/home/README.md index c62a09c0b4..0747d439e0 100644 --- a/plugins/home/README.md +++ b/plugins/home/README.md @@ -132,7 +132,7 @@ export const RandomJokeHomePageComponent = homePlugin.provide( ); ``` -These settings can also be defined for components that use `createReactExtension` instead `createCardExtension` by using +These settings can also be defined for components that use `createReactExtension` instead of `createCardExtension` by using the data property: ```tsx @@ -367,6 +367,41 @@ If you want more control over the recent and top visited lists, you can write yo /> ``` +#### Transform Pathname Function + +You can provide a `transformPathname` function to transform the pathname before it's processed for visit tracking. This is useful for normalizing URLs or removing query parameters: + +```tsx +import { + VisitListener, + VisitTransformPathnameFunction, +} from '@backstage/plugin-home'; + +const transformPathname: VisitTransformPathnameFunction = ({ pathname }) => { + // Remove query parameters and hash fragments + return pathname.split('?')[0].split('#')[0]; +}; + +; +``` + +#### Can Save Function + +You can provide a `canSave` function to determine which visits should be tracked and saved. This allows you to filter out certain pages or paths: + +```tsx +import { VisitListener, VisitCanSaveFunction } from '@backstage/plugin-home'; + +const canSave: VisitCanSaveFunction = ({ pathname }) => { + // Don't save visits to admin or settings pages + return !pathname.startsWith('/admin') && !pathname.startsWith('/settings'); +}; + +; +``` + +#### Visit Enrichment + You can also add the `enrichVisit` function to put additional values on each `Visit`. The values could later be used to customize the chips in the `VisitList`. For example, you could add the entity `type` on the `Visit` so that `type` is used for labels instead of `kind`. ```tsx @@ -451,9 +486,9 @@ export default function HomePage() { ### Homepage Components -We believe that people have great ideas for what makes a useful Home Page, and we want to make it easy for every to benefit from the effort you put in to create something cool for the Home Page. Therefore, a great way of contributing is by simply creating more Home Page Components, than can then be used by everyone when composing their own Home Page. If they are tightly coupled to an existing plugin, it is recommended to allow them to live within that plugin, for convenience and to limit complex dependencies. On the other hand, if there's no clear plugin that the component is based on, it's also fine to contribute them into the [home plugin](/plugins/home/src/homePageComponents) +We believe that people have great ideas for what makes a useful Home Page, and we want to make it easy for everyone to benefit from the effort you put in to create something cool for the Home Page. Therefore, a great way of contributing is by simply creating more Home Page Components that can then be used by everyone when composing their own Home Page. If they are tightly coupled to an existing plugin, it is recommended to allow them to live within that plugin, for convenience and to limit complex dependencies. On the other hand, if there's no clear plugin that the component is based on, it's also fine to contribute them into the [home plugin](/plugins/home/src/homePageComponents) -Additionally, the API is at a very early state, so contributing with additional use cases may expose weaknesses in the current solution that we may iterate on, to provide more flexibility and ease of use for those who wish to develop components for the Home Page. +Additionally, the API is at a very early state, so contributing additional use cases may expose weaknesses in the current solution that we may iterate on to provide more flexibility and ease of use for those who wish to develop components for the Home Page. ### Homepage Templates From 2668aa6e2b5df68142d7a2c0863b247565ff6a37 Mon Sep 17 00:00:00 2001 From: Madhav Peri Date: Thu, 14 Aug 2025 16:05:35 -0500 Subject: [PATCH 06/69] docs: Edit titles and remove code block Signed-off-by: Madhav Peri --- plugins/home/README.md | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/plugins/home/README.md b/plugins/home/README.md index 0747d439e0..40d200380f 100644 --- a/plugins/home/README.md +++ b/plugins/home/README.md @@ -358,18 +358,11 @@ In order to validate the config you can use `backstage/cli config:check` ### Customizing the VisitList -If you want more control over the recent and top visited lists, you can write your own functions to transform the path names and determine which visits to save. Pass them to the `VisitListener` with `transformPathname` and `canSave`. - -```tsx - -``` +If you want more control over the recent and top visited lists, you can write your own functions to transform the pathnames and determine which visits to save. Pass them to the `VisitListener` with `transformPathname` and `canSave`. #### Transform Pathname Function -You can provide a `transformPathname` function to transform the pathname before it's processed for visit tracking. This is useful for normalizing URLs or removing query parameters: +Provide a `transformPathname` function to transform the pathname before it's processed for visit tracking. This is useful for normalizing URLs or removing query parameters: ```tsx import { @@ -387,7 +380,7 @@ const transformPathname: VisitTransformPathnameFunction = ({ pathname }) => { #### Can Save Function -You can provide a `canSave` function to determine which visits should be tracked and saved. This allows you to filter out certain pages or paths: +Provide a `canSave` function to determine which visits should be tracked and saved. This allows you to filter out certain pages or paths: ```tsx import { VisitListener, VisitCanSaveFunction } from '@backstage/plugin-home'; @@ -400,7 +393,7 @@ const canSave: VisitCanSaveFunction = ({ pathname }) => { ; ``` -#### Visit Enrichment +#### Enrich Visit Function You can also add the `enrichVisit` function to put additional values on each `Visit`. The values could later be used to customize the chips in the `VisitList`. For example, you could add the entity `type` on the `Visit` so that `type` is used for labels instead of `kind`. @@ -440,6 +433,8 @@ const AppVisitListener = ({ children }: { children: React.ReactNode }) => { }; ``` +#### Custom Chip Colors and Labels + To provide your own chip colors and/or labels for the recent and top visited lists, wrap the components in `VisitDisplayProvider` with `getChipColor` and `getChipLabel` functions. The colors provided will be used instead of the hard coded [colorVariants](https://github.com/backstage/backstage/blob/2da352043425bcab4c4422e4d2820c26c0a83382/packages/theme/src/base/pageTheme.ts#L46) provided via `@backstage/theme`. ```tsx From 5d5f3b71ccb6c708c087e4a79835d48c1c440454 Mon Sep 17 00:00:00 2001 From: Stephanie Swaney Date: Fri, 15 Aug 2025 11:48:02 -0500 Subject: [PATCH 07/69] docs: summarize whole section vs first two headings Signed-off-by: Stephanie Swaney docs: remove line, can read rest to get the functions Signed-off-by: Stephanie Swaney --- plugins/home/README.md | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/plugins/home/README.md b/plugins/home/README.md index 40d200380f..92ad82424f 100644 --- a/plugins/home/README.md +++ b/plugins/home/README.md @@ -358,11 +358,11 @@ In order to validate the config you can use `backstage/cli config:check` ### Customizing the VisitList -If you want more control over the recent and top visited lists, you can write your own functions to transform the pathnames and determine which visits to save. Pass them to the `VisitListener` with `transformPathname` and `canSave`. +If you want more control over the recent and top visited lists, you can write your own functions to transform the pathnames and determine which visits to save. You can also enrich each visit with other fields and customize the chip colors/labels in the visit lists. #### Transform Pathname Function -Provide a `transformPathname` function to transform the pathname before it's processed for visit tracking. This is useful for normalizing URLs or removing query parameters: +Provide a `transformPathname` function to transform the pathname before it's processed for visit tracking. This can be used for transforming the pathname for the visit (before any other consideration). As an example, you can treat multiple sub-path visits to be counted as a singular path, e.g. `/entity-path/sub1` , `/entity-path/sub-2`, `/entity-path/sub-2/sub-sub-2` can all be mapped to `/entity-path` so visits to any of those routes are all counted as the same. ```tsx import { @@ -371,8 +371,12 @@ import { } from '@backstage/plugin-home'; const transformPathname: VisitTransformPathnameFunction = ({ pathname }) => { - // Remove query parameters and hash fragments - return pathname.split('?')[0].split('#')[0]; + const pathnameParts = pathname.split('/').filter(part => part !== ''); + const rootPathFromPathname = pathnameParts[0] ?? ''; + if (rootPathFromPathname === 'catalog' && pathnameParts.length >= 4) { + return `/${pathnameParts.slice(0, 4).join('/')}`; + } + return pathname; }; ; @@ -380,7 +384,7 @@ const transformPathname: VisitTransformPathnameFunction = ({ pathname }) => { #### Can Save Function -Provide a `canSave` function to determine which visits should be tracked and saved. This allows you to filter out certain pages or paths: +Provide a `canSave` function to determine which visits should be tracked and saved. This allows you to conditionally save visits to the list: ```tsx import { VisitListener, VisitCanSaveFunction } from '@backstage/plugin-home'; From 786e37bb91e0d97afceef82e08578851df3ee9b0 Mon Sep 17 00:00:00 2001 From: Stephanie Swaney Date: Fri, 15 Aug 2025 17:15:10 -0500 Subject: [PATCH 08/69] chore: weird change required for alpha docs Signed-off-by: Stephanie Swaney --- plugins/home/report-alpha.api.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/home/report-alpha.api.md b/plugins/home/report-alpha.api.md index 83568c0e45..d2e5c22ef8 100644 --- a/plugins/home/report-alpha.api.md +++ b/plugins/home/report-alpha.api.md @@ -105,6 +105,7 @@ export default _default; export const homeTranslationRef: TranslationRef< 'home', { + readonly 'starredEntities.noStarredEntitiesMessage': 'Click the star beside an entity name to add it to this list!'; readonly 'addWidgetDialog.title': 'Add new widget to dashboard'; readonly 'customHomepageButtons.clearAll': 'Clear all'; readonly 'customHomepageButtons.edit': 'Edit'; @@ -123,7 +124,6 @@ export const homeTranslationRef: TranslationRef< readonly 'quickStart.title': 'Onboarding'; readonly 'quickStart.description': 'Get started with Backstage'; readonly 'quickStart.learnMoreLinkTitle': 'Learn more'; - readonly 'starredEntities.noStarredEntitiesMessage': 'Click the star beside an entity name to add it to this list!'; readonly 'visitedByType.action.viewMore': 'View more'; readonly 'visitedByType.action.viewLess': 'View less'; readonly 'featuredDocsCard.empty.title': 'No documents to show'; From 30f4b44c7384886eec9e7203e12b3a9de52b058d Mon Sep 17 00:00:00 2001 From: Stephanie Swaney Date: Wed, 17 Sep 2025 20:55:21 -0500 Subject: [PATCH 09/69] feat: move new functions from VisitListener to VisitsStorageApi Signed-off-by: Stephanie Swaney --- .changeset/eighty-mails-leave.md | 2 +- plugins/home/README.md | 93 ++++++-- plugins/home/report-alpha.api.md | 2 +- plugins/home/report.api.md | 38 ++-- plugins/home/src/api/VisitsApi.ts | 18 ++ plugins/home/src/api/VisitsStorageApi.test.ts | 198 +++++++++++++++++ plugins/home/src/api/VisitsStorageApi.ts | 97 +++++++- plugins/home/src/api/index.ts | 1 + .../src/components/VisitListener.test.tsx | 209 +----------------- plugins/home/src/components/VisitListener.tsx | 117 +--------- 10 files changed, 403 insertions(+), 372 deletions(-) diff --git a/.changeset/eighty-mails-leave.md b/.changeset/eighty-mails-leave.md index 4924b73056..3c6ec5ab8f 100644 --- a/.changeset/eighty-mails-leave.md +++ b/.changeset/eighty-mails-leave.md @@ -2,4 +2,4 @@ '@backstage/plugin-home': patch --- -Allow customization of VisitList with optional enrichVisit, transformPathname, canSave functions along with VisitDisplayProvider for colors, labels +Allow customization of VisitList by adding optional enrichVisit, transformPathname, canSave functions to VisitsStorageApi, along with VisitDisplayProvider for colors, labels diff --git a/plugins/home/README.md b/plugins/home/README.md index 92ad82424f..b08e785403 100644 --- a/plugins/home/README.md +++ b/plugins/home/README.md @@ -366,11 +366,14 @@ Provide a `transformPathname` function to transform the pathname before it's pro ```tsx import { - VisitListener, - VisitTransformPathnameFunction, -} from '@backstage/plugin-home'; + AnyApiFactory, + createApiFactory, + identityApiRef, + storageApiRef, +} from '@backstage/core-plugin-api'; +import { VisitsStorageApi } from '@backstage/plugin-home'; -const transformPathname: VisitTransformPathnameFunction = ({ pathname }) => { +const transformPathname = (pathname: string) => { const pathnameParts = pathname.split('/').filter(part => part !== ''); const rootPathFromPathname = pathnameParts[0] ?? ''; if (rootPathFromPathname === 'catalog' && pathnameParts.length >= 4) { @@ -379,7 +382,21 @@ const transformPathname: VisitTransformPathnameFunction = ({ pathname }) => { return pathname; }; -; +export const apis: AnyApiFactory[] = [ + createApiFactory({ + api: visitsApiRef, + deps: { + storageApi: storageApiRef, + identityApi: identityApiRef, + }, + factory: ({ storageApi, identityApi }) => + VisitsStorageApi.create({ + storageApi, + identityApi, + transformPathname, + }), + }), +]; ``` #### Can Save Function @@ -387,14 +404,37 @@ const transformPathname: VisitTransformPathnameFunction = ({ pathname }) => { Provide a `canSave` function to determine which visits should be tracked and saved. This allows you to conditionally save visits to the list: ```tsx -import { VisitListener, VisitCanSaveFunction } from '@backstage/plugin-home'; +import { + AnyApiFactory, + createApiFactory, + identityApiRef, + storageApiRef, +} from '@backstage/core-plugin-api'; +import { VisitInput, VisitsStorageApi } from '@backstage/plugin-home'; -const canSave: VisitCanSaveFunction = ({ pathname }) => { +const canSave = (visit: VisitInput) => { // Don't save visits to admin or settings pages - return !pathname.startsWith('/admin') && !pathname.startsWith('/settings'); + return ( + !visit.pathname.startsWith('/admin') && + !visit.pathname.startsWith('/settings') + ); }; -; +export const apis: AnyApiFactory[] = [ + createApiFactory({ + api: visitsApiRef, + deps: { + storageApi: storageApiRef, + identityApi: identityApiRef, + }, + factory: ({ storageApi, identityApi }) => + VisitsStorageApi.create({ + storageApi, + identityApi, + canSave, + }), + }), +]; ``` #### Enrich Visit Function @@ -402,8 +442,14 @@ const canSave: VisitCanSaveFunction = ({ pathname }) => { You can also add the `enrichVisit` function to put additional values on each `Visit`. The values could later be used to customize the chips in the `VisitList`. For example, you could add the entity `type` on the `Visit` so that `type` is used for labels instead of `kind`. ```tsx -import { VisitListener, VisitInput } from '@backstage/plugin-home'; +import { + AnyApiFactory, + createApiFactory, + identityApiRef, + storageApiRef, +} from '@backstage/core-plugin-api'; import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog-react'; +import { VisitsStorageApi } from '@backstage/plugin-home'; type EnrichedVisit = VisitInput & { type?: string; @@ -423,18 +469,23 @@ const createEnrichVisit = return visit; } }; -// This example requires its own component in order to use hook to look up entity in catalog -const AppVisitListener = ({ children }: { children: React.ReactNode }) => { - const catalogApi = useApi(catalogApiRef); - const enrichVisit = createEnrichVisit(catalogApi); - return ( - <> - - {children} - - ); -}; +export const apis: AnyApiFactory[] = [ + createApiFactory({ + api: visitsApiRef, + deps: { + storageApi: storageApiRef, + identityApi: identityApiRef, + catalogApi: catalogApiRef, + }, + factory: ({ storageApi, identityApi, catalogApi }) => + VisitsStorageApi.create({ + storageApi, + identityApi, + enrichVisit: createEnrichVisit(catalogApi), + }), + }), +]; ``` #### Custom Chip Colors and Labels diff --git a/plugins/home/report-alpha.api.md b/plugins/home/report-alpha.api.md index d2e5c22ef8..83568c0e45 100644 --- a/plugins/home/report-alpha.api.md +++ b/plugins/home/report-alpha.api.md @@ -105,7 +105,6 @@ export default _default; export const homeTranslationRef: TranslationRef< 'home', { - readonly 'starredEntities.noStarredEntitiesMessage': 'Click the star beside an entity name to add it to this list!'; readonly 'addWidgetDialog.title': 'Add new widget to dashboard'; readonly 'customHomepageButtons.clearAll': 'Clear all'; readonly 'customHomepageButtons.edit': 'Edit'; @@ -124,6 +123,7 @@ export const homeTranslationRef: TranslationRef< readonly 'quickStart.title': 'Onboarding'; readonly 'quickStart.description': 'Get started with Backstage'; readonly 'quickStart.learnMoreLinkTitle': 'Learn more'; + readonly 'starredEntities.noStarredEntitiesMessage': 'Click the star beside an entity name to add it to this list!'; readonly 'visitedByType.action.viewMore': 'View more'; readonly 'visitedByType.action.viewLess': 'View less'; readonly 'featuredDocsCard.empty.title': 'No documents to show'; diff --git a/plugins/home/report.api.md b/plugins/home/report.api.md index 3e6ff5de04..7036e9d6d2 100644 --- a/plugins/home/report.api.md +++ b/plugins/home/report.api.md @@ -264,13 +264,6 @@ export type Visit = { entityRef?: string; }; -// @public -export type VisitCanSaveFunction = ({ - pathname, -}: { - pathname: string; -}) => boolean; - // @public export interface VisitDisplayContextValue { // (undocumented) @@ -308,11 +301,6 @@ export type VisitedByTypeProps = { kind: VisitedByTypeKind; }; -// @public -export type VisitEnrichmentFunction = ( - visit: VisitInput, -) => Record | Promise>; - // @public export type VisitInput = { name: string; @@ -325,22 +313,21 @@ export const VisitListener: ({ children, toEntityRef, visitName, - enrichVisit, - transformPathname, - canSave, }: { children?: ReactNode; toEntityRef?: ({ pathname }: { pathname: string }) => string | undefined; visitName?: ({ pathname }: { pathname: string }) => string; - enrichVisit?: VisitEnrichmentFunction; - transformPathname?: VisitTransformPathnameFunction; - canSave?: VisitCanSaveFunction; }) => JSX.Element; // @public export interface VisitsApi { + canSave?(visit: VisitInput): boolean | Promise; + enrichVisit?( + visit: VisitInput, + ): Promise> | Record; list(queryParams?: VisitsApiQueryParams): Promise; save(saveParams: VisitsApiSaveParams): Promise; + transformPathname?(pathname: string): string; } // @public @@ -367,10 +354,13 @@ export type VisitsApiSaveParams = { // @public export class VisitsStorageApi implements VisitsApi { + canSave(visit: VisitInput): Promise; // (undocumented) static create(options: VisitsStorageApiOptions): VisitsStorageApi; + enrichVisit(visit: VisitInput): Promise>; list(queryParams?: VisitsApiQueryParams): Promise; save(saveParams: VisitsApiSaveParams): Promise; + transformPathname(pathname: string): string; } // @public (undocumented) @@ -378,6 +368,11 @@ export type VisitsStorageApiOptions = { limit?: number; storageApi: StorageApi; identityApi: IdentityApi; + transformPathname?: (pathname: string) => string; + canSave?: (visit: VisitInput) => boolean | Promise; + enrichVisit?: ( + visit: VisitInput, + ) => Promise> | Record; }; // @public @@ -393,13 +388,6 @@ export type VisitsWebStorageApiOptions = { errorApi: ErrorApi; }; -// @public -export type VisitTransformPathnameFunction = ({ - pathname, -}: { - pathname: string; -}) => string; - // @public export const WelcomeTitle: ({ language, diff --git a/plugins/home/src/api/VisitsApi.ts b/plugins/home/src/api/VisitsApi.ts index 610f4be483..5cbd65fab3 100644 --- a/plugins/home/src/api/VisitsApi.ts +++ b/plugins/home/src/api/VisitsApi.ts @@ -15,6 +15,7 @@ */ import { createApiRef } from '@backstage/core-plugin-api'; +import { VisitInput } from './VisitsStorageApi'; /** * @public @@ -126,6 +127,23 @@ export interface VisitsApi { * @param queryParams - optional search query params. */ list(queryParams?: VisitsApiQueryParams): Promise; + /** + * Transform the pathname before it is considered for any other processing. + * @param pathname - the original pathname + */ + transformPathname?(pathname: string): string; + /** + * Determine whether a visit should be saved. + * @param visit - page visit data + */ + canSave?(visit: VisitInput): boolean | Promise; + /** + * Add additional data to the visit before saving. + * @param visit - page visit data + */ + enrichVisit?( + visit: VisitInput, + ): Promise> | Record; } /** @public */ diff --git a/plugins/home/src/api/VisitsStorageApi.test.ts b/plugins/home/src/api/VisitsStorageApi.test.ts index 55cf623149..c6629a39a0 100644 --- a/plugins/home/src/api/VisitsStorageApi.test.ts +++ b/plugins/home/src/api/VisitsStorageApi.test.ts @@ -359,4 +359,202 @@ describe('VisitsStorageApi.create', () => { expect(visits.length).toEqual(8); }); }); + describe('.save() with transformPathname', () => { + it('transforms pathname before saving', async () => { + const api = VisitsStorageApi.create({ + storageApi: mockApis.storage(), + identityApi: mockIdentityApi, + transformPathname: (pathname: string) => + pathname.replace(/\/admin$/, ''), + }); + + const visit = { + pathname: '/catalog/default/component/test/admin', + entityRef: 'component:default/test', + name: 'Test Component', + }; + + const savedVisit = await api.save({ visit }); + expect(savedVisit.pathname).toBe('/catalog/default/component/test'); + }); + }); + + describe('.save() with canSave', () => { + it('skips saving when canSave returns false', async () => { + const api = VisitsStorageApi.create({ + storageApi: mockApis.storage(), + identityApi: mockIdentityApi, + canSave: visitInput => !visitInput.pathname.includes('/private'), + }); + + const privateVisit = { + pathname: '/private/admin', + entityRef: 'component:default/admin', + name: 'Admin Component', + }; + + const result = await api.save({ visit: privateVisit }); + expect(result.id).toBe(''); + expect(result.hits).toBe(0); + + const visits = await api.list(); + expect(visits).toHaveLength(0); + }); + + it('saves when canSave returns true', async () => { + const api = VisitsStorageApi.create({ + storageApi: mockApis.storage(), + identityApi: mockIdentityApi, + canSave: visitInput => !visitInput.pathname.includes('/private'), + }); + + const publicVisit = { + pathname: '/catalog/default/component/public', + entityRef: 'component:default/public', + name: 'Public Component', + }; + + const result = await api.save({ visit: publicVisit }); + expect(result.id).toBeTruthy(); + expect(result.hits).toBe(1); + + const visits = await api.list(); + expect(visits).toHaveLength(1); + }); + + it('handles async canSave function', async () => { + const api = VisitsStorageApi.create({ + storageApi: mockApis.storage(), + identityApi: mockIdentityApi, + canSave: async visitInput => + Promise.resolve(!visitInput.pathname.includes('/restricted')), + }); + + const restrictedVisit = { + pathname: '/restricted/area', + entityRef: 'component:default/restricted', + name: 'Restricted Component', + }; + + const result = await api.save({ visit: restrictedVisit }); + expect(result.id).toBe(''); + + const visits = await api.list(); + expect(visits).toHaveLength(0); + }); + }); + + describe('.save() with enrichVisit', () => { + it('enriches visit data before saving', async () => { + const api = VisitsStorageApi.create({ + storageApi: mockApis.storage(), + identityApi: mockIdentityApi, + enrichVisit: visitInput => ({ + category: visitInput.entityRef?.split(':')[0] || 'unknown', + source: 'test', + }), + }); + + const visit = { + pathname: '/catalog/default/component/test', + entityRef: 'component:default/test', + name: 'Test Component', + }; + + const savedVisit = await api.save({ visit }); + expect(savedVisit).toEqual( + expect.objectContaining({ + ...visit, + category: 'component', + source: 'test', + }), + ); + + const visits = await api.list(); + expect(visits[0]).toEqual( + expect.objectContaining({ + category: 'component', + source: 'test', + }), + ); + }); + + it('handles async enrichVisit function', async () => { + const api = VisitsStorageApi.create({ + storageApi: mockApis.storage(), + identityApi: mockIdentityApi, + enrichVisit: async visitInput => + Promise.resolve({ + enrichedAt: Date.now(), + type: visitInput.entityRef?.split(':')[0], + }), + }); + + const visit = { + pathname: '/catalog/default/api/test-api', + entityRef: 'api:default/test-api', + name: 'Test API', + }; + + const savedVisit = await api.save({ visit }); + expect(savedVisit).toEqual( + expect.objectContaining({ + type: 'api', + enrichedAt: expect.any(Number), + }), + ); + }); + }); + + describe('.save() with combined options', () => { + it('applies transformPathname, canSave, and enrichVisit in sequence', async () => { + const api = VisitsStorageApi.create({ + storageApi: mockApis.storage(), + identityApi: mockIdentityApi, + transformPathname: pathname => pathname.toLowerCase(), + canSave: visitInput => !visitInput.pathname.includes('forbidden'), + enrichVisit: visitInput => ({ + processed: true, + originalPath: visitInput.pathname, + }), + }); + + const visit = { + pathname: '/CATALOG/Default/Component/Test', + entityRef: 'component:default/test', + name: 'Test Component', + }; + + const savedVisit = await api.save({ visit }); + expect(savedVisit).toEqual( + expect.objectContaining({ + pathname: '/catalog/default/component/test', + processed: true, + originalPath: '/catalog/default/component/test', + }), + ); + }); + + it('prevents saving when canSave returns false after pathname transformation', async () => { + const api = VisitsStorageApi.create({ + storageApi: mockApis.storage(), + identityApi: mockIdentityApi, + transformPathname: pathname => + pathname.replace('/test/', '/forbidden/'), + canSave: visitInput => !visitInput.pathname.includes('forbidden'), + }); + + const visit = { + pathname: '/catalog/test/component/sample', + entityRef: 'component:default/sample', + name: 'Sample Component', + }; + + const result = await api.save({ visit }); + expect(result.id).toBe(''); + + const visits = await api.list(); + expect(visits).toHaveLength(0); + }); + }); }); diff --git a/plugins/home/src/api/VisitsStorageApi.ts b/plugins/home/src/api/VisitsStorageApi.ts index da3adc285e..3a55a6b16f 100644 --- a/plugins/home/src/api/VisitsStorageApi.ts +++ b/plugins/home/src/api/VisitsStorageApi.ts @@ -21,11 +21,26 @@ import { VisitsApiSaveParams, } from './VisitsApi'; +/** + * @public + * Type definition for visit data before it's saved (without auto-generated fields) + */ +export type VisitInput = { + name: string; + pathname: string; + entityRef?: string; +}; + /** @public */ export type VisitsStorageApiOptions = { limit?: number; storageApi: StorageApi; identityApi: IdentityApi; + transformPathname?: (pathname: string) => string; + canSave?: (visit: VisitInput) => boolean | Promise; + enrichVisit?: ( + visit: VisitInput, + ) => Promise> | Record; }; type ArrayElement = A extends readonly (infer T)[] ? T : never; @@ -43,6 +58,13 @@ export class VisitsStorageApi implements VisitsApi { private readonly storageApi: StorageApi; private readonly storageKeyPrefix = '@backstage/plugin-home:visits'; private readonly identityApi: IdentityApi; + private readonly transformPathnameImpl?: (pathname: string) => string; + private readonly canSaveImpl?: ( + visit: VisitInput, + ) => boolean | Promise; + private readonly enrichVisitImpl?: ( + visit: VisitInput, + ) => Promise> | Record; static create(options: VisitsStorageApiOptions) { return new VisitsStorageApi(options); @@ -52,6 +74,9 @@ export class VisitsStorageApi implements VisitsApi { this.limit = Math.abs(options.limit ?? 100); this.storageApi = options.storageApi; this.identityApi = options.identityApi; + this.transformPathnameImpl = options.transformPathname; + this.canSaveImpl = options.canSave; + this.enrichVisitImpl = options.enrichVisit; } /** @@ -88,34 +113,90 @@ export class VisitsStorageApi implements VisitsApi { return visits.slice(0, queryParams?.limit ?? DEFAULT_LIST_LIMIT); } + /** + * Transform the pathname before it is considered for any other processing. + * @param pathname - the original pathname + * @returns the transformed pathname + */ + transformPathname(pathname: string): string { + return this.transformPathnameImpl?.(pathname) ?? pathname; + } + + /** + * Determine whether a visit should be saved. + * @param visit - page visit data + */ + async canSave(visit: VisitInput): Promise { + if (!this.canSaveImpl) { + return true; + } + return Promise.resolve(this.canSaveImpl(visit)); + } + + /** + * Add additional data to the visit before saving. + * @param visit - page visit data + */ + async enrichVisit(visit: VisitInput): Promise> { + if (!this.enrichVisitImpl) { + return {}; + } + return Promise.resolve(this.enrichVisitImpl(visit)); + } + /** * Saves a visit through the visitsApi */ async save(saveParams: VisitsApiSaveParams): Promise { + let visit = saveParams.visit; + + // Transform pathname if needed + visit = { + ...visit, + pathname: this.transformPathname(visit.pathname), + }; + + // Check if visit should be saved + if (!(await this.canSave(visit))) { + // Return a minimal visit object without saving + return { + ...visit, + id: '', + hits: 0, + timestamp: Date.now(), + }; + } + + // Enrich the visit + const enrichedData = await this.enrichVisit(visit); + const enrichedVisit = { ...visit, ...enrichedData }; + const visits: Visit[] = [...(await this.retrieveAll())]; - const visit: Visit = { - ...saveParams.visit, + const visitToSave: Visit = { + ...enrichedVisit, id: window.crypto.randomUUID(), hits: 1, timestamp: Date.now(), }; // Updates entry if pathname is already registered - const visitIndex = visits.findIndex(e => e.pathname === visit.pathname); + const visitIndex = visits.findIndex( + e => e.pathname === visitToSave.pathname, + ); if (visitIndex >= 0) { - visit.id = visits[visitIndex].id; - visit.hits = visits[visitIndex].hits + 1; - visits[visitIndex] = visit; + visitToSave.id = visits[visitIndex].id; + visitToSave.hits = visits[visitIndex].hits + 1; + visits[visitIndex] = visitToSave; } else { - visits.push(visit); + visits.push(visitToSave); } // Sort by time, most recent first visits.sort((a, b) => b.timestamp - a.timestamp); // Keep the most recent items up to limit await this.persistAll(visits.splice(0, this.limit)); - return visit; + return visitToSave; } private async persistAll(visits: Array) { diff --git a/plugins/home/src/api/index.ts b/plugins/home/src/api/index.ts index 944fa65330..3ac9d8fce8 100644 --- a/plugins/home/src/api/index.ts +++ b/plugins/home/src/api/index.ts @@ -17,3 +17,4 @@ export * from './VisitsStorageApi'; export * from './VisitsWebStorageApi'; export * from './VisitsApi'; +export type { VisitInput } from './VisitsStorageApi'; diff --git a/plugins/home/src/components/VisitListener.test.tsx b/plugins/home/src/components/VisitListener.test.tsx index 1661dc202c..7f7db95a9e 100644 --- a/plugins/home/src/components/VisitListener.test.tsx +++ b/plugins/home/src/components/VisitListener.test.tsx @@ -15,7 +15,7 @@ */ import { TestApiProvider, renderInTestApp } from '@backstage/test-utils'; import { Visit, visitsApiRef } from '../api'; -import { VisitListener, VisitEnrichmentFunction } from './VisitListener'; +import { VisitListener } from './VisitListener'; import { waitFor } from '@testing-library/react'; const visits: Array = [ @@ -49,33 +49,7 @@ const mockVisitsApi = { }; describe('', () => { - beforeEach(() => { - jest - .spyOn(window, 'requestAnimationFrame') - .mockImplementation((cb: FrameRequestCallback): number => { - cb(0); - return 0; - }); - }); - - afterEach(() => { - jest.restoreAllMocks(); - jest.resetAllMocks(); - }); - - it('uses requestAnimationFrame to defer visit saving', async () => { - const pathname = '/catalog/default/component/test-component'; - - await renderInTestApp( - - - , - { routeEntries: [pathname] }, - ); - - expect(window.requestAnimationFrame).toHaveBeenCalledTimes(1); - await waitFor(() => expect(mockVisitsApi.save).toHaveBeenCalledTimes(1)); - }); + afterEach(jest.resetAllMocks); it('registers a visit', async () => { const pathname = '/catalog/default/component/playback-order'; @@ -156,183 +130,4 @@ describe('', () => { }), ); }); - - it('saves base visit when no enrichment function is provided', async () => { - const pathname = '/catalog/default/component/test-component'; - - await renderInTestApp( - - - , - { routeEntries: [pathname] }, - ); - - await waitFor(() => - expect(mockVisitsApi.save).toHaveBeenCalledWith({ - visit: { - pathname, - entityRef: 'component:default/test-component', - name: 'test-component', - }, - }), - ); - }); - - it('enriches visit with additional data when enrichVisit function is provided', async () => { - const pathname = '/catalog/default/component/test-component'; - const enrichVisit: VisitEnrichmentFunction = jest.fn(async _visit => ({ - customProperty: 'custom-value', - category: 'test-category', - priority: 1, - })); - - await renderInTestApp( - - - , - { routeEntries: [pathname] }, - ); - - await waitFor(() => { - expect(enrichVisit).toHaveBeenCalledWith({ - pathname, - entityRef: 'component:default/test-component', - name: 'test-component', - }); - expect(mockVisitsApi.save).toHaveBeenCalledWith({ - visit: { - pathname, - entityRef: 'component:default/test-component', - name: 'test-component', - customProperty: 'custom-value', - category: 'test-category', - priority: 1, - }, - }); - }); - }); - - it('handles synchronous enrichment function', async () => { - const pathname = '/catalog/default/component/test-component'; - const enrichVisit: VisitEnrichmentFunction = jest.fn(_visit => ({ - syncProperty: 'sync-value', - })); - - await renderInTestApp( - - - , - { routeEntries: [pathname] }, - ); - - await waitFor(() => { - expect(enrichVisit).toHaveBeenCalledWith({ - pathname, - entityRef: 'component:default/test-component', - name: 'test-component', - }); - expect(mockVisitsApi.save).toHaveBeenCalledWith({ - visit: { - pathname, - entityRef: 'component:default/test-component', - name: 'test-component', - syncProperty: 'sync-value', - }, - }); - }); - }); - - it('enrichment function can override base visit properties', async () => { - const pathname = '/catalog/default/component/test-component'; - const enrichVisit: VisitEnrichmentFunction = jest.fn(async _visit => ({ - name: 'Overridden Name', - entityRef: 'overridden:ref/value', - customField: 'additional-data', - })); - - await renderInTestApp( - - - , - { routeEntries: [pathname] }, - ); - - await waitFor(() => { - expect(mockVisitsApi.save).toHaveBeenCalledWith({ - visit: { - pathname, - name: 'Overridden Name', - entityRef: 'overridden:ref/value', - customField: 'additional-data', - }, - }); - }); - }); - - it('is able to override transformPathname and change the pathname', async () => { - const pathname = '/catalog/default/component/playback-order-2/sub-path'; - - const transformPathnameOverride = ({ - pathname: mypathname, - }: { - pathname: string; - }) => mypathname.replace('/sub-path', ''); - - await renderInTestApp( - - - , - { routeEntries: [pathname] }, - ); - - await waitFor(() => - expect(mockVisitsApi.save).toHaveBeenCalledWith({ - visit: { - pathname: '/catalog/default/component/playback-order-2', - entityRef: 'component:default/playback-order-2', - name: 'playback-order-2', - }, - }), - ); - }); - - it('is able to override canSave and save under set conditions', async () => { - const pathname = '/catalog'; - - const canSaveOverride = ({ pathname: path }: { pathname: string }) => - path === '/catalog'; - - await renderInTestApp( - - - , - { routeEntries: [pathname] }, - ); - - await waitFor(() => - expect(mockVisitsApi.save).toHaveBeenCalledWith({ - visit: { - pathname, - entityRef: undefined, - name: 'catalog', - }, - }), - ); - }); - - it('is able to override canSave and not save under set conditions', async () => { - const pathname = '/catalog'; - - const canSaveOverride = ({ pathname: path }: { pathname: string }) => - path !== '/catalog'; - - await renderInTestApp( - - - , - { routeEntries: [pathname] }, - ); - - await waitFor(() => expect(mockVisitsApi.save).not.toHaveBeenCalled()); - }); }); diff --git a/plugins/home/src/components/VisitListener.tsx b/plugins/home/src/components/VisitListener.tsx index 534e6805b2..ee4f4558c1 100644 --- a/plugins/home/src/components/VisitListener.tsx +++ b/plugins/home/src/components/VisitListener.tsx @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { ReactNode, useEffect, useRef } from 'react'; +import { ReactNode, useEffect } from 'react'; import { useLocation } from 'react-router-dom'; @@ -74,67 +74,6 @@ const getVisitName = return document.title; }; -/** - * @public - * Type definition for visit data before it's saved (without auto-generated fields) - */ -export type VisitInput = { - name: string; - pathname: string; - entityRef?: string; -}; - -/** - * @public - * Type definition for the visit enrichment function - * This allows adding custom properties to visits at save time - */ -export type VisitEnrichmentFunction = ( - visit: VisitInput, -) => Record | Promise>; - -/** - * @public - * Type definition for the transform pathname function - * This allows transforming the pathname before it is considered for any other processing - */ -export type VisitTransformPathnameFunction = ({ - pathname, -}: { - pathname: string; -}) => string; - -/** - * @internal - * Default implementation of visit pathname transform function - */ -const getTransformPathname = - (): VisitTransformPathnameFunction => - ({ pathname }: { pathname: string }): string => { - return pathname; - }; - -/** - * @public - * Type definition for the can save function - * This allows checking whether a visit can be saved - */ -export type VisitCanSaveFunction = ({ - pathname, -}: { - pathname: string; -}) => boolean; - -/** - * @internal - * Default implementation of visit can save function - */ -const getCanSave = - (): VisitCanSaveFunction => - (_: { pathname: string }): boolean => { - return true; - }; - /** * @public * Component responsible for listening to location changes and calling @@ -144,69 +83,29 @@ export const VisitListener = ({ children, toEntityRef, visitName, - enrichVisit, - transformPathname, - canSave, }: { children?: ReactNode; toEntityRef?: ({ pathname }: { pathname: string }) => string | undefined; visitName?: ({ pathname }: { pathname: string }) => string; - enrichVisit?: VisitEnrichmentFunction; - transformPathname?: VisitTransformPathnameFunction; - canSave?: VisitCanSaveFunction; }): JSX.Element => { - const previousVisitPathname = useRef(''); const visitsApi = useApi(visitsApiRef); const { pathname } = useLocation(); const toEntityRefImpl = toEntityRef ?? getToEntityRef(); const visitNameImpl = visitName ?? getVisitName(); - const transformPathnameImpl = transformPathname ?? getTransformPathname(); - const canSaveImpl = canSave ?? getCanSave(); - useEffect(() => { - const visitPathname = transformPathnameImpl({ pathname }); - if (previousVisitPathname.current === visitPathname) { - return () => {}; - } - previousVisitPathname.current = visitPathname; - if (!canSaveImpl({ pathname: visitPathname })) { - return () => {}; - } // Wait for the browser to finish with paint with the assumption react // has finished with dom reconciliation. - const requestId = requestAnimationFrame(async () => { - const baseVisit = { - name: visitNameImpl({ pathname: visitPathname }), - pathname: visitPathname, - entityRef: toEntityRefImpl({ pathname: visitPathname }), - }; - - let visitToSave = baseVisit; - - if (enrichVisit) { - try { - const enrichedData = await enrichVisit(baseVisit); - visitToSave = { ...baseVisit, ...enrichedData }; - } catch (error) { - // If enrichment fails, save the base visit without enrichment - visitToSave = baseVisit; - } - } - + const requestId = requestAnimationFrame(() => { visitsApi.save({ - visit: visitToSave, + visit: { + name: visitNameImpl({ pathname }), + pathname, + entityRef: toEntityRefImpl({ pathname }), + }, }); }); return () => cancelAnimationFrame(requestId); - }, [ - visitsApi, - pathname, - toEntityRefImpl, - visitNameImpl, - enrichVisit, - transformPathnameImpl, - canSaveImpl, - ]); + }, [visitsApi, pathname, toEntityRefImpl, visitNameImpl]); return <>{children}; }; From 52452db1e3dfbb69d9a9c21a17640f2967fa04b5 Mon Sep 17 00:00:00 2001 From: Stephanie Swaney Date: Thu, 18 Sep 2025 09:59:49 -0500 Subject: [PATCH 10/69] chore: run all api reports, not single plugin Signed-off-by: Stephanie Swaney --- plugins/home/report-alpha.api.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/home/report-alpha.api.md b/plugins/home/report-alpha.api.md index 83568c0e45..d2e5c22ef8 100644 --- a/plugins/home/report-alpha.api.md +++ b/plugins/home/report-alpha.api.md @@ -105,6 +105,7 @@ export default _default; export const homeTranslationRef: TranslationRef< 'home', { + readonly 'starredEntities.noStarredEntitiesMessage': 'Click the star beside an entity name to add it to this list!'; readonly 'addWidgetDialog.title': 'Add new widget to dashboard'; readonly 'customHomepageButtons.clearAll': 'Clear all'; readonly 'customHomepageButtons.edit': 'Edit'; @@ -123,7 +124,6 @@ export const homeTranslationRef: TranslationRef< readonly 'quickStart.title': 'Onboarding'; readonly 'quickStart.description': 'Get started with Backstage'; readonly 'quickStart.learnMoreLinkTitle': 'Learn more'; - readonly 'starredEntities.noStarredEntitiesMessage': 'Click the star beside an entity name to add it to this list!'; readonly 'visitedByType.action.viewMore': 'View more'; readonly 'visitedByType.action.viewLess': 'View less'; readonly 'featuredDocsCard.empty.title': 'No documents to show'; From 87e597c406e7ae2b13828e07f33dd59f95f66780 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Edeg=C3=A5rd?= Date: Wed, 1 Oct 2025 09:10:45 +0000 Subject: [PATCH 11/69] Allows for a opt-in strategy for notifications rather than opt-out. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Henrik Edegård --- .changeset/fancy-years-camp.md | 9 + docs/notifications/index.md | 55 +++ plugins/notifications-backend/config.d.ts | 7 + .../src/service/router.test.ts | 316 +++++++++++++++++- .../src/service/router.ts | 75 ++++- plugins/notifications-common/report.api.md | 1 + plugins/notifications-common/src/types.ts | 6 + plugins/notifications-common/src/utils.ts | 20 +- 8 files changed, 476 insertions(+), 13 deletions(-) create mode 100644 .changeset/fancy-years-camp.md diff --git a/.changeset/fancy-years-camp.md b/.changeset/fancy-years-camp.md new file mode 100644 index 0000000000..41782d4e0c --- /dev/null +++ b/.changeset/fancy-years-camp.md @@ -0,0 +1,9 @@ +--- +'@backstage/plugin-notifications-backend': minor +'@backstage/plugin-notifications-common': minor +--- + +Adds support for default configuration for an entire notification channel. +This setting will also be inherited down to origins and topics while still respecting the users individual choices. + +This will be handy if you want to use a "opt-in" strategy. diff --git a/docs/notifications/index.md b/docs/notifications/index.md index 59ff2962ed..dce58d3214 100644 --- a/docs/notifications/index.md +++ b/docs/notifications/index.md @@ -164,6 +164,61 @@ You can customize the origin names shown in the UI by passing an object where th Each notification processor will receive its own row in the settings page, where the user can enable or disable notifications from that processor. +### Default notification settings + +You can configure default notification settings for all users in your `app-config.yaml` file. This allows you to set up notification preferences globally, such as disabling specific channels or origins by default, implementing an opt-in strategy instead of opt-out. + +#### Channel-level defaults + +You can set a default enabled state for an entire channel. When set to `false`, the channel uses an opt-in strategy where notifications are disabled by default unless explicitly enabled by the user or for specific origins. + +```yaml +notifications: + defaultSettings: + channels: + - id: 'Web' + enabled: false # Opt-in strategy: channel disabled by default + - id: 'Email' + enabled: true # Opt-out strategy: channel enabled by default (default behavior) +``` + +#### Origin-level defaults + +You can also configure defaults for specific origins within a channel: + +```yaml +notifications: + defaultSettings: + channels: + - id: 'Web' + enabled: true # Channel is enabled by default + origins: + - id: 'plugin:scaffolder' + enabled: false # Disable scaffolder notifications by default + - id: 'plugin:catalog' + enabled: true # Enable catalog notifications by default +``` + +#### Topic-level defaults + +For even more granular control, you can set defaults for specific topics within origins: + +```yaml +notifications: + defaultSettings: + channels: + - id: 'Email' + enabled: false # Email is opt-in by default + origins: + - id: 'plugin:catalog' + enabled: true # But catalog notifications are enabled + topics: + - id: 'entity:validation:error' + enabled: false # Except validation errors +``` + +**Note:** If a channel's `enabled` flag is not set, it defaults to `true` for backwards compatibility. When a channel is set to `enabled: false`, all origins within that channel default to disabled unless explicitly enabled. + ### Automatic notification cleanup Notifications are deleted automatically after a certain period of time to prevent the database from growing indefinitely diff --git a/plugins/notifications-backend/config.d.ts b/plugins/notifications-backend/config.d.ts index 2b6f874f72..d03baaec94 100644 --- a/plugins/notifications-backend/config.d.ts +++ b/plugins/notifications-backend/config.d.ts @@ -34,6 +34,13 @@ export interface Config { defaultSettings?: { channels?: { id: string; + /** + * Optional flag to enable/disable the channel by default. + * If not set, defaults to true for backwards compatibility. + * When set to false, the channel uses an opt-in strategy where + * origins are disabled by default unless explicitly enabled. + */ + enabled?: boolean; origins?: { id: string; enabled: boolean; diff --git a/plugins/notifications-backend/src/service/router.test.ts b/plugins/notifications-backend/src/service/router.test.ts index 3f71377151..62a37acbbb 100644 --- a/plugins/notifications-backend/src/service/router.test.ts +++ b/plugins/notifications-backend/src/service/router.test.ts @@ -32,7 +32,7 @@ import { import { catalogServiceMock } from '@backstage/plugin-catalog-node/testUtils'; import { DatabaseService } from '@backstage/backend-plugin-api'; import { v4 as uuid } from 'uuid'; -import { DatabaseNotificationsStore } from '../database'; +import { DatabaseNotificationsStore, generateSettingsHash } from '../database'; const databases = TestDatabases.create(); let store: DatabaseNotificationsStore; @@ -581,6 +581,157 @@ describe.each(databases.eachSupportedId())('createRouter (%s)', databaseId => { expect(response.status).toEqual(400); }); + + it('should not send notification when channel is disabled and user has no settings', async () => { + // Create a new config with channel disabled + const configWithChannelDisabled = mockServices.rootConfig({ + data: { + app: { baseUrl: 'http://localhost' }, + notifications: { + defaultSettings: { + channels: [ + { + id: 'Web', + enabled: false, // Channel disabled by default (opt-in) + }, + ], + }, + }, + }, + }); + + const routerWithChannelDisabled = await createRouter({ + logger: mockServices.logger.mock(), + store, + signals: signalService, + userInfo, + config: configWithChannelDisabled, + httpAuth, + auth, + catalog, + }); + const appWithChannelDisabled = express() + .use(routerWithChannelDisabled) + .use(mockErrorHandler()); + + const sendNotificationToDisabledChannel = ( + opts: NotificationSendOptions, + ) => + request(appWithChannelDisabled) + .post('/notifications') + .send(opts) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + const response = await sendNotificationToDisabledChannel({ + recipients: { + type: 'entity', + entityRef: ['user:default/mock'], + }, + payload: { + title: 'test notification', + topic: 'test-topic', + }, + }); + + expect(response.status).toEqual(200); + expect(response.body).toEqual([]); // No notifications sent + + const client = await database.getClient(); + const notifications = await client('notification') + .where('user', 'user:default/mock') + .select(); + expect(notifications).toHaveLength(0); // No notifications created + }); + + it('should send notification when user enabled specific topic even if channel is disabled', async () => { + // Create a new config with channel disabled + const configWithChannelDisabled = mockServices.rootConfig({ + data: { + app: { baseUrl: 'http://localhost' }, + notifications: { + defaultSettings: { + channels: [ + { + id: 'Web', + enabled: false, // Channel disabled by default (opt-in) + }, + ], + }, + }, + }, + }); + + const routerWithChannelDisabled = await createRouter({ + logger: mockServices.logger.mock(), + store, + signals: signalService, + userInfo, + config: configWithChannelDisabled, + httpAuth, + auth, + catalog, + }); + const appWithChannelDisabled = express() + .use(routerWithChannelDisabled) + .use(mockErrorHandler()); + + const sendNotificationToDisabledChannel = ( + opts: NotificationSendOptions, + ) => + request(appWithChannelDisabled) + .post('/notifications') + .send(opts) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + // User explicitly enables a specific topic + const client = await database.getClient(); + await client('user_settings').insert({ + settings_key_hash: generateSettingsHash( + 'user:default/mock', + 'Web', + 'external:test-service', + 'important-topic', + ), + user: 'user:default/mock', + channel: 'Web', + origin: 'external:test-service', + topic: 'important-topic', + enabled: true, + }); + + const response = await sendNotificationToDisabledChannel({ + recipients: { + type: 'entity', + entityRef: ['user:default/mock'], + }, + payload: { + title: 'important notification', + topic: 'important-topic', + }, + }); + + expect(response.status).toEqual(200); + expect(response.body).toEqual([ + { + created: expect.any(String), + id: expect.any(String), + origin: 'external:test-service', + payload: { + severity: 'normal', + title: 'important notification', + topic: 'important-topic', + }, + user: 'user:default/mock', + }, + ]); + + const notifications = await client('notification') + .where('user', 'user:default/mock') + .select(); + expect(notifications).toHaveLength(1); // Notification created for enabled topic + }); }); describe('POST /notifications with custom receiver resolver', () => { @@ -932,6 +1083,169 @@ describe.each(databases.eachSupportedId())('createRouter (%s)', databaseId => { ], }); }); + + it('should respect channel-level enabled flag from config', async () => { + // Create a new config with channel-level enabled flag + const configWithChannelEnabled = mockServices.rootConfig({ + data: { + app: { baseUrl: 'http://localhost' }, + notifications: { + defaultSettings: { + channels: [ + { + id: 'Web', + enabled: false, // Channel disabled by default (opt-in) + }, + ], + }, + }, + }, + }); + + const routerWithChannelDisabled = await createRouter({ + logger: mockServices.logger.mock(), + store, + signals: signalService, + userInfo, + config: configWithChannelEnabled, + httpAuth, + auth, + catalog, + }); + const appWithChannelDisabled = express() + .use(routerWithChannelDisabled) + .use(mockErrorHandler()); + + const response = await request(appWithChannelDisabled).get('/settings'); + expect(response.status).toEqual(200); + expect(response.body).toEqual({ + channels: [ + { + id: 'Web', + enabled: false, + origins: expect.arrayContaining([ + { + enabled: false, + id: 'external:test-service', + topics: [{ enabled: false, id: 'test-topic' }], + }, + { + enabled: false, + id: 'external:test-service2', + topics: [{ enabled: false, id: 'test-topic2' }], + }, + ]), + }, + ], + }); + }); + + it('should allow user to enable specific topic even when channel is disabled', async () => { + // Create a new config with channel disabled + const configWithChannelDisabled = mockServices.rootConfig({ + data: { + app: { baseUrl: 'http://localhost' }, + notifications: { + defaultSettings: { + channels: [ + { + id: 'Web', + enabled: false, // Channel disabled by default (opt-in) + }, + ], + }, + }, + }, + }); + + const routerWithChannelDisabled = await createRouter({ + logger: mockServices.logger.mock(), + store, + signals: signalService, + userInfo, + config: configWithChannelDisabled, + httpAuth, + auth, + catalog, + }); + const appWithChannelDisabled = express() + .use(routerWithChannelDisabled) + .use(mockErrorHandler()); + + const client = await database.getClient(); + + // Clear existing notifications from beforeEach + await client('notification').del(); + + // Create notifications with multiple topics for the same origin + await client('notification').insert({ + id: uuid(), + user: 'user:default/mock', + origin: 'external:test-service', + topic: 'topic-build-failed', + title: 'Build Failed', + created: new Date(), + severity: 'high', + }); + + await client('notification').insert({ + id: uuid(), + user: 'user:default/mock', + origin: 'external:test-service', + topic: 'topic-deployment-success', + title: 'Deployment Success', + created: new Date(), + severity: 'normal', + }); + + await client('notification').insert({ + id: uuid(), + user: 'user:default/mock', + origin: 'external:test-service', + topic: 'topic-security-alert', + title: 'Security Alert', + created: new Date(), + severity: 'critical', + }); + + // User explicitly enables only one specific topic (build failures) + // The other topics are NOT in the database, so they should inherit from channel default (false) + await client('user_settings').insert({ + settings_key_hash: generateSettingsHash( + 'user:default/mock', + 'Web', + 'external:test-service', + 'topic-build-failed', + ), + user: 'user:default/mock', + channel: 'Web', + origin: 'external:test-service', + topic: 'topic-build-failed', + enabled: true, + }); + + const response = await request(appWithChannelDisabled).get('/settings'); + expect(response.status).toEqual(200); + expect(response.body).toEqual({ + channels: [ + { + id: 'Web', + enabled: false, + origins: [ + { + enabled: true, // Origin gets enabled when user enables a topic + id: 'external:test-service', + topics: expect.arrayContaining([ + { enabled: true, id: 'topic-build-failed' }, // User explicitly enabled this + { enabled: false, id: 'topic-deployment-success' }, // Inherits from channel default (false) + { enabled: false, id: 'topic-security-alert' }, // Inherits from channel default (false) + ]), + }, + ], + }, + ], + }); + }); }); describe('POST /settings', () => { diff --git a/plugins/notifications-backend/src/service/router.ts b/plugins/notifications-backend/src/service/router.ts index 77f2685ebf..63d974e708 100644 --- a/plugins/notifications-backend/src/service/router.ts +++ b/plugins/notifications-backend/src/service/router.ts @@ -122,7 +122,7 @@ export async function createRouter( topic: any, existingOrigin: OriginSetting | undefined, defaultOriginSettings: OriginSetting | undefined, - defaultEnabled: boolean, + channelDefaultEnabled: boolean, ) => { const existingTopic = existingOrigin?.topics?.find( t => t.id.toLowerCase() === topic.topic.toLowerCase(), @@ -131,11 +131,14 @@ export async function createRouter( t => t.id.toLowerCase() === topic.topic.toLowerCase(), ); + // If topic has explicit setting, use it + // Otherwise check default topic settings from config + // Otherwise use channel default (not origin enabled state) return { id: topic.topic, enabled: existingTopic ? existingTopic.enabled - : defaultTopicSettings?.enabled ?? defaultEnabled, + : defaultTopicSettings?.enabled ?? channelDefaultEnabled, }; }; @@ -144,6 +147,8 @@ export async function createRouter( existingChannel: ChannelSetting | undefined, defaultChannelSettings: ChannelSetting | undefined, topics: { origin: string; topic: string }[], + channelDefaultEnabled: boolean, + channelHasExplicitEnabled: boolean, ) => { const existingOrigin = existingChannel?.origins?.find( o => o.id.toLowerCase() === originId.toLowerCase(), @@ -155,7 +160,7 @@ export async function createRouter( const defaultEnabled = existingOrigin ? existingOrigin.enabled - : defaultOriginSettings?.enabled ?? true; + : defaultOriginSettings?.enabled ?? channelDefaultEnabled; return { id: originId, @@ -167,7 +172,7 @@ export async function createRouter( t, existingOrigin, defaultOriginSettings, - defaultEnabled, + channelHasExplicitEnabled ? channelDefaultEnabled : defaultEnabled, ), ), }; @@ -186,14 +191,29 @@ export async function createRouter( c => c.id.toLowerCase() === channelId.toLowerCase(), ); + // Determine channel enabled state + const channelEnabled = + existingChannel?.enabled ?? defaultChannelSettings?.enabled; + + // Use channel's enabled flag as the default for origins if not explicitly set + const defaultEnabledForOrigins = channelEnabled ?? true; + + // Check if channel has explicit enabled flag (either from user settings or config) + const channelHasExplicitEnabled = + existingChannel?.enabled !== undefined || + defaultChannelSettings?.enabled !== undefined; + return { id: channelId, + enabled: channelEnabled, origins: origins.map(originId => getOriginSettings( originId, existingChannel, defaultChannelSettings, topics, + defaultEnabledForOrigins, + channelHasExplicitEnabled, ), ), }; @@ -241,7 +261,52 @@ export async function createRouter( origin: string; topic: string | null; }) => { - const settings = await getNotificationSettings(opts.user); + // Get user's explicit settings from database + const userSettings = await store.getNotificationSettings({ + user: opts.user, + }); + + // Build a minimal settings object with user settings and config defaults + const settings: NotificationSettings = { + channels: [ + { + id: opts.channel, + enabled: defaultNotificationSettings?.channels?.find( + c => c.id.toLowerCase() === opts.channel.toLowerCase(), + )?.enabled, + origins: [], + }, + ], + }; + + // Add user's channel if it exists + const userChannel = userSettings.channels.find( + c => c.id.toLowerCase() === opts.channel.toLowerCase(), + ); + if (userChannel) { + settings.channels[0] = { + ...settings.channels[0], + enabled: userChannel.enabled ?? settings.channels[0].enabled, + origins: userChannel.origins, + }; + } + + // Add config default origins if not in user settings + const defaultChannelSettings = defaultNotificationSettings?.channels?.find( + c => c.id.toLowerCase() === opts.channel.toLowerCase(), + ); + if (defaultChannelSettings?.origins) { + for (const defaultOrigin of defaultChannelSettings.origins) { + if ( + !settings.channels[0].origins.some( + o => o.id.toLowerCase() === defaultOrigin.id.toLowerCase(), + ) + ) { + settings.channels[0].origins.push(defaultOrigin); + } + } + } + return isNotificationsEnabledFor( settings, opts.channel, diff --git a/plugins/notifications-common/report.api.md b/plugins/notifications-common/report.api.md index 337c41b7b5..ecc0ec8ef0 100644 --- a/plugins/notifications-common/report.api.md +++ b/plugins/notifications-common/report.api.md @@ -9,6 +9,7 @@ import { JsonValue } from '@backstage/types'; // @public (undocumented) export type ChannelSetting = { id: string; + enabled?: boolean; origins: OriginSetting[]; }; diff --git a/plugins/notifications-common/src/types.ts b/plugins/notifications-common/src/types.ts index 4e49de79de..0653007637 100644 --- a/plugins/notifications-common/src/types.ts +++ b/plugins/notifications-common/src/types.ts @@ -154,6 +154,12 @@ export type OriginSetting = { */ export type ChannelSetting = { id: string; + /** + * Optional flag to enable/disable the channel by default. + * If not set, defaults to true for backwards compatibility. + * When set to false, the channel uses an opt-in strategy. + */ + enabled?: boolean; origins: OriginSetting[]; }; diff --git a/plugins/notifications-common/src/utils.ts b/plugins/notifications-common/src/utils.ts index b95df5bea5..02d840e34f 100644 --- a/plugins/notifications-common/src/utils.ts +++ b/plugins/notifications-common/src/utils.ts @@ -29,14 +29,20 @@ export const isNotificationsEnabledFor = ( const origin = channel.origins.find(o => o.id === originId); if (!origin) { - return true; + // If no origin is found, use channel's enabled flag (defaults to true if not set) + return channel.enabled ?? true; } - if (topicId === null) { + + // If topic is specified, check topic-level setting + if (topicId !== null) { + const topic = origin.topics?.find(t => t.id === topicId); + if (topic) { + return topic.enabled; + } + // No explicit topic setting, check origin return origin.enabled; } - const topic = origin.topics?.find(t => t.id === topicId); - if (!topic) { - return origin.enabled; - } - return topic.enabled; + + // No topic specified, check origin-level setting + return origin.enabled; }; From 4918a6ffe684b6da76a488177c9c8e42123d3505 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Edeg=C3=A5rd?= Date: Tue, 4 Nov 2025 13:10:39 +0000 Subject: [PATCH 12/69] fixes two failing tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Henrik Edegård --- plugins/notifications-backend/src/service/router.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/notifications-backend/src/service/router.ts b/plugins/notifications-backend/src/service/router.ts index 63d974e708..0e307fb4ea 100644 --- a/plugins/notifications-backend/src/service/router.ts +++ b/plugins/notifications-backend/src/service/router.ts @@ -233,7 +233,7 @@ export async function createRouter( channels.push(channel.id); } - for (const origin of channel.origins) { + for (const origin of channel.origins ?? []) { if (!origins.includes(origin.id)) { origins.push(origin.id); } @@ -287,7 +287,7 @@ export async function createRouter( settings.channels[0] = { ...settings.channels[0], enabled: userChannel.enabled ?? settings.channels[0].enabled, - origins: userChannel.origins, + origins: userChannel.origins ?? [], }; } From 722e2df20a6243d67e528846a6ed1aeb93924ebe Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 5 Nov 2025 01:35:51 +0100 Subject: [PATCH 13/69] app-visualizer: migrate to use BUI Signed-off-by: Patrik Oldsberg --- .changeset/sharp-dragons-relate.md | 5 + plugins/app-visualizer/package.json | 4 +- .../AppVisualizerPage/AppVisualizerPage.tsx | 6 +- .../AppVisualizerPage/DetailedVisualizer.tsx | 370 ++++++++---------- .../AppVisualizerPage/TextVisualizer.tsx | 46 +-- .../AppVisualizerPage/TreeVisualizer.tsx | 45 +-- plugins/app-visualizer/src/plugin.tsx | 2 +- 7 files changed, 218 insertions(+), 260 deletions(-) create mode 100644 .changeset/sharp-dragons-relate.md diff --git a/.changeset/sharp-dragons-relate.md b/.changeset/sharp-dragons-relate.md new file mode 100644 index 0000000000..1f7e5e0d4a --- /dev/null +++ b/.changeset/sharp-dragons-relate.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-app-visualizer': patch +--- + +Migrated to use `@backstage/ui`. diff --git a/plugins/app-visualizer/package.json b/plugins/app-visualizer/package.json index f7bfdb8296..3e6a0c7d85 100644 --- a/plugins/app-visualizer/package.json +++ b/plugins/app-visualizer/package.json @@ -37,8 +37,8 @@ "@backstage/core-components": "workspace:^", "@backstage/core-plugin-api": "workspace:^", "@backstage/frontend-plugin-api": "workspace:^", - "@material-ui/core": "^4.12.2", - "@material-ui/icons": "^4.9.1" + "@backstage/ui": "workspace:^", + "@remixicon/react": "^4.6.0" }, "devDependencies": { "@backstage/cli": "workspace:^", diff --git a/plugins/app-visualizer/src/components/AppVisualizerPage/AppVisualizerPage.tsx b/plugins/app-visualizer/src/components/AppVisualizerPage/AppVisualizerPage.tsx index 6410e20965..c005ccb18f 100644 --- a/plugins/app-visualizer/src/components/AppVisualizerPage/AppVisualizerPage.tsx +++ b/plugins/app-visualizer/src/components/AppVisualizerPage/AppVisualizerPage.tsx @@ -17,7 +17,7 @@ import { Content, Header, HeaderTabs, Page } from '@backstage/core-components'; import { useApi } from '@backstage/core-plugin-api'; import { appTreeApiRef } from '@backstage/frontend-plugin-api'; -import Box from '@material-ui/core/Box'; +import { Flex } from '@backstage/ui'; import { useCallback, useEffect, useMemo } from 'react'; import { DetailedVisualizer } from './DetailedVisualizer'; import { TextVisualizer } from './TextVisualizer'; @@ -86,14 +86,14 @@ export function AppVisualizerPage() {
- + {element} - + ); diff --git a/plugins/app-visualizer/src/components/AppVisualizerPage/DetailedVisualizer.tsx b/plugins/app-visualizer/src/components/AppVisualizerPage/DetailedVisualizer.tsx index ba93507ae2..df9308ce96 100644 --- a/plugins/app-visualizer/src/components/AppVisualizerPage/DetailedVisualizer.tsx +++ b/plugins/app-visualizer/src/components/AppVisualizerPage/DetailedVisualizer.tsx @@ -25,150 +25,93 @@ import { ThemeBlueprint, useRouteRef, } from '@backstage/frontend-plugin-api'; -import Box from '@material-ui/core/Box'; -import Paper from '@material-ui/core/Paper'; -import Tooltip from '@material-ui/core/Tooltip'; -import Typography from '@material-ui/core/Typography'; -import * as colors from '@material-ui/core/colors'; -import { makeStyles } from '@material-ui/core/styles'; -import InputIcon from '@material-ui/icons/InputSharp'; -import DisabledIcon from '@material-ui/icons/NotInterestedSharp'; +import { Box, Flex } from '@backstage/ui'; import { Link } from 'react-router-dom'; +import { + RiInputField as InputIcon, + RiCloseCircleLine as DisabledIcon, +} from '@remixicon/react'; + +function getContrastColor(bgColor: string): string { + const hex = bgColor.replace('#', ''); + const r = parseInt(hex.substr(0, 2), 16); + const g = parseInt(hex.substr(2, 2), 16); + const b = parseInt(hex.substr(4, 2), 16); + const brightness = (r * 299 + g * 587 + b * 114) / 1000; + return brightness > 128 ? '#000000' : '#ffffff'; +} function createOutputColorGenerator( colorMap: { [extDataId: string]: string }, availableColors: string[], ) { - const map = new Map(); + const map = new Map(); let i = 0; return function getOutputColor(id: string) { + let backgroundColor: string; if (id in colorMap) { - return colorMap[id]; + backgroundColor = colorMap[id]; + } else { + const cached = map.get(id); + if (cached) { + return cached; + } + backgroundColor = availableColors[i]; + i += 1; + if (i >= availableColors.length) { + i = 0; + } } - let color = map.get(id); - if (color) { - return color; - } - color = availableColors[i]; - i += 1; - if (i >= availableColors.length) { - i = 0; - } - map.set(id, color); - return color; + const result = { + backgroundColor, + color: getContrastColor(backgroundColor), + }; + map.set(id, result); + return result; }; } +// Color palette for output visualization +const colorPalette = { + green: { 500: '#4caf50', 200: '#a5d6a7' }, + yellow: { 500: '#ffeb3b', 200: '#fff59d' }, + purple: { 500: '#9c27b0', 200: '#ce93d8' }, + blue: { 500: '#2196f3', 200: '#90caf9' }, + lime: { 500: '#cddc39', 200: '#e6ee9c' }, + orange: { 500: '#ff9800', 200: '#ffcc80' }, + red: { 200: '#ef9a9a' }, +}; + const getOutputColor = createOutputColorGenerator( { - [coreExtensionData.reactElement.id]: colors.green[500], - [coreExtensionData.routePath.id]: colors.yellow[500], - [coreExtensionData.routeRef.id]: colors.purple[500], - [ApiBlueprint.dataRefs.factory.id]: colors.blue[500], - [ThemeBlueprint.dataRefs.theme.id]: colors.lime[500], - [NavItemBlueprint.dataRefs.target.id]: colors.orange[500], + [coreExtensionData.reactElement.id]: colorPalette.green[500], + [coreExtensionData.routePath.id]: colorPalette.yellow[500], + [coreExtensionData.routeRef.id]: colorPalette.purple[500], + [ApiBlueprint.dataRefs.factory.id]: colorPalette.blue[500], + [ThemeBlueprint.dataRefs.theme.id]: colorPalette.lime[500], + [NavItemBlueprint.dataRefs.target.id]: colorPalette.orange[500], }, [ - colors.blue[200], - colors.orange[200], - colors.green[200], - colors.red[200], - colors.yellow[200], - colors.purple[200], - colors.lime[200], + colorPalette.blue[200], + colorPalette.orange[200], + colorPalette.green[200], + colorPalette.red[200], + colorPalette.yellow[200], + colorPalette.purple[200], + colorPalette.lime[200], ], ); -interface StyleProps { - enabled: boolean; - depth: number; +// Helper function to get border color based on depth +function getBorderColor(depth: number): string { + const greyLevels = [8, 7, 6, 5]; // darker levels that contrast well with background + const index = depth % greyLevels.length; + const level = greyLevels[index]; + return `var(--bui-gray-${level})`; } -const config = { - borderWidth: 0.75, -}; - -const useStyles = makeStyles(theme => ({ - extension: { - borderLeftWidth: theme.spacing(config.borderWidth), - borderLeftStyle: 'solid', - borderLeftColor: ({ depth }: StyleProps) => - colors.grey[(700 - (depth % 6) * 100) as keyof typeof colors.grey], - cursor: 'pointer', - - '&:hover $extensionHeader': { - color: ({ enabled }: StyleProps) => - enabled ? theme.palette.primary.main : theme.palette.text.secondary, - }, - }, - extensionHeader: { - display: 'flex', - alignItems: 'center', - width: 'fit-content', - - padding: theme.spacing(0.5, 1), - color: ({ enabled }: StyleProps) => - enabled ? theme.palette.text.primary : theme.palette.text.disabled, - background: theme.palette.background.paper, - - borderTopRightRadius: theme.shape.borderRadius, - borderBottomRightRadius: theme.shape.borderRadius, - }, - extensionHeaderId: { - userSelect: 'all', - }, - extensionHeaderOutputs: { - display: 'flex', - alignItems: 'center', - marginLeft: theme.spacing(1), - gap: theme.spacing(1), - }, - attachments: { - gap: theme.spacing(2), - display: 'flex', - flexDirection: 'column', - }, - attachmentsInput: { - '&:first-child $attachmentsInputTitle': { - borderTop: 0, - }, - }, - attachmentsInputTitle: { - display: 'flex', - alignItems: 'center', - width: 'fit-content', - padding: theme.spacing(1), - - borderTopWidth: theme.spacing(config.borderWidth), - borderTopStyle: 'solid', - borderTopColor: ({ depth }: StyleProps) => - colors.grey[(700 - (depth % 6) * 100) as keyof typeof colors.grey], - }, - attachmentsInputName: { - marginLeft: theme.spacing(1), - }, - attachmentsInputChildren: { - display: 'flex', - flexDirection: 'column', - alignItems: 'flex-start', - gap: theme.spacing(0.5), - marginLeft: theme.spacing(1), - marginBottom: theme.spacing(1), - }, -})); - -const useOutputStyles = makeStyles(theme => ({ - output: ({ color }: { color: string }) => ({ - padding: `0 10px`, - height: 20, - borderRadius: 10, - color: theme.palette.getContrastText(color), - backgroundColor: color, - }), -})); - function getFullPath(node?: AppNode): string { if (!node) { return ''; @@ -184,7 +127,7 @@ function getFullPath(node?: AppNode): string { function OutputLink(props: { dataRef: ExtensionDataRef; node?: AppNode; - className: string; + style: React.CSSProperties; }) { const routeRef = props.node?.instance?.getData(coreExtensionData.routeRef); @@ -192,11 +135,11 @@ function OutputLink(props: { const link = useRouteRef(routeRef as RouteRef); return ( - {props.dataRef.id}}> - +
+ {link ? link : null} - - + +
); } catch (ex) { // eslint-disable-next-line no-console @@ -215,26 +158,39 @@ function Output(props: { dataRef: ExtensionDataRef; node?: AppNode }) { const { id } = dataRef; const instance = node?.instance; - const classes = useOutputStyles({ color: getOutputColor(id) }); + const { backgroundColor, color } = getOutputColor(id); + + const chipStyle: React.CSSProperties = { + padding: '0 var(--bui-space-2_5, 10px)', + height: 20, + borderRadius: 'var(--bui-radius-full)', + color, + backgroundColor, + }; if (id === coreExtensionData.routePath.id) { return ( - {getFullPath(node)}}> - +
+ {String(instance?.getData(dataRef) ?? '')} - - + +
); } - if (id === coreExtensionData.routeRef.id) { - return ; + if (id === coreExtensionData.routeRef.id && node) { + return ( + + ); } return ( - {id}}> - - +
+ +
); } @@ -243,29 +199,35 @@ function Attachments(props: { enabled: boolean; depth: number; }) { - const { node, enabled, depth } = props; + const { node, depth } = props; const { attachments } = node.edges; - const classes = useStyles({ enabled, depth }); - if (attachments.size === 0) { return null; } return ( - + {[...attachments.entries()] .sort(([a], [b]) => a.localeCompare(b)) - .map(([key, children]) => { + .map(([key, children], idx) => { return ( - - - - - {key} - - - + + + +
{key}
+
+ {children.map(childNode => ( ))} -
+
); })} -
- ); -} - -function ExtensionTooltip(props: { node: AppNode }) { - const parts = []; - let node = props.node; - parts.push(node.spec.id); - while (node.edges.attachedTo) { - const input = node.edges.attachedTo.input; - node = node.edges.attachedTo.node; - parts.push(`${node.spec.id} [${input}]`); - } - parts.reverse(); - - return ( - <> - {parts.map(part => ( - {part} - ))} - + ); } @@ -305,27 +247,54 @@ function Extension(props: { node: AppNode; depth: number }) { const { node, depth } = props; const enabled = Boolean(node.instance); - const classes = useStyles({ enabled, depth }); - const dataRefs = node.instance && [...node.instance.getDataRefs()]; + // Build tooltip text + const tooltipParts = []; + let currentNode = node; + tooltipParts.push(currentNode.spec.id); + while (currentNode.edges.attachedTo) { + const input = currentNode.edges.attachedTo.input; + currentNode = currentNode.edges.attachedTo.node; + tooltipParts.push(`${currentNode.spec.id} [${input}]`); + } + tooltipParts.reverse(); + const tooltipText = tooltipParts.join(' → '); + return ( - - - }> - - {node.spec.id} - - - + + +
+ {node.spec.id} +
+ {dataRefs && dataRefs.length > 0 && dataRefs .sort((a, b) => a.id.localeCompare(b.id)) .map(ref => )} - {!enabled && } -
-
+ {!enabled && } + +
); @@ -343,24 +312,19 @@ const legendMap = { function Legend() { return ( {Object.entries(legendMap).map(([label, dataRef]) => ( - + - {label} - +
{label}
+ ))}
); @@ -368,14 +332,22 @@ function Legend() { export function DetailedVisualizer({ tree }: { tree: AppTree }) { return ( - - + + - + - + ); } diff --git a/plugins/app-visualizer/src/components/AppVisualizerPage/TextVisualizer.tsx b/plugins/app-visualizer/src/components/AppVisualizerPage/TextVisualizer.tsx index 4385ba0938..82fd913bf2 100644 --- a/plugins/app-visualizer/src/components/AppVisualizerPage/TextVisualizer.tsx +++ b/plugins/app-visualizer/src/components/AppVisualizerPage/TextVisualizer.tsx @@ -15,10 +15,7 @@ */ import { AppNode, AppTree } from '@backstage/frontend-plugin-api'; -import Box from '@material-ui/core/Box'; -import Checkbox from '@material-ui/core/Checkbox'; -import FormControlLabel from '@material-ui/core/FormControlLabel'; -import Paper from '@material-ui/core/Paper'; +import { Box, Checkbox } from '@backstage/ui'; import { ReactNode, useState } from 'react'; function mkDiv( @@ -30,7 +27,7 @@ function mkDiv( key={options?.key} style={{ color: options?.color, - marginLeft: options?.indent ? 16 : undefined, + marginLeft: options?.indent ? 'var(--bui-space-4)' : undefined, }} > {children} @@ -87,30 +84,25 @@ export function TextVisualizer({ tree }: { tree: AppTree }) { return ( <> -
+ {nodeToText(tree.root, { showOutputs, showDisabled })} -
+
+
+ + + Show Outputs + + + Show Disabled + - - setShowOutputs(value)} - /> - } - label="Show Outputs" - /> - setShowDisabled(value)} - /> - } - label="Show Disabled" - /> - ); } diff --git a/plugins/app-visualizer/src/components/AppVisualizerPage/TreeVisualizer.tsx b/plugins/app-visualizer/src/components/AppVisualizerPage/TreeVisualizer.tsx index b9058cb87b..d3a9145bb3 100644 --- a/plugins/app-visualizer/src/components/AppVisualizerPage/TreeVisualizer.tsx +++ b/plugins/app-visualizer/src/components/AppVisualizerPage/TreeVisualizer.tsx @@ -19,8 +19,7 @@ import { DependencyGraphTypes, } from '@backstage/core-components'; import { AppNode, AppTree } from '@backstage/frontend-plugin-api'; -import Box from '@material-ui/core/Box'; -import { makeStyles } from '@material-ui/core/styles'; +import { Flex } from '@backstage/ui'; import { useLayoutEffect, useMemo, useRef, useState } from 'react'; type NodeType = @@ -84,26 +83,9 @@ function resolveGraphData(tree: AppTree): { }; } -const useStyles = makeStyles(theme => ({ - node: { - fill: (node: NodeType) => - node.type === 'node' - ? theme.palette.primary.light - : theme.palette.grey[500], - stroke: (node: NodeType) => - node.type === 'node' - ? theme.palette.primary.main - : theme.palette.grey[600], - }, - text: { - fill: theme.palette.primary.contrastText, - }, -})); - /** @public */ export function Node(props: { node: NodeType }) { const { node } = props; - const classes = useStyles(node); const [width, setWidth] = useState(0); const [height, setHeight] = useState(0); const idRef = useRef(null); @@ -127,17 +109,23 @@ export function Node(props: { node: NodeType }) { const paddedWidth = width + padding * 2; const paddedHeight = height + padding * 2; + // Simple inline styles for SVG elements + const nodeFill = node.type === 'node' ? '#90caf9' : '#9e9e9e'; + const nodeStroke = node.type === 'node' ? '#2196f3' : '#757575'; + const textFill = '#000000'; + return ( resolveGraphData(tree), [tree]); return ( - - + ); } diff --git a/plugins/app-visualizer/src/plugin.tsx b/plugins/app-visualizer/src/plugin.tsx index 58e1b2effa..3d30df16e6 100644 --- a/plugins/app-visualizer/src/plugin.tsx +++ b/plugins/app-visualizer/src/plugin.tsx @@ -20,7 +20,7 @@ import { NavItemBlueprint, PageBlueprint, } from '@backstage/frontend-plugin-api'; -import VisualizerIcon from '@material-ui/icons/Visibility'; +import { RiEyeLine as VisualizerIcon } from '@remixicon/react'; const rootRouteRef = createRouteRef(); From b17383c34dd63dc19d2137decb606a79f24dc7af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Edeg=C3=A5rd?= Date: Wed, 5 Nov 2025 07:03:36 +0000 Subject: [PATCH 14/69] Add comments for clarity in notification configuration types. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Henrik Edegård --- plugins/notifications-backend/config.d.ts | 15 ++++++++++++++ plugins/notifications-common/src/types.ts | 24 +++++++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/plugins/notifications-backend/config.d.ts b/plugins/notifications-backend/config.d.ts index d03baaec94..90a2053824 100644 --- a/plugins/notifications-backend/config.d.ts +++ b/plugins/notifications-backend/config.d.ts @@ -33,6 +33,9 @@ export interface Config { */ defaultSettings?: { channels?: { + /** + * Channel identifier (e.g., 'Web', 'Email') + */ id: string; /** * Optional flag to enable/disable the channel by default. @@ -42,10 +45,22 @@ export interface Config { */ enabled?: boolean; origins?: { + /** + * Origin identifier (e.g., 'plugin:catalog', 'external:jenkins') + */ id: string; + /** + * Whether notifications from this origin are enabled by default + */ enabled: boolean; topics?: { + /** + * Topic identifier (e.g., 'entity-refresh', 'build-failure') + */ id: string; + /** + * Whether notifications for this topic are enabled by default + */ enabled: boolean; }[]; }[]; diff --git a/plugins/notifications-common/src/types.ts b/plugins/notifications-common/src/types.ts index 0653007637..41b335a4c2 100644 --- a/plugins/notifications-common/src/types.ts +++ b/plugins/notifications-common/src/types.ts @@ -136,7 +136,13 @@ export type NotificationProcessorFilters = { * @public */ export type TopicSetting = { + /** + * Topic identifier + */ id: string; + /** + * Whether notifications for this topic are enabled + */ enabled: boolean; }; @@ -144,8 +150,17 @@ export type TopicSetting = { * @public */ export type OriginSetting = { + /** + * Origin identifier + */ id: string; + /** + * Whether notifications from this origin are enabled + */ enabled: boolean; + /** + * Optional array of topic-specific settings + */ topics?: TopicSetting[]; }; @@ -153,6 +168,9 @@ export type OriginSetting = { * @public */ export type ChannelSetting = { + /** + * Channel identifier + */ id: string; /** * Optional flag to enable/disable the channel by default. @@ -160,6 +178,9 @@ export type ChannelSetting = { * When set to false, the channel uses an opt-in strategy. */ enabled?: boolean; + /** + * Array of origin settings for this channel + */ origins: OriginSetting[]; }; @@ -167,5 +188,8 @@ export type ChannelSetting = { * @public */ export type NotificationSettings = { + /** + * Array of channel settings + */ channels: ChannelSetting[]; }; From 92d91ae4e631ae3020db8b17ca3c1a23fa6633ae Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 5 Nov 2025 11:27:09 +0100 Subject: [PATCH 15/69] app-visualizer: BUI migration detailed fixes and cleanup Signed-off-by: Patrik Oldsberg --- plugins/app-visualizer/package.json | 3 +- .../AppVisualizerPage/DetailedVisualizer.tsx | 142 +++++++----------- yarn.lock | 5 +- 3 files changed, 63 insertions(+), 87 deletions(-) diff --git a/plugins/app-visualizer/package.json b/plugins/app-visualizer/package.json index 3e6a0c7d85..89bf7b3c5b 100644 --- a/plugins/app-visualizer/package.json +++ b/plugins/app-visualizer/package.json @@ -38,7 +38,8 @@ "@backstage/core-plugin-api": "workspace:^", "@backstage/frontend-plugin-api": "workspace:^", "@backstage/ui": "workspace:^", - "@remixicon/react": "^4.6.0" + "@remixicon/react": "^4.6.0", + "react-aria-components": "^1.13.0" }, "devDependencies": { "@backstage/cli": "workspace:^", diff --git a/plugins/app-visualizer/src/components/AppVisualizerPage/DetailedVisualizer.tsx b/plugins/app-visualizer/src/components/AppVisualizerPage/DetailedVisualizer.tsx index df9308ce96..1d38ec8267 100644 --- a/plugins/app-visualizer/src/components/AppVisualizerPage/DetailedVisualizer.tsx +++ b/plugins/app-visualizer/src/components/AppVisualizerPage/DetailedVisualizer.tsx @@ -18,19 +18,19 @@ import { AppNode, AppTree, ExtensionDataRef, - RouteRef, coreExtensionData, ApiBlueprint, NavItemBlueprint, ThemeBlueprint, - useRouteRef, + useApi, + routeResolutionApiRef, } from '@backstage/frontend-plugin-api'; -import { Box, Flex } from '@backstage/ui'; -import { Link } from 'react-router-dom'; +import { Box, Flex, Link, Text, Tooltip, TooltipTrigger } from '@backstage/ui'; import { RiInputField as InputIcon, RiCloseCircleLine as DisabledIcon, } from '@remixicon/react'; +import { Focusable } from 'react-aria-components'; function getContrastColor(bgColor: string): string { const hex = bgColor.replace('#', ''); @@ -72,36 +72,17 @@ function createOutputColorGenerator( }; } -// Color palette for output visualization -const colorPalette = { - green: { 500: '#4caf50', 200: '#a5d6a7' }, - yellow: { 500: '#ffeb3b', 200: '#fff59d' }, - purple: { 500: '#9c27b0', 200: '#ce93d8' }, - blue: { 500: '#2196f3', 200: '#90caf9' }, - lime: { 500: '#cddc39', 200: '#e6ee9c' }, - orange: { 500: '#ff9800', 200: '#ffcc80' }, - red: { 200: '#ef9a9a' }, -}; - const getOutputColor = createOutputColorGenerator( { - [coreExtensionData.reactElement.id]: colorPalette.green[500], - [coreExtensionData.routePath.id]: colorPalette.yellow[500], - [coreExtensionData.routeRef.id]: colorPalette.purple[500], - [ApiBlueprint.dataRefs.factory.id]: colorPalette.blue[500], - [ThemeBlueprint.dataRefs.theme.id]: colorPalette.lime[500], - [NavItemBlueprint.dataRefs.target.id]: colorPalette.orange[500], + [coreExtensionData.reactElement.id]: '#4caf50', + [coreExtensionData.routePath.id]: '#ffeb3b', + [coreExtensionData.routeRef.id]: '#9c27b0', + [ApiBlueprint.dataRefs.factory.id]: '#2196f3', + [ThemeBlueprint.dataRefs.theme.id]: '#cddc39', + [NavItemBlueprint.dataRefs.target.id]: '#ff9800', }, - [ - colorPalette.blue[200], - colorPalette.orange[200], - colorPalette.green[200], - colorPalette.red[200], - colorPalette.yellow[200], - colorPalette.purple[200], - colorPalette.lime[200], - ], + ['#90caf9', '#ffcc80', '#a5d6a7', '#ef9a9a', '#fff59d', '#ce93d8', '#e6ee9c'], ); // Helper function to get border color based on depth @@ -124,73 +105,62 @@ function getFullPath(node?: AppNode): string { return getFullPath(parent) + part; } -function OutputLink(props: { - dataRef: ExtensionDataRef; - node?: AppNode; - style: React.CSSProperties; -}) { - const routeRef = props.node?.instance?.getData(coreExtensionData.routeRef); - - try { - const link = useRouteRef(routeRef as RouteRef); - - return ( -
- - {link ? link : null} - -
- ); - } catch (ex) { - // eslint-disable-next-line no-console - console.warn( - props.node?.spec.id - ? `Unable to generate output link for ${props.node.spec.id}` - : 'Unable to generate output link', - ex, - ); - return null; - } -} - function Output(props: { dataRef: ExtensionDataRef; node?: AppNode }) { const { dataRef, node } = props; const { id } = dataRef; const instance = node?.instance; + const routeResolutionApi = useApi(routeResolutionApiRef); + const { backgroundColor, color } = getOutputColor(id); const chipStyle: React.CSSProperties = { - padding: '0 var(--bui-space-2_5, 10px)', height: 20, - borderRadius: 'var(--bui-radius-full)', + padding: '0 10px', + borderRadius: '10px', color, backgroundColor, + display: 'flex', + alignItems: 'center', + fontWeight: + 'var(--bui-font-weight-regular)' as React.CSSProperties['fontWeight'], }; - if (id === coreExtensionData.routePath.id) { - return ( -
- - {String(instance?.getData(dataRef) ?? '')} - -
- ); + if (id === coreExtensionData.routeRef.id && node) { + try { + const routeRef = props.node?.instance?.getData( + coreExtensionData.routeRef, + ); + const link = routeRef && routeResolutionApi.resolve(routeRef)?.(); + if (link) { + return ( + + + link + + {id} + + ); + } + } catch { + /* ignore */ + } } - if (id === coreExtensionData.routeRef.id && node) { - return ( - - ); + let tooltip = id; + let text: string | undefined = undefined; + if (id === coreExtensionData.routePath.id) { + text = String(instance?.getData(dataRef) ?? ''); + tooltip = getFullPath(node); } return ( -
- -
+ + + {text} + + {tooltip} + ); } @@ -259,7 +229,7 @@ function Extension(props: { node: AppNode; depth: number }) { tooltipParts.push(`${currentNode.spec.id} [${input}]`); } tooltipParts.reverse(); - const tooltipText = tooltipParts.join(' → '); + const tooltipText = tooltipParts.join('\n'); return ( -
- {node.spec.id} -
+ + + {node.spec.id} + + + {tooltipText} + + {dataRefs && dataRefs.length > 0 && diff --git a/yarn.lock b/yarn.lock index 056de1c49a..a559f0b8cb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4121,10 +4121,11 @@ __metadata: "@backstage/core-plugin-api": "workspace:^" "@backstage/frontend-defaults": "workspace:^" "@backstage/frontend-plugin-api": "workspace:^" - "@material-ui/core": "npm:^4.12.2" - "@material-ui/icons": "npm:^4.9.1" + "@backstage/ui": "workspace:^" + "@remixicon/react": "npm:^4.6.0" "@types/react": "npm:^18.0.0" react: "npm:^18.0.2" + react-aria-components: "npm:^1.13.0" react-dom: "npm:^18.0.2" react-router-dom: "npm:^6.3.0" peerDependencies: From 80d5d664a147a5dd1f75d243c1818c08ae5c75d1 Mon Sep 17 00:00:00 2001 From: Gaurav Pandey Date: Wed, 5 Nov 2025 18:58:52 +0100 Subject: [PATCH 16/69] add usage statistics plugin Signed-off-by: Gaurav Pandey --- microsite/data/plugins/usage-statistics.yaml | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 microsite/data/plugins/usage-statistics.yaml diff --git a/microsite/data/plugins/usage-statistics.yaml b/microsite/data/plugins/usage-statistics.yaml new file mode 100644 index 0000000000..d32fb54ac4 --- /dev/null +++ b/microsite/data/plugins/usage-statistics.yaml @@ -0,0 +1,9 @@ +--- +title: Usage Statistics +author: CodeVerse-GP +authorUrl: https://github.com/CodeVerse-GP +category: Monitoring +description: Shows usage statistics for scaffolder templates +documentation: https://github.com/CodeVerse-GP/usage-statistics/blob/main/README.md +npmPackageName: '@codeverse-gp/plugin-usage-statistics' +addedDate: '2025-11-05' From 6db9e7e9d890a1094709a9c2058fe6cf72e56089 Mon Sep 17 00:00:00 2001 From: Benjamin Janssens Date: Thu, 6 Nov 2025 08:39:34 +0100 Subject: [PATCH 17/69] style(org): improve responsiveness of GroupProfileCard Signed-off-by: Benjamin Janssens --- .changeset/all-parrots-change.md | 5 + .../Group/GroupProfile/GroupProfileCard.tsx | 150 +++++++++--------- 2 files changed, 83 insertions(+), 72 deletions(-) create mode 100644 .changeset/all-parrots-change.md diff --git a/.changeset/all-parrots-change.md b/.changeset/all-parrots-change.md new file mode 100644 index 0000000000..65d5c37658 --- /dev/null +++ b/.changeset/all-parrots-change.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-org': patch +--- + +Improved responsiveness of GroupProfileCard component diff --git a/plugins/org/src/components/Cards/Group/GroupProfile/GroupProfileCard.tsx b/plugins/org/src/components/Cards/Group/GroupProfile/GroupProfileCard.tsx index 5c8757a4dd..89eba12b97 100644 --- a/plugins/org/src/components/Cards/Group/GroupProfile/GroupProfileCard.tsx +++ b/plugins/org/src/components/Cards/Group/GroupProfile/GroupProfileCard.tsx @@ -29,7 +29,6 @@ import { Link, } from '@backstage/core-components'; import Box from '@material-ui/core/Box'; -import Grid from '@material-ui/core/Grid'; import IconButton from '@material-ui/core/IconButton'; import List from '@material-ui/core/List'; import ListItem from '@material-ui/core/ListItem'; @@ -57,6 +56,20 @@ import { useEntityPermission } from '@backstage/plugin-catalog-react/alpha'; import { catalogEntityRefreshPermission } from '@backstage/plugin-catalog-common/alpha'; import { useTranslationRef } from '@backstage/frontend-plugin-api'; import { orgTranslationRef } from '../../../../translation'; +import { makeStyles } from '@material-ui/core/styles'; + +const useStyles = makeStyles(theme => ({ + container: { + display: 'flex', + flexWrap: 'wrap', + gap: theme.spacing(1), + padding: theme.spacing(1), + }, + list: { + padding: 0, + marginLeft: theme.spacing(0.5), + }, +})); const CardTitle = (props: { title: string }) => ( @@ -77,6 +90,7 @@ export const GroupProfileCard = (props: { catalogEntityRefreshPermission, ); const { t } = useTranslationRef(orgTranslationRef); + const classes = useStyles(); const refreshEntity = useCallback(async () => { await catalogApi.refreshEntity(stringifyEntityRef(group)); @@ -153,84 +167,76 @@ export const GroupProfileCard = (props: { } > - - - - - - + + + + + + + + + + + + {profile?.email && ( - - + + {profile.email}} + secondary={t('groupProfileCard.listItemTitle.email')} /> - {profile?.email && ( - - - - - - - {profile.email}} - secondary={t('groupProfileCard.listItemTitle.email')} - /> - - )} - - - - - - - - ) : ( - 'N/A' - ) - } - secondary={t('groupProfileCard.listItemTitle.parentGroup')} - /> - - - - - - - - - ) : ( - 'N/A' - ) - } - secondary={t('groupProfileCard.listItemTitle.childGroups')} - /> - - {props?.showLinks && } - - - + )} + + + + + + + + ) : ( + 'N/A' + ) + } + secondary={t('groupProfileCard.listItemTitle.parentGroup')} + /> + + + + + + + + + ) : ( + 'N/A' + ) + } + secondary={t('groupProfileCard.listItemTitle.childGroups')} + /> + + {props?.showLinks && } + + ); }; From d77fdc3608d6dc3200cc7a1f41b3af4993d47b09 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 6 Nov 2025 09:46:25 +0100 Subject: [PATCH 18/69] app-visualizer: icon type fix Signed-off-by: Patrik Oldsberg --- plugins/app-visualizer/src/plugin.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/app-visualizer/src/plugin.tsx b/plugins/app-visualizer/src/plugin.tsx index 3d30df16e6..c9c7857c77 100644 --- a/plugins/app-visualizer/src/plugin.tsx +++ b/plugins/app-visualizer/src/plugin.tsx @@ -38,7 +38,7 @@ const appVisualizerPage = PageBlueprint.make({ export const appVisualizerNavItem = NavItemBlueprint.make({ params: { title: 'Visualizer', - icon: VisualizerIcon, + icon: () => , routeRef: rootRouteRef, }, }); From a5c2fa22dab550987650b5523ee94a6c86a9bd5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Edeg=C3=A5rd?= Date: Thu, 6 Nov 2025 13:55:11 +0000 Subject: [PATCH 19/69] fix for adding origins only if channel is enabled. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Henrik Edegård --- plugins/notifications-backend/src/service/router.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/plugins/notifications-backend/src/service/router.ts b/plugins/notifications-backend/src/service/router.ts index 0e307fb4ea..9e410cb964 100644 --- a/plugins/notifications-backend/src/service/router.ts +++ b/plugins/notifications-backend/src/service/router.ts @@ -292,10 +292,14 @@ export async function createRouter( } // Add config default origins if not in user settings + // Only add origins if the channel is enabled (not explicitly disabled) const defaultChannelSettings = defaultNotificationSettings?.channels?.find( c => c.id.toLowerCase() === opts.channel.toLowerCase(), ); - if (defaultChannelSettings?.origins) { + if ( + defaultChannelSettings?.origins && + settings.channels[0].enabled !== false + ) { for (const defaultOrigin of defaultChannelSettings.origins) { if ( !settings.channels[0].origins.some( From 36d75826876d50bfb0cc2d7166efd24d9b9ed230 Mon Sep 17 00:00:00 2001 From: Eswaraiahsapram Date: Mon, 3 Nov 2025 16:21:27 +0530 Subject: [PATCH 20/69] fix: add missing i18n support for catalog react plugin Signed-off-by: Eswaraiahsapram --- .changeset/eleven-lights-taste.md | 5 ++ plugins/catalog-react/report-alpha.api.md | 25 +++++++++ .../CardActionComponents/EmailCardAction.tsx | 2 +- .../EntityCardActions.tsx | 2 +- .../InspectEntityDialog.tsx | 42 ++++++++------- .../components/AncestryPage.tsx | 14 ++--- .../components/ColocatedPage.tsx | 10 +++- .../components/OverviewPage.tsx | 28 +++++++--- .../MissingAnnotationEmptyState.tsx | 51 +++++++++++-------- plugins/catalog-react/src/translation.ts | 40 +++++++++++++++ 10 files changed, 160 insertions(+), 59 deletions(-) create mode 100644 .changeset/eleven-lights-taste.md diff --git a/.changeset/eleven-lights-taste.md b/.changeset/eleven-lights-taste.md new file mode 100644 index 0000000000..e575b909e6 --- /dev/null +++ b/.changeset/eleven-lights-taste.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-react': patch +--- + +Added missing i18n diff --git a/plugins/catalog-react/report-alpha.api.md b/plugins/catalog-react/report-alpha.api.md index cf28d80c11..ef101c9e1b 100644 --- a/plugins/catalog-react/report-alpha.api.md +++ b/plugins/catalog-react/report-alpha.api.md @@ -45,8 +45,10 @@ export const catalogReactTranslationRef: TranslationRef< readonly 'entityProcessingStatusPicker.title': 'Processing Status'; readonly 'entityTagPicker.title': 'Tags'; readonly 'entityPeekAheadPopover.title': 'Drill into the entity to see all of the tags.'; + readonly 'entityPeekAheadPopover.entityCardActionsAriaLabel': 'Show'; readonly 'entityPeekAheadPopover.entityCardActionsTitle': 'Show details'; readonly 'entityPeekAheadPopover.emailCardAction.title': 'Email {{email}}'; + readonly 'entityPeekAheadPopover.emailCardAction.ariaLabel': 'Email'; readonly 'entityPeekAheadPopover.emailCardAction.subTitle': 'mailto {{email}}'; readonly 'entitySearchBar.placeholder': 'Search'; readonly 'entityTypePicker.title': 'Type'; @@ -56,16 +58,34 @@ export const catalogReactTranslationRef: TranslationRef< readonly 'favoriteEntity.removeFromFavorites': 'Remove from favorites'; readonly 'inspectEntityDialog.title': 'Entity Inspector'; readonly 'inspectEntityDialog.closeButtonTitle': 'Close'; + readonly 'inspectEntityDialog.tabsAriaLabel': 'Inspector options'; readonly 'inspectEntityDialog.ancestryPage.title': 'Ancestry'; + readonly 'inspectEntityDialog.ancestryPage.descriptionPart1': 'This is the ancestry of entities above the current one - as in, the chain(s) of entities down to the current one, where'; + readonly 'inspectEntityDialog.ancestryPage.processorsLink': 'processors emitted'; + readonly 'inspectEntityDialog.ancestryPage.descriptionPart2': 'child entities that ultimately led to the current one existing. Note that this is a completely different mechanism from relations.'; readonly 'inspectEntityDialog.colocatedPage.title': 'Colocated'; readonly 'inspectEntityDialog.colocatedPage.description': 'These are the entities that are colocated with this entity - as in, they originated from the same data source (e.g. came from the same YAML file), or from the same origin (e.g. the originally registered URL).'; readonly 'inspectEntityDialog.colocatedPage.alertNoLocation': 'Entity had no location information.'; readonly 'inspectEntityDialog.colocatedPage.alertNoEntity': 'There were no other entities on this location.'; + readonly 'inspectEntityDialog.colocatedPage.locationHeader': 'At the same location'; + readonly 'inspectEntityDialog.colocatedPage.originHeader': 'At the same origin'; readonly 'inspectEntityDialog.jsonPage.title': 'Entity as JSON'; readonly 'inspectEntityDialog.jsonPage.description': 'This is the raw entity data as received from the catalog, on JSON form.'; readonly 'inspectEntityDialog.overviewPage.title': 'Overview'; + readonly 'inspectEntityDialog.overviewPage.labels': 'Labels'; + readonly 'inspectEntityDialog.overviewPage.annotations': 'Annotations'; + readonly 'inspectEntityDialog.overviewPage.tags': 'Tags'; + readonly 'inspectEntityDialog.overviewPage.relationTitle': 'Relations'; + readonly 'inspectEntityDialog.overviewPage.statusTitle': 'Status'; + readonly 'inspectEntityDialog.overviewPage.identityTitle': 'Identity'; + readonly 'inspectEntityDialog.overviewPage.metadataTitle': 'Metadata'; readonly 'inspectEntityDialog.yamlPage.title': 'Entity as YAML'; readonly 'inspectEntityDialog.yamlPage.description': 'This is the raw entity data as received from the catalog, on YAML form.'; + readonly 'inspectEntityDialog.tabNames.json': 'Raw JSON'; + readonly 'inspectEntityDialog.tabNames.yaml': 'Raw YAML'; + readonly 'inspectEntityDialog.tabNames.overview': 'Overview'; + readonly 'inspectEntityDialog.tabNames.ancestry': 'Ancestry'; + readonly 'inspectEntityDialog.tabNames.colocated': 'Colocated'; readonly 'unregisterEntityDialog.title': 'Are you sure you want to unregister this entity?'; readonly 'unregisterEntityDialog.cancelButtonTitle': 'Cancel'; readonly 'unregisterEntityDialog.deleteButtonTitle': 'Delete Entity'; @@ -98,6 +118,11 @@ export const catalogReactTranslationRef: TranslationRef< readonly 'entityTableColumnTitle.lifecycle': 'Lifecycle'; readonly 'entityTableColumnTitle.owner': 'Owner'; readonly 'entityTableColumnTitle.targets': 'Targets'; + readonly 'missingAnnotationEmptyState.title': 'Missing Annotation'; + readonly 'missingAnnotationEmptyState.readMore': 'Read more'; + readonly 'missingAnnotationEmptyState.annotationYaml': 'Add the annotation to your {{entityKind}} YAML as shown in the highlighted example below:'; + readonly 'missingAnnotationEmptyState.generateDescription.multiple': 'The annotations {{annotations}} are missing. You need to add the annotations to your {{entityKind}} if you want to enable this tool.'; + readonly 'missingAnnotationEmptyState.generateDescription.single': 'The annotation {{annotations}} is missing. You need to add the annotation to your {{entityKind}} if you want to enable this tool.'; } >; diff --git a/plugins/catalog-react/src/components/EntityPeekAheadPopover/CardActionComponents/EmailCardAction.tsx b/plugins/catalog-react/src/components/EntityPeekAheadPopover/CardActionComponents/EmailCardAction.tsx index 8c9c0cf2bf..24913bf5df 100644 --- a/plugins/catalog-react/src/components/EntityPeekAheadPopover/CardActionComponents/EmailCardAction.tsx +++ b/plugins/catalog-react/src/components/EntityPeekAheadPopover/CardActionComponents/EmailCardAction.tsx @@ -30,7 +30,7 @@ export const EmailCardAction = (props: { email: string }) => { return ( { return ( diff --git a/plugins/catalog-react/src/components/InspectEntityDialog/InspectEntityDialog.tsx b/plugins/catalog-react/src/components/InspectEntityDialog/InspectEntityDialog.tsx index 1d8a351701..8ba81dcb41 100644 --- a/plugins/catalog-react/src/components/InspectEntityDialog/InspectEntityDialog.tsx +++ b/plugins/catalog-react/src/components/InspectEntityDialog/InspectEntityDialog.tsx @@ -24,7 +24,7 @@ import DialogTitle from '@material-ui/core/DialogTitle'; import Tab from '@material-ui/core/Tab'; import Tabs from '@material-ui/core/Tabs'; import { makeStyles } from '@material-ui/core/styles'; -import { ComponentProps, useEffect, useState, ReactNode } from 'react'; +import { ComponentProps, useEffect, useState, ReactNode, useMemo } from 'react'; import { AncestryPage } from './components/AncestryPage'; import { ColocatedPage } from './components/ColocatedPage'; import { JsonPage } from './components/JsonPage'; @@ -85,18 +85,12 @@ function a11yProps(index: number) { }; } -const tabNames: Record< +type TabKey = 'overview' | 'ancestry' | 'colocated' | 'json' | 'yaml'; + +type TabNames = Record< NonNullable['initialTab']>, string -> = { - overview: 'Overview', - ancestry: 'Ancestry', - colocated: 'Colocated', - json: 'Raw JSON', - yaml: 'Raw YAML', -} as const; - -const tabs = Object.keys(tabNames) as Array; +>; /** * A dialog that lets users inspect the low level details of their entities. @@ -106,20 +100,33 @@ const tabs = Object.keys(tabNames) as Array; export function InspectEntityDialog(props: { open: boolean; entity: Entity; - initialTab?: 'overview' | 'ancestry' | 'colocated' | 'json' | 'yaml'; + initialTab?: TabKey; onClose: () => void; onSelect?: (tab: string) => void; }) { const classes = useStyles(); + const { t } = useTranslationRef(catalogReactTranslationRef); + + const tabNames: TabNames = useMemo( + () => ({ + overview: t('inspectEntityDialog.tabNames.overview'), + ancestry: t('inspectEntityDialog.tabNames.ancestry'), + colocated: t('inspectEntityDialog.tabNames.colocated'), + json: t('inspectEntityDialog.tabNames.json'), + yaml: t('inspectEntityDialog.tabNames.yaml'), + }), + [t], + ); + + const tabs = Object.keys(tabNames) as TabKey[]; const [activeTab, setActiveTab] = useState( getTabIndex(tabs, props.initialTab), ); - const { t } = useTranslationRef(catalogReactTranslationRef); useEffect(() => { getTabIndex(tabs, props.initialTab); - }, [props.open, props.initialTab]); + }, [props.open, props.initialTab, tabs]); if (!props.entity) { return null; @@ -147,7 +154,7 @@ export function InspectEntityDialog(props: { setActiveTab(tabIndex); props.onSelect?.(tabs[tabIndex]); }} - aria-label="Inspector options" + aria-label={t('inspectEntityDialog.tabsAriaLabel')} className={classes.tabs} > {tabs.map((tab, index) => ( @@ -181,9 +188,6 @@ export function InspectEntityDialog(props: { ); } -function getTabIndex( - allTabs: string[], - initialTab: keyof typeof tabNames | undefined, -) { +function getTabIndex(allTabs: string[], initialTab: TabKey | undefined) { return initialTab ? allTabs.indexOf(initialTab) : 0; } diff --git a/plugins/catalog-react/src/components/InspectEntityDialog/components/AncestryPage.tsx b/plugins/catalog-react/src/components/InspectEntityDialog/components/AncestryPage.tsx index c81009e3f5..771eb7b279 100644 --- a/plugins/catalog-react/src/components/InspectEntityDialog/components/AncestryPage.tsx +++ b/plugins/catalog-react/src/components/InspectEntityDialog/components/AncestryPage.tsx @@ -213,13 +213,13 @@ export function AncestryPage(props: { entity: Entity }) { {t('inspectEntityDialog.ancestryPage.title')} - This is the ancestry of entities above the current one - as in, the - chain(s) of entities down to the current one, where{' '} - - processors emitted - {' '} - child entities that ultimately led to the current one existing. Note - that this is a completely different mechanism from relations. + {t('inspectEntityDialog.ancestryPage.description', { + processorsLink: ( + + {t('inspectEntityDialog.ancestryPage.processorsLink')} + + ), + })} 0 && ( )} {atOrigin.length > 0 && ( )} diff --git a/plugins/catalog-react/src/components/InspectEntityDialog/components/OverviewPage.tsx b/plugins/catalog-react/src/components/InspectEntityDialog/components/OverviewPage.tsx index b9085ecb60..4feabf9c38 100644 --- a/plugins/catalog-react/src/components/InspectEntityDialog/components/OverviewPage.tsx +++ b/plugins/catalog-react/src/components/InspectEntityDialog/components/OverviewPage.tsx @@ -69,7 +69,7 @@ export function OverviewPage(props: { entity: AlphaEntity }) { {t('inspectEntityDialog.overviewPage.title')}
- + @@ -110,13 +110,13 @@ export function OverviewPage(props: { entity: AlphaEntity }) { - + {!!Object.keys(metadata.annotations || {}).length && ( - Annotations + {t('inspectEntityDialog.overviewPage.annotations')} } @@ -127,14 +127,28 @@ export function OverviewPage(props: { entity: AlphaEntity }) { )} {!!Object.keys(metadata.labels || {}).length && ( - Labels}> + + {t('inspectEntityDialog.overviewPage.labels')} + + } + > {Object.entries(metadata.labels!).map(entry => ( ))} )} {!!metadata.tags?.length && ( - Tags}> + + {t('inspectEntityDialog.overviewPage.tags')} + + } + > {metadata.tags.map((tag, index) => ( @@ -147,7 +161,7 @@ export function OverviewPage(props: { entity: AlphaEntity }) { {!!relations.length && ( {Object.entries(groupedRelations).map( @@ -172,7 +186,7 @@ export function OverviewPage(props: { entity: AlphaEntity }) { {!!status.items?.length && ( {status.items.map((item, index) => ( diff --git a/plugins/catalog-react/src/components/MissingAnnotationEmptyState/MissingAnnotationEmptyState.tsx b/plugins/catalog-react/src/components/MissingAnnotationEmptyState/MissingAnnotationEmptyState.tsx index 4472ecc8a2..cf6add2512 100644 --- a/plugins/catalog-react/src/components/MissingAnnotationEmptyState/MissingAnnotationEmptyState.tsx +++ b/plugins/catalog-react/src/components/MissingAnnotationEmptyState/MissingAnnotationEmptyState.tsx @@ -20,7 +20,12 @@ import { makeStyles } from '@material-ui/core/styles'; import Typography from '@material-ui/core/Typography'; import { CodeSnippet, Link, EmptyState } from '@backstage/core-components'; import { Entity } from '@backstage/catalog-model'; +import { + TranslationFunction, + useTranslationRef, +} from '@backstage/core-plugin-api/alpha'; import { useEntity } from '../../hooks'; +import { catalogReactTranslationRef } from '../../translation'; /** @public */ export type MissingAnnotationEmptyStateClassKey = 'code'; @@ -68,23 +73,24 @@ spec: }; } -function generateDescription(annotations: string[], entityKind = 'Component') { - const isSingular = annotations.length <= 1; - return ( - <> - The {isSingular ? 'annotation' : 'annotations'}{' '} - {annotations - .map(ann => {ann}) - .reduce((prev, curr) => ( - <> - {prev}, {curr} - - ))}{' '} - {isSingular ? 'is' : 'are'} missing. You need to add the{' '} - {isSingular ? 'annotation' : 'annotations'} to your {entityKind} if you - want to enable this tool. - - ); +function generateDescription( + annotations: string[], + entityKind = 'Component', + t: TranslationFunction, +) { + const annotationList = annotations + .map(ann => {ann}) + .reduce((prev, curr) => ( + <> + {prev}, {curr} + + )); + + return t('missingAnnotationEmptyState.generateDescription', { + count: annotations.length, + entityKind, + annotations: annotationList, + }); } /** @@ -95,6 +101,8 @@ export function MissingAnnotationEmptyState(props: { annotation: string | string[]; readMoreUrl?: string; }) { + const { t } = useTranslationRef(catalogReactTranslationRef); + let entity: Entity | undefined; try { const entityContext = useEntity(); @@ -115,13 +123,12 @@ export function MissingAnnotationEmptyState(props: { return ( - Add the annotation to your {entityKind} YAML as shown in the - highlighted example below: + {t('missingAnnotationEmptyState.annotationYaml', { entityKind })} } diff --git a/plugins/catalog-react/src/translation.ts b/plugins/catalog-react/src/translation.ts index 6e831886cd..4411f7e004 100644 --- a/plugins/catalog-react/src/translation.ts +++ b/plugins/catalog-react/src/translation.ts @@ -48,7 +48,9 @@ export const catalogReactTranslationRef = createTranslationRef({ emailCardAction: { title: 'Email {{email}}', subTitle: 'mailto {{email}}', + ariaLabel: 'Email', }, + entityCardActionsAriaLabel: 'Show', entityCardActionsTitle: 'Show details', }, entitySearchBar: { @@ -68,6 +70,9 @@ export const catalogReactTranslationRef = createTranslationRef({ closeButtonTitle: 'Close', ancestryPage: { title: 'Ancestry', + description: + 'This is the ancestry of entities above the current one - as in, the chain(s) of entities down to the current one, where {{processorsLink}} child entities that ultimately led to the current one existing. Note that this is a completely different mechanism from relations.', + processorsLink: 'processors emitted', }, colocatedPage: { title: 'Colocated', @@ -75,6 +80,8 @@ export const catalogReactTranslationRef = createTranslationRef({ 'These are the entities that are colocated with this entity - as in, they originated from the same data source (e.g. came from the same YAML file), or from the same origin (e.g. the originally registered URL).', alertNoLocation: 'Entity had no location information.', alertNoEntity: 'There were no other entities on this location.', + locationHeader: 'At the same location', + originHeader: 'At the same origin', }, jsonPage: { title: 'Entity as JSON', @@ -83,12 +90,35 @@ export const catalogReactTranslationRef = createTranslationRef({ }, overviewPage: { title: 'Overview', + relation: { + title: 'Relations', + }, + status: { + title: 'Status', + }, + identity: { + title: 'Identity', + }, + metadata: { + title: 'Metadata', + }, + annotations: 'Annotations', + labels: 'Labels', + tags: 'Tags', }, yamlPage: { title: 'Entity as YAML', description: 'This is the raw entity data as received from the catalog, on YAML form.', }, + tabNames: { + overview: 'Overview', + ancestry: 'Ancestry', + colocated: 'Colocated', + json: 'Raw JSON', + yaml: 'Raw YAML', + }, + tabsAriaLabel: 'Inspector options', }, unregisterEntityDialog: { title: 'Are you sure you want to unregister this entity?', @@ -138,5 +168,15 @@ export const catalogReactTranslationRef = createTranslationRef({ label: 'Label', domain: 'Domain', }, + missingAnnotationEmptyState: { + title: 'Missing Annotation', + readMore: 'Read more', + annotationYaml: + 'Add the annotation to your {{entityKind}} YAML as shown in the highlighted example below:', + generateDescription_one: + 'The annotation {{annotations}} is missing. You need to add the annotation to your {{entityKind}} if you want to enable this tool.', + generateDescription_other: + 'The annotations {{annotations}} are missing. You need to add the annotations to your {{entityKind}} if you want to enable this tool.', + }, }, }); From ef8864571e3987cd8825dc5e090b4eebd4ed0ecf Mon Sep 17 00:00:00 2001 From: Eswaraiahsapram Date: Wed, 5 Nov 2025 11:11:32 +0530 Subject: [PATCH 21/69] api report fix Signed-off-by: Eswaraiahsapram --- plugins/catalog-react/report-alpha.api.md | 29 +++++++++---------- .../InspectEntityDialog.tsx | 2 +- 2 files changed, 15 insertions(+), 16 deletions(-) diff --git a/plugins/catalog-react/report-alpha.api.md b/plugins/catalog-react/report-alpha.api.md index ef101c9e1b..a532a871cd 100644 --- a/plugins/catalog-react/report-alpha.api.md +++ b/plugins/catalog-react/report-alpha.api.md @@ -60,9 +60,8 @@ export const catalogReactTranslationRef: TranslationRef< readonly 'inspectEntityDialog.closeButtonTitle': 'Close'; readonly 'inspectEntityDialog.tabsAriaLabel': 'Inspector options'; readonly 'inspectEntityDialog.ancestryPage.title': 'Ancestry'; - readonly 'inspectEntityDialog.ancestryPage.descriptionPart1': 'This is the ancestry of entities above the current one - as in, the chain(s) of entities down to the current one, where'; + readonly 'inspectEntityDialog.ancestryPage.description': 'This is the ancestry of entities above the current one - as in, the chain(s) of entities down to the current one, where {{processorsLink}} child entities that ultimately led to the current one existing. Note that this is a completely different mechanism from relations.'; readonly 'inspectEntityDialog.ancestryPage.processorsLink': 'processors emitted'; - readonly 'inspectEntityDialog.ancestryPage.descriptionPart2': 'child entities that ultimately led to the current one existing. Note that this is a completely different mechanism from relations.'; readonly 'inspectEntityDialog.colocatedPage.title': 'Colocated'; readonly 'inspectEntityDialog.colocatedPage.description': 'These are the entities that are colocated with this entity - as in, they originated from the same data source (e.g. came from the same YAML file), or from the same origin (e.g. the originally registered URL).'; readonly 'inspectEntityDialog.colocatedPage.alertNoLocation': 'Entity had no location information.'; @@ -72,20 +71,20 @@ export const catalogReactTranslationRef: TranslationRef< readonly 'inspectEntityDialog.jsonPage.title': 'Entity as JSON'; readonly 'inspectEntityDialog.jsonPage.description': 'This is the raw entity data as received from the catalog, on JSON form.'; readonly 'inspectEntityDialog.overviewPage.title': 'Overview'; + readonly 'inspectEntityDialog.overviewPage.metadata.title': 'Metadata'; readonly 'inspectEntityDialog.overviewPage.labels': 'Labels'; + readonly 'inspectEntityDialog.overviewPage.status.title': 'Status'; readonly 'inspectEntityDialog.overviewPage.annotations': 'Annotations'; readonly 'inspectEntityDialog.overviewPage.tags': 'Tags'; - readonly 'inspectEntityDialog.overviewPage.relationTitle': 'Relations'; - readonly 'inspectEntityDialog.overviewPage.statusTitle': 'Status'; - readonly 'inspectEntityDialog.overviewPage.identityTitle': 'Identity'; - readonly 'inspectEntityDialog.overviewPage.metadataTitle': 'Metadata'; + readonly 'inspectEntityDialog.overviewPage.relation.title': 'Relations'; + readonly 'inspectEntityDialog.overviewPage.identity.title': 'Identity'; readonly 'inspectEntityDialog.yamlPage.title': 'Entity as YAML'; readonly 'inspectEntityDialog.yamlPage.description': 'This is the raw entity data as received from the catalog, on YAML form.'; readonly 'inspectEntityDialog.tabNames.json': 'Raw JSON'; - readonly 'inspectEntityDialog.tabNames.yaml': 'Raw YAML'; readonly 'inspectEntityDialog.tabNames.overview': 'Overview'; readonly 'inspectEntityDialog.tabNames.ancestry': 'Ancestry'; readonly 'inspectEntityDialog.tabNames.colocated': 'Colocated'; + readonly 'inspectEntityDialog.tabNames.yaml': 'Raw YAML'; readonly 'unregisterEntityDialog.title': 'Are you sure you want to unregister this entity?'; readonly 'unregisterEntityDialog.cancelButtonTitle': 'Cancel'; readonly 'unregisterEntityDialog.deleteButtonTitle': 'Delete Entity'; @@ -112,17 +111,17 @@ export const catalogReactTranslationRef: TranslationRef< readonly 'entityTableColumnTitle.title': 'Title'; readonly 'entityTableColumnTitle.description': 'Description'; readonly 'entityTableColumnTitle.domain': 'Domain'; - readonly 'entityTableColumnTitle.system': 'System'; - readonly 'entityTableColumnTitle.tags': 'Tags'; readonly 'entityTableColumnTitle.namespace': 'Namespace'; readonly 'entityTableColumnTitle.lifecycle': 'Lifecycle'; readonly 'entityTableColumnTitle.owner': 'Owner'; + readonly 'entityTableColumnTitle.system': 'System'; readonly 'entityTableColumnTitle.targets': 'Targets'; + readonly 'entityTableColumnTitle.tags': 'Tags'; readonly 'missingAnnotationEmptyState.title': 'Missing Annotation'; readonly 'missingAnnotationEmptyState.readMore': 'Read more'; readonly 'missingAnnotationEmptyState.annotationYaml': 'Add the annotation to your {{entityKind}} YAML as shown in the highlighted example below:'; - readonly 'missingAnnotationEmptyState.generateDescription.multiple': 'The annotations {{annotations}} are missing. You need to add the annotations to your {{entityKind}} if you want to enable this tool.'; - readonly 'missingAnnotationEmptyState.generateDescription.single': 'The annotation {{annotations}} is missing. You need to add the annotation to your {{entityKind}} if you want to enable this tool.'; + readonly 'missingAnnotationEmptyState.generateDescription_one': 'The annotation {{annotations}} is missing. You need to add the annotation to your {{entityKind}} if you want to enable this tool.'; + readonly 'missingAnnotationEmptyState.generateDescription_other': 'The annotations {{annotations}} are missing. You need to add the annotations to your {{entityKind}} if you want to enable this tool.'; } >; @@ -557,17 +556,17 @@ export type EntityPredicateValue = export const EntityTableColumnTitle: ({ translationKey, }: EntityTableColumnTitleProps) => - | 'Title' - | 'System' | 'Domain' + | 'System' + | 'Name' + | 'Description' | 'Lifecycle' | 'Namespace' | 'Owner' | 'Tags' | 'Type' - | 'Name' - | 'Description' | 'Targets' + | 'Title' | 'Label'; // @alpha (undocumented) diff --git a/plugins/catalog-react/src/components/InspectEntityDialog/InspectEntityDialog.tsx b/plugins/catalog-react/src/components/InspectEntityDialog/InspectEntityDialog.tsx index 8ba81dcb41..2b047bf762 100644 --- a/plugins/catalog-react/src/components/InspectEntityDialog/InspectEntityDialog.tsx +++ b/plugins/catalog-react/src/components/InspectEntityDialog/InspectEntityDialog.tsx @@ -100,7 +100,7 @@ type TabNames = Record< export function InspectEntityDialog(props: { open: boolean; entity: Entity; - initialTab?: TabKey; + initialTab?: 'overview' | 'ancestry' | 'colocated' | 'json' | 'yaml'; onClose: () => void; onSelect?: (tab: string) => void; }) { From f62a9324ca932b0e346628633a6b23356fab7555 Mon Sep 17 00:00:00 2001 From: Eswaraiahsapram Date: Thu, 6 Nov 2025 10:26:04 +0530 Subject: [PATCH 22/69] api report fix Signed-off-by: Eswaraiahsapram --- plugins/catalog-react/report-alpha.api.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/plugins/catalog-react/report-alpha.api.md b/plugins/catalog-react/report-alpha.api.md index a532a871cd..fdb0bf38b1 100644 --- a/plugins/catalog-react/report-alpha.api.md +++ b/plugins/catalog-react/report-alpha.api.md @@ -76,15 +76,15 @@ export const catalogReactTranslationRef: TranslationRef< readonly 'inspectEntityDialog.overviewPage.status.title': 'Status'; readonly 'inspectEntityDialog.overviewPage.annotations': 'Annotations'; readonly 'inspectEntityDialog.overviewPage.tags': 'Tags'; - readonly 'inspectEntityDialog.overviewPage.relation.title': 'Relations'; readonly 'inspectEntityDialog.overviewPage.identity.title': 'Identity'; + readonly 'inspectEntityDialog.overviewPage.relation.title': 'Relations'; readonly 'inspectEntityDialog.yamlPage.title': 'Entity as YAML'; readonly 'inspectEntityDialog.yamlPage.description': 'This is the raw entity data as received from the catalog, on YAML form.'; readonly 'inspectEntityDialog.tabNames.json': 'Raw JSON'; readonly 'inspectEntityDialog.tabNames.overview': 'Overview'; + readonly 'inspectEntityDialog.tabNames.yaml': 'Raw YAML'; readonly 'inspectEntityDialog.tabNames.ancestry': 'Ancestry'; readonly 'inspectEntityDialog.tabNames.colocated': 'Colocated'; - readonly 'inspectEntityDialog.tabNames.yaml': 'Raw YAML'; readonly 'unregisterEntityDialog.title': 'Are you sure you want to unregister this entity?'; readonly 'unregisterEntityDialog.cancelButtonTitle': 'Cancel'; readonly 'unregisterEntityDialog.deleteButtonTitle': 'Delete Entity'; @@ -111,10 +111,10 @@ export const catalogReactTranslationRef: TranslationRef< readonly 'entityTableColumnTitle.title': 'Title'; readonly 'entityTableColumnTitle.description': 'Description'; readonly 'entityTableColumnTitle.domain': 'Domain'; + readonly 'entityTableColumnTitle.system': 'System'; readonly 'entityTableColumnTitle.namespace': 'Namespace'; readonly 'entityTableColumnTitle.lifecycle': 'Lifecycle'; readonly 'entityTableColumnTitle.owner': 'Owner'; - readonly 'entityTableColumnTitle.system': 'System'; readonly 'entityTableColumnTitle.targets': 'Targets'; readonly 'entityTableColumnTitle.tags': 'Tags'; readonly 'missingAnnotationEmptyState.title': 'Missing Annotation'; @@ -556,15 +556,15 @@ export type EntityPredicateValue = export const EntityTableColumnTitle: ({ translationKey, }: EntityTableColumnTitleProps) => - | 'Domain' | 'System' - | 'Name' - | 'Description' + | 'Domain' | 'Lifecycle' | 'Namespace' | 'Owner' | 'Tags' | 'Type' + | 'Name' + | 'Description' | 'Targets' | 'Title' | 'Label'; From ee6a988ce049ba31c98e6e53f97059d9bbe61a11 Mon Sep 17 00:00:00 2001 From: Eswaraiahsapram Date: Fri, 7 Nov 2025 13:35:01 +0530 Subject: [PATCH 23/69] api report fix Signed-off-by: Eswaraiahsapram --- plugins/catalog-react/report-alpha.api.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/plugins/catalog-react/report-alpha.api.md b/plugins/catalog-react/report-alpha.api.md index fdb0bf38b1..4b4a0a9a18 100644 --- a/plugins/catalog-react/report-alpha.api.md +++ b/plugins/catalog-react/report-alpha.api.md @@ -74,15 +74,15 @@ export const catalogReactTranslationRef: TranslationRef< readonly 'inspectEntityDialog.overviewPage.metadata.title': 'Metadata'; readonly 'inspectEntityDialog.overviewPage.labels': 'Labels'; readonly 'inspectEntityDialog.overviewPage.status.title': 'Status'; + readonly 'inspectEntityDialog.overviewPage.identity.title': 'Identity'; readonly 'inspectEntityDialog.overviewPage.annotations': 'Annotations'; readonly 'inspectEntityDialog.overviewPage.tags': 'Tags'; - readonly 'inspectEntityDialog.overviewPage.identity.title': 'Identity'; readonly 'inspectEntityDialog.overviewPage.relation.title': 'Relations'; readonly 'inspectEntityDialog.yamlPage.title': 'Entity as YAML'; readonly 'inspectEntityDialog.yamlPage.description': 'This is the raw entity data as received from the catalog, on YAML form.'; readonly 'inspectEntityDialog.tabNames.json': 'Raw JSON'; - readonly 'inspectEntityDialog.tabNames.overview': 'Overview'; readonly 'inspectEntityDialog.tabNames.yaml': 'Raw YAML'; + readonly 'inspectEntityDialog.tabNames.overview': 'Overview'; readonly 'inspectEntityDialog.tabNames.ancestry': 'Ancestry'; readonly 'inspectEntityDialog.tabNames.colocated': 'Colocated'; readonly 'unregisterEntityDialog.title': 'Are you sure you want to unregister this entity?'; @@ -112,11 +112,11 @@ export const catalogReactTranslationRef: TranslationRef< readonly 'entityTableColumnTitle.description': 'Description'; readonly 'entityTableColumnTitle.domain': 'Domain'; readonly 'entityTableColumnTitle.system': 'System'; + readonly 'entityTableColumnTitle.tags': 'Tags'; readonly 'entityTableColumnTitle.namespace': 'Namespace'; readonly 'entityTableColumnTitle.lifecycle': 'Lifecycle'; readonly 'entityTableColumnTitle.owner': 'Owner'; readonly 'entityTableColumnTitle.targets': 'Targets'; - readonly 'entityTableColumnTitle.tags': 'Tags'; readonly 'missingAnnotationEmptyState.title': 'Missing Annotation'; readonly 'missingAnnotationEmptyState.readMore': 'Read more'; readonly 'missingAnnotationEmptyState.annotationYaml': 'Add the annotation to your {{entityKind}} YAML as shown in the highlighted example below:'; @@ -556,6 +556,7 @@ export type EntityPredicateValue = export const EntityTableColumnTitle: ({ translationKey, }: EntityTableColumnTitleProps) => + | 'Title' | 'System' | 'Domain' | 'Lifecycle' @@ -566,7 +567,6 @@ export const EntityTableColumnTitle: ({ | 'Name' | 'Description' | 'Targets' - | 'Title' | 'Label'; // @alpha (undocumented) From f0c4ad72923e09bd4ac009c3733d85eb19c137c4 Mon Sep 17 00:00:00 2001 From: Ayush More Date: Wed, 23 Jul 2025 18:51:54 +0530 Subject: [PATCH 24/69] Update backend plugin tutorial to refer to /todos endpoint instead of /health This PR updates the backend plugin tutorial documentation to match the current plugin template. Previously, the tutorial referenced a /health endpoint, which does not exist in the generated backend plugin. Signed-off-by: Ayush More --- docs/plugins/backend-plugin.md | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/docs/plugins/backend-plugin.md b/docs/plugins/backend-plugin.md index bfd210aeb9..7dbc6cdc54 100644 --- a/docs/plugins/backend-plugin.md +++ b/docs/plugins/backend-plugin.md @@ -48,11 +48,18 @@ This will think for a bit, and then say `Listening on :7007`. In a different terminal window, now run ```sh -curl localhost:7007/api/carmen/health +curl localhost:7007/api/carmen/todos ``` -This should return `{"status":"ok"}`. Success! Press `Ctrl + c` to stop it -again. +You should see the following response: +```sh +{ +"items": [] +} +``` +:::note Note: The route shown here matches the default in the current backend plugin template. If you want a `/health` endpoint for health checks, you can add it to your router yourself. + +::: ## Developing your Backend Plugin From be76b403d8f09076f3e088c1970f796271bc4c56 Mon Sep 17 00:00:00 2001 From: Ayush More Date: Wed, 23 Jul 2025 22:49:50 +0530 Subject: [PATCH 25/69] Json fixxxxx Signed-off-by: Ayush More --- docs/plugins/backend-plugin.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/plugins/backend-plugin.md b/docs/plugins/backend-plugin.md index 7dbc6cdc54..9b8b63fc89 100644 --- a/docs/plugins/backend-plugin.md +++ b/docs/plugins/backend-plugin.md @@ -52,11 +52,13 @@ curl localhost:7007/api/carmen/todos ``` You should see the following response: -```sh + +```json { -"items": [] -} + "items": [] +} ``` + :::note Note: The route shown here matches the default in the current backend plugin template. If you want a `/health` endpoint for health checks, you can add it to your router yourself. ::: From 816af0fa398d2aca839f49bac68e180f9b33a9f7 Mon Sep 17 00:00:00 2001 From: Johan Persson Date: Tue, 4 Nov 2025 15:04:46 +0100 Subject: [PATCH 26/69] feat(ui): add searchable and multiple selection support to Select component Enhanced the Select component with search filtering and multi-selection capabilities through new searchable, selectionMode, and searchPlaceholder props. Breaking changes: - The SelectProps interface now accepts a generic type parameter for selection mode Implementation details: - Created SelectTrigger, SelectContent, and SelectListBox components for modular composition - Integrated react-aria's Autocomplete and SearchField for search functionality - Added support for multiple selection mode - Added "No results found" empty state when search returns no matches - Improved CSS organization and updated visual styling for better consistency - Exported Option type as public API for type safety - Updated API reports and added comprehensive Storybook stories - Added documentation with examples for searchable and multiple selection modes Migration guide: If using SelectProps type directly, update the type parameter: ```diff - SelectProps + SelectProps<'single' | 'multiple'> ``` Component usage remains backward compatible - existing Select implementations require no changes. Signed-off-by: Johan Persson --- .changeset/wicked-cycles-enter.md | 9 + docs-ui/src/content/select.mdx | 39 ++++ docs-ui/src/content/select.props.ts | 84 +++++++-- packages/ui/report.api.md | 32 ++-- .../src/components/Select/Select.module.css | 166 +++++++++++++----- .../src/components/Select/Select.stories.tsx | 64 +++++++ packages/ui/src/components/Select/Select.tsx | 68 ++----- .../src/components/Select/SelectContent.tsx | 74 ++++++++ .../src/components/Select/SelectListBox.tsx | 71 ++++++++ .../src/components/Select/SelectTrigger.tsx | 42 +++++ packages/ui/src/components/Select/types.ts | 33 +++- packages/ui/src/utils/componentDefinitions.ts | 6 +- 12 files changed, 556 insertions(+), 132 deletions(-) create mode 100644 .changeset/wicked-cycles-enter.md create mode 100644 packages/ui/src/components/Select/SelectContent.tsx create mode 100644 packages/ui/src/components/Select/SelectListBox.tsx create mode 100644 packages/ui/src/components/Select/SelectTrigger.tsx diff --git a/.changeset/wicked-cycles-enter.md b/.changeset/wicked-cycles-enter.md new file mode 100644 index 0000000000..cea5cff371 --- /dev/null +++ b/.changeset/wicked-cycles-enter.md @@ -0,0 +1,9 @@ +--- +'@backstage/ui': minor +--- + +**BREAKING**: The `SelectProps` interface now accepts a generic type parameter for selection mode. + +Added searchable and multiple selection support to Select component. The component now accepts `searchable`, `selectionMode`, and `searchPlaceholder` props to enable filtering and multi-selection modes. + +Migration: If you're using `SelectProps` type directly, update from `SelectProps` to `SelectProps<'single' | 'multiple'>`. Component usage remains backward compatible. diff --git a/docs-ui/src/content/select.mdx b/docs-ui/src/content/select.mdx index 09a852e871..eb53e9ea35 100644 --- a/docs-ui/src/content/select.mdx +++ b/docs-ui/src/content/select.mdx @@ -11,6 +11,9 @@ import { selectDisabledSnippet, selectResponsiveSnippet, selectIconSnippet, + selectSearchableSnippet, + selectMultipleSnippet, + selectSearchableMultipleSnippet, } from './select.props'; import { PageTitle } from '@/components/PageTitle'; import { Theming } from '@/components/Theming'; @@ -87,6 +90,42 @@ Here's a view when the select is disabled. code={selectDisabledSnippet} /> +### Searchable + +Here's a view when the select has searchable filtering. + +} + code={selectSearchableSnippet} +/> + +### Multiple Selection + +Here's a view when the select allows multiple selections. + +} + code={selectMultipleSnippet} +/> + +### Searchable with Multiple Selection + +Here's a view when the select combines search and multiple selection. + +} + code={selectSearchableMultipleSnippet} +/> + ### Responsive Here's a view when the select is responsive. diff --git a/docs-ui/src/content/select.props.ts b/docs-ui/src/content/select.props.ts index a93f448bd8..05f0d895c9 100644 --- a/docs-ui/src/content/select.props.ts +++ b/docs-ui/src/content/select.props.ts @@ -23,6 +23,12 @@ export const selectPropDefs: Record = { values: ['Array<{ value: string, label: string }>'], required: true, }, + selectionMode: { + type: 'enum', + values: ['single', 'multiple'], + default: 'single', + responsive: false, + }, placeholder: { type: 'string', default: 'Select an item', @@ -34,17 +40,23 @@ export const selectPropDefs: Record = { responsive: false, }, value: { - type: 'string', + type: 'enum', + values: ['string', 'string[]'], responsive: false, + description: + 'Selected value (controlled). String for single selection, array for multiple.', }, defaultValue: { - type: 'string', + type: 'enum', + values: ['string', 'string[]'], responsive: false, + description: + 'Initial value (uncontrolled). String for single selection, array for multiple.', }, size: { type: 'enum', values: ['small', 'medium'], - default: 'medium', + default: 'small', responsive: true, }, isOpen: { @@ -57,7 +69,7 @@ export const selectPropDefs: Record = { }, disabledKeys: { type: 'enum', - values: ['string[]'], + values: ['Iterable'], responsive: false, }, isDisabled: { @@ -72,14 +84,6 @@ export const selectPropDefs: Record = { type: 'boolean', responsive: false, }, - selectedKey: { - type: 'string', - responsive: false, - }, - defaultSelectedKey: { - type: 'string', - responsive: false, - }, onOpenChange: { type: 'enum', values: ['(isOpen: boolean) => void'], @@ -87,7 +91,19 @@ export const selectPropDefs: Record = { }, onSelectionChange: { type: 'enum', - values: ['(key: Key | null) => void'], + values: ['(key: Key | null) => void', '(keys: Selection) => void'], + responsive: false, + description: + 'Handler called when selection changes. Single mode: receives Key | null. Multiple mode: receives Selection.', + }, + searchable: { + type: 'boolean', + default: 'false', + responsive: false, + }, + searchPlaceholder: { + type: 'string', + default: 'Search...', responsive: false, }, ...classNamePropDefs, @@ -151,3 +167,45 @@ export const selectResponsiveSnippet = ``; + +export const selectMultipleSnippet = ``; diff --git a/packages/ui/report.api.md b/packages/ui/report.api.md index c1e1a309cc..9636b5361c 100644 --- a/packages/ui/report.api.md +++ b/packages/ui/report.api.md @@ -664,12 +664,16 @@ export const componentDefinitions: { readonly root: 'bui-Select'; readonly popover: 'bui-SelectPopover'; readonly trigger: 'bui-SelectTrigger'; + readonly chevron: 'bui-SelectTriggerChevron'; readonly value: 'bui-SelectValue'; - readonly icon: 'bui-SelectIcon'; readonly list: 'bui-SelectList'; readonly item: 'bui-SelectItem'; readonly itemIndicator: 'bui-SelectItemIndicator'; readonly itemLabel: 'bui-SelectItemLabel'; + readonly searchWrapper: 'bui-SelectSearchWrapper'; + readonly search: 'bui-SelectSearch'; + readonly searchClear: 'bui-SelectSearchClear'; + readonly noResults: 'bui-SelectNoResults'; }; readonly dataAttributes: { readonly size: readonly ['small', 'medium']; @@ -1170,6 +1174,14 @@ export const MenuTrigger: (props: MenuTriggerProps) => JSX_2.Element; // @public (undocumented) export interface MenuTriggerProps extends MenuTriggerProps_2 {} +// @public (undocumented) +type Option_2 = { + value: string; + label: string; + disabled?: boolean; +}; +export { Option_2 as Option }; + // @public (undocumented) export const Radio: ForwardRefExoticComponent< RadioProps & RefAttributes @@ -1214,22 +1226,18 @@ export interface SearchFieldProps // @public (undocumented) export const Select: ForwardRefExoticComponent< - SelectProps & RefAttributes + SelectProps<'multiple' | 'single'> & RefAttributes >; // @public (undocumented) -export interface SelectProps - extends SelectProps_2<{ - name: string; - value: string; - }>, +export interface SelectProps + extends SelectProps_2, Omit { icon?: ReactNode; - options?: Array<{ - value: string; - label: string; - disabled?: boolean; - }>; + options?: Array; + searchable?: boolean; + searchPlaceholder?: string; + selectionMode?: T; size?: 'small' | 'medium' | Partial>; } diff --git a/packages/ui/src/components/Select/Select.module.css b/packages/ui/src/components/Select/Select.module.css index d4e201ef3a..a2eb41040e 100644 --- a/packages/ui/src/components/Select/Select.module.css +++ b/packages/ui/src/components/Select/Select.module.css @@ -17,6 +17,17 @@ @layer tokens, base, components, utilities; @layer components { + .bui-Select, + .bui-SelectPopover { + &[data-size='small'] { + --select-item-height: 2rem; + } + + &[data-size='medium'] { + --select-item-height: 2.5rem; + } + } + .bui-SelectPopover { min-width: var(--trigger-width); } @@ -32,30 +43,29 @@ cursor: pointer; gap: var(--bui-space-2); width: 100%; + height: var(--select-item-height); + + .bui-Select[data-size='small'] & { + padding-inline: var(--bui-space-3) 0; + } + + .bui-Select[data-size='medium'] & { + padding-inline: var(--bui-space-4) 0; + } & svg { flex-shrink: 0; color: var(--bui-fg-secondary); - } - &[data-size='small'] { - height: 2rem; - padding-inline: var(--bui-space-3); - } + .bui-Select[data-size='small'] & { + width: 1rem; + height: 1rem; + } - &[data-size='medium'] { - height: 3rem; - padding-inline: var(--bui-space-4); - } - - &[data-size='small'] svg { - width: 1rem; - height: 1rem; - } - - &[data-size='medium'] svg { - width: 1.25rem; - height: 1.25rem; + .bui-Select[data-size='medium'] & { + width: 1.25rem; + height: 1.25rem; + } } &::placeholder { @@ -63,7 +73,7 @@ } &:hover { - transition: border-color 0.2s ease-in-out, outline-color 0.2s ease-in-out; + transition: border-color 0.2s ease-in-out; border-color: var(--bui-border-hover); } @@ -72,16 +82,13 @@ outline: 0; } - .bui-Select[data-invalid] &, - &[data-invalid] { + .bui-Select[data-invalid] & { border-color: var(--bui-fg-danger); - } - &[data-invalid]:hover { - border-width: 2px; - } - &[data-invalid]:focus-visible { - border-width: 2px; + &:focus-visible, + &:hover { + outline: 1px solid var(--bui-fg-danger); + } } &[disabled] { @@ -89,14 +96,15 @@ border-color: var(--bui-border-disabled); color: var(--bui-fg-disabled); } + } - &[disabled] .bui-SelectValue { - color: var(--bui-fg-disabled); - } - - &[data-popup-open] .bui-SelectIcon { - transform: rotate(180deg); - } + .bui-SelectTriggerChevron { + display: grid; + place-content: center; + width: var(--select-item-height); + height: var(--select-item-height); + flex-shrink: 0; + flex-grow: 0; } .bui-SelectValue { @@ -128,37 +136,32 @@ } .bui-SelectItem { + box-sizing: border-box; position: relative; width: var(--anchor-width); display: grid; grid-template-areas: 'icon text'; grid-template-columns: 1rem 1fr; align-items: center; - padding-block: var(--bui-space-2); + min-height: var(--select-item-height); + padding-block: var(--bui-space-1); padding-left: var(--bui-space-3); padding-right: var(--bui-space-4); color: var(--bui-fg-primary); - border-radius: var(--bui-radius-3); cursor: pointer; user-select: none; font-size: var(--bui-font-size-3); - gap: var(--bui-space-1); + gap: var(--bui-space-2); outline: none; - &[data-focused] { - z-index: 0; - position: relative; - color: var(--bui-fg-primary); - } - &[data-focused]::before { content: ''; - z-index: -1; position: absolute; inset-block: 0; - inset-inline: 0.25rem; - border-radius: 0.25rem; - background-color: var(--bui-bg-tint-hover); + inset-inline: var(--bui-space-1); + border-radius: var(--bui-radius-2); + background: var(--bui-bg-surface-2); + z-index: -1; } &[data-disabled] { @@ -184,4 +187,73 @@ flex: 1; grid-area: text; } + + .bui-SelectSearchWrapper { + flex-shrink: 0; + margin-bottom: var(--bui-space-1); + display: flex; + align-items: center; + padding-inline: var(--bui-space-3) 0; + border-bottom: 1px solid var(--bui-border); + } + + .bui-SelectSearch { + border: none; + background-color: transparent; + padding: 0; + color: var(--bui-fg-primary); + flex: 1; + outline: none; + font-size: var(--bui-font-size-3); + font-family: var(--bui-font-regular); + height: var(--select-item-height); + line-height: var(--select-item-height); + + &::placeholder { + color: var(--bui-fg-secondary); + } + + /* Hide native browser clear button */ + &::-webkit-search-cancel-button, + &::-webkit-search-decoration { + -webkit-appearance: none; + } + } + + .bui-SelectSearchClear { + flex: 0 0 auto; + display: grid; + place-content: center; + background-color: transparent; + border: none; + padding: 0; + margin: 0; + cursor: pointer; + color: var(--bui-fg-secondary); + transition: color 0.2s ease-in-out; + width: var(--select-item-height); + height: var(--select-item-height); + + input:placeholder-shown + & { + display: none; + } + + &:hover { + color: var(--bui-fg-primary); + } + + & svg { + width: 1rem; + height: 1rem; + } + } + + .bui-SelectNoResults { + padding-inline: var(--bui-space-3); + padding-block: var(--bui-space-2); + color: var(--bui-fg-secondary); + font-size: var(--bui-font-size-3); + font-family: var(--bui-font-regular); + font-weight: var(--bui-font-weight-regular); + } } diff --git a/packages/ui/src/components/Select/Select.stories.tsx b/packages/ui/src/components/Select/Select.stories.tsx index 6e96e37f08..18263fe710 100644 --- a/packages/ui/src/components/Select/Select.stories.tsx +++ b/packages/ui/src/components/Select/Select.stories.tsx @@ -22,6 +22,9 @@ import { RiCloudLine } from '@remixicon/react'; const meta = { title: 'Backstage UI/Select', component: Select, + args: { + style: { width: 300 }, + }, } satisfies Meta; export default meta; @@ -34,6 +37,35 @@ const fontOptions = [ { value: 'cursive', label: 'Cursive' }, ]; +const countries = [ + { value: 'us', label: 'United States' }, + { value: 'ca', label: 'Canada' }, + { value: 'mx', label: 'Mexico' }, + { value: 'uk', label: 'United Kingdom' }, + { value: 'fr', label: 'France' }, + { value: 'de', label: 'Germany' }, + { value: 'it', label: 'Italy' }, + { value: 'es', label: 'Spain' }, + { value: 'jp', label: 'Japan' }, + { value: 'cn', label: 'China' }, + { value: 'in', label: 'India' }, + { value: 'br', label: 'Brazil' }, + { value: 'au', label: 'Australia' }, +]; + +const skills = [ + { value: 'react', label: 'React' }, + { value: 'typescript', label: 'TypeScript' }, + { value: 'javascript', label: 'JavaScript' }, + { value: 'python', label: 'Python' }, + { value: 'java', label: 'Java' }, + { value: 'csharp', label: 'C#' }, + { value: 'go', label: 'Go' }, + { value: 'rust', label: 'Rust' }, + { value: 'kotlin', label: 'Kotlin' }, + { value: 'swift', label: 'Swift' }, +]; + export const Default: Story = { args: { options: fontOptions, @@ -41,6 +73,38 @@ export const Default: Story = { }, }; +export const Searchable: Story = { + args: { + label: 'Country', + searchable: true, + searchPlaceholder: 'Search countries...', + options: countries, + }, +}; + +export const MultipleSelection: Story = { + args: { + label: 'Select multiple options', + selectionMode: 'multiple', + options: [ + { value: 'option1', label: 'Option 1' }, + { value: 'option2', label: 'Option 2' }, + { value: 'option3', label: 'Option 3' }, + { value: 'option4', label: 'Option 4' }, + ], + }, +}; + +export const SearchableMultiple: Story = { + args: { + label: 'Skills', + searchable: true, + selectionMode: 'multiple', + searchPlaceholder: 'Filter skills...', + options: skills, + }, +}; + export const Preview: Story = { args: { label: 'Font Family', diff --git a/packages/ui/src/components/Select/Select.tsx b/packages/ui/src/components/Select/Select.tsx index b1fec7dbe7..0b133d07c5 100644 --- a/packages/ui/src/components/Select/Select.tsx +++ b/packages/ui/src/components/Select/Select.tsx @@ -15,15 +15,7 @@ */ import { forwardRef, useEffect } from 'react'; -import { - Select as AriaSelect, - SelectValue, - Button, - Popover, - ListBox, - ListBoxItem, - Text, -} from 'react-aria-components'; +import { Select as AriaSelect, Popover } from 'react-aria-components'; import clsx from 'clsx'; import { SelectProps } from './types'; import { useStyles } from '../../hooks/useStyles'; @@ -31,10 +23,14 @@ import { FieldLabel } from '../FieldLabel'; import { FieldError } from '../FieldError'; import styles from './Select.module.css'; import stylesPopover from '../Popover/Popover.module.css'; -import { RiArrowDownSLine, RiCheckLine } from '@remixicon/react'; +import { SelectTrigger } from './SelectTrigger'; +import { SelectContent } from './SelectContent'; /** @public */ -export const Select = forwardRef((props, ref) => { +export const Select = forwardRef< + HTMLDivElement, + SelectProps<'single' | 'multiple'> +>((props, ref) => { const { classNames: popoverClassNames } = useStyles('Popover'); const { classNames, dataAttributes, cleanedProps } = useStyles('Select', { size: 'small', @@ -47,14 +43,13 @@ export const Select = forwardRef((props, ref) => { label, description, options, - placeholder, - size, icon, + searchable, + searchPlaceholder, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, isRequired, secondaryLabel, - style, ...rest } = cleanedProps; @@ -66,7 +61,6 @@ export const Select = forwardRef((props, ref) => { } }, [label, ariaLabel, ariaLabelledBy]); - // If a secondary label is provided, use it. Otherwise, use 'Required' if the field is required. const secondaryLabelText = secondaryLabel || (isRequired ? 'Required' : null); return ( @@ -83,16 +77,7 @@ export const Select = forwardRef((props, ref) => { secondaryLabel={secondaryLabelText} description={description} /> - + ((props, ref) => { classNames.popover, styles[classNames.popover], )} + {...dataAttributes} > - - {options?.map(option => ( - -
- -
- - {option.label} - -
- ))} -
+
); diff --git a/packages/ui/src/components/Select/SelectContent.tsx b/packages/ui/src/components/Select/SelectContent.tsx new file mode 100644 index 0000000000..42070321a3 --- /dev/null +++ b/packages/ui/src/components/Select/SelectContent.tsx @@ -0,0 +1,74 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + Input, + SearchField, + Autocomplete, + Button, +} from 'react-aria-components'; +import { useFilter } from 'react-aria'; +import { RiCloseCircleLine } from '@remixicon/react'; +import clsx from 'clsx'; +import { useStyles } from '../../hooks/useStyles'; +import { SelectListBox } from './SelectListBox'; +import styles from './Select.module.css'; +import type { Option } from './types'; + +interface SelectContentProps { + searchable?: boolean; + searchPlaceholder?: string; + options?: Array