feat: add optional enrichVisit function onto VisitListener to add fields to customize chips

Signed-off-by: Stephanie Swaney <stephanie.swaney.ddk6@statefarm.com>

feat: add VisitInput which has visit before auto saved fields, used to enrich in consuming app

Signed-off-by: Stephanie Swaney <stephanie.swaney.ddk6@statefarm.com>

chore: Rename ItemCategoryContext to VisitDisplayContext

fix: rename the remainder to VisitDisplayContext from ItemCategoryContext

Signed-off-by: Stephanie Swaney <stephanie.swaney.ddk6@statefarm.com>

test: Add tests for VisitListener component
This commit is contained in:
Stephanie Swaney
2025-08-07 16:02:10 -05:00
parent e9efcfe64a
commit c324751332
12 changed files with 400 additions and 60 deletions
+1 -1
View File
@@ -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';
+48
View File
@@ -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<string, any> | Promise<Record<string, any>>;
// @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
@@ -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<VisitDisplayContextValue>({
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 (
<VisitDisplayContext.Provider value={value}>
{children}
</VisitDisplayContext.Provider>
);
};
/**
* 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;
};
@@ -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 (
<Chip
size="small"
className={classes.chip}
label={(entity?.kind ?? 'Other').toLocaleLowerCase('en-US')}
style={{ background: getChipColor(entity) }}
label={getLabel(visit)}
style={{ background: getChipColor(visit) }}
/>
);
};
@@ -39,6 +39,9 @@ const ItemDetailTimeAgo = ({ visit }: { visit: Visit }) => {
);
};
/**
* @internal
*/
export type ItemDetailType = 'time-ago' | 'hits';
export const ItemDetail = ({
@@ -24,6 +24,9 @@ import { VisitListEmpty } from './VisitListEmpty';
import { VisitListFew } from './VisitListFew';
import { VisitListSkeleton } from './VisitListSkeleton';
/**
* @internal
*/
export const VisitList = ({
detailType,
visits = [],
@@ -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';
@@ -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<Visit> = [
@@ -130,4 +130,145 @@ describe('<VisitListener/>', () => {
}),
);
});
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(
<TestApiProvider apis={[[visitsApiRef, mockVisitsApi]]}>
<VisitListener />
</TestApiProvider>,
{ 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(
<TestApiProvider apis={[[visitsApiRef, mockVisitsApi]]}>
<VisitListener />
</TestApiProvider>,
{ 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(
<TestApiProvider apis={[[visitsApiRef, mockVisitsApi]]}>
<VisitListener enrichVisit={enrichVisit} />
</TestApiProvider>,
{ 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(
<TestApiProvider apis={[[visitsApiRef, mockVisitsApi]]}>
<VisitListener enrichVisit={enrichVisit} />
</TestApiProvider>,
{ 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(
<TestApiProvider apis={[[visitsApiRef, mockVisitsApi]]}>
<VisitListener enrichVisit={enrichVisit} />
</TestApiProvider>,
{ routeEntries: [pathname] },
);
await waitFor(() => {
expect(mockVisitsApi.save).toHaveBeenCalledWith({
visit: {
pathname,
name: 'Overridden Name',
entityRef: 'overridden:ref/value',
customField: 'additional-data',
},
});
});
});
});
});
+43 -7
View File
@@ -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<string, any> | Promise<Record<string, any>>;
/**
* @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}</>;
};
+1
View File
@@ -17,3 +17,4 @@
export { HomepageCompositionRoot } from './HomepageCompositionRoot';
export * from './CustomHomepage';
export * from './VisitListener';
export * from './VisitList';
@@ -25,23 +25,23 @@ import {
import { Visit } from '../../api/VisitsApi';
import { VisitedByTypeKind } from './Content';
export type ContextValueOnly = {
export type ContextValueOnly<T = Visit> = {
collapsed: boolean;
numVisitsOpen: number;
numVisitsTotal: number;
visits: Array<Visit>;
visits: Array<T>;
loading: boolean;
kind: VisitedByTypeKind;
};
export type ContextValue = ContextValueOnly & {
export type ContextValue<T = Visit> = ContextValueOnly<T> & {
setCollapsed: Dispatch<SetStateAction<boolean>>;
setNumVisitsOpen: Dispatch<SetStateAction<number>>;
setNumVisitsTotal: Dispatch<SetStateAction<number>>;
setVisits: Dispatch<SetStateAction<Array<Visit>>>;
setVisits: Dispatch<SetStateAction<Array<T>>>;
setLoading: Dispatch<SetStateAction<boolean>>;
setKind: Dispatch<SetStateAction<VisitedByTypeKind>>;
setContext: Dispatch<SetStateAction<ContextValueOnly>>;
setContext: Dispatch<SetStateAction<ContextValueOnly<T>>>;
};
const defaultContextValueOnly: ContextValueOnly = {
@@ -79,9 +79,9 @@ const getFilteredSet =
}));
export const ContextProvider = ({ children }: { children: JSX.Element }) => {
const [context, setContext] = useState<ContextValueOnly>(
defaultContextValueOnly,
);
const [context, setContext] = useState<ContextValueOnly>({
...defaultContextValueOnly,
});
const {
setCollapsed,
setNumVisitsOpen,
@@ -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 = () => {