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 = () => {