Merge pull request #30905 from StateFarmIns/home-visit-enrichment-transform-save

Home plugin - Visit enrichment, transform, save, display context provider
This commit is contained in:
Eric Peterson
2025-11-10 13:18:33 +01:00
committed by GitHub
15 changed files with 710 additions and 62 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-home': patch
---
Allow customization of VisitList by adding optional enrichVisit, transformPathname, canSave functions to VisitsStorageApi, along with VisitDisplayProvider for colors, labels
+179 -3
View File
@@ -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
@@ -356,13 +356,189 @@ 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 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 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 {
AnyApiFactory,
createApiFactory,
identityApiRef,
storageApiRef,
} from '@backstage/core-plugin-api';
import { VisitsStorageApi } from '@backstage/plugin-home';
const transformPathname = (pathname: string) => {
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;
};
export const apis: AnyApiFactory[] = [
createApiFactory({
api: visitsApiRef,
deps: {
storageApi: storageApiRef,
identityApi: identityApiRef,
},
factory: ({ storageApi, identityApi }) =>
VisitsStorageApi.create({
storageApi,
identityApi,
transformPathname,
}),
}),
];
```
#### Can Save Function
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 {
AnyApiFactory,
createApiFactory,
identityApiRef,
storageApiRef,
} from '@backstage/core-plugin-api';
import { VisitInput, VisitsStorageApi } from '@backstage/plugin-home';
const canSave = (visit: VisitInput) => {
// Don't save visits to admin or settings pages
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
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 {
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;
};
const createEnrichVisit =
(catalogApi: CatalogApi) =>
async (visit: VisitInput): Promise<EnrichedVisit> => {
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;
}
};
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
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 (
<VisitDisplayProvider getChipColor={getChipColor} getLabel={getChipLabel}>
<CustomHomepageGrid title="Your Dashboard">
<HomePageRecentlyVisited />
<HomePageTopVisited />
</CustomHomepageGrid>
</VisitDisplayProvider>
);
}
```
## Contributing
### 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
+54
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,6 +301,13 @@ export type VisitedByTypeProps = {
kind: VisitedByTypeKind;
};
// @public
export type VisitInput = {
name: string;
pathname: string;
entityRef?: string;
};
// @public
export const VisitListener: ({
children,
@@ -280,8 +321,13 @@ export const VisitListener: ({
// @public
export interface VisitsApi {
canSave?(visit: VisitInput): boolean | Promise<boolean>;
enrichVisit?(
visit: VisitInput,
): Promise<Record<string, any>> | Record<string, any>;
list(queryParams?: VisitsApiQueryParams): Promise<Visit[]>;
save(saveParams: VisitsApiSaveParams): Promise<Visit>;
transformPathname?(pathname: string): string;
}
// @public
@@ -308,10 +354,13 @@ export type VisitsApiSaveParams = {
// @public
export class VisitsStorageApi implements VisitsApi {
canSave(visit: VisitInput): Promise<boolean>;
// (undocumented)
static create(options: VisitsStorageApiOptions): VisitsStorageApi;
enrichVisit(visit: VisitInput): Promise<Record<string, any>>;
list(queryParams?: VisitsApiQueryParams): Promise<Visit[]>;
save(saveParams: VisitsApiSaveParams): Promise<Visit>;
transformPathname(pathname: string): string;
}
// @public (undocumented)
@@ -319,6 +368,11 @@ export type VisitsStorageApiOptions = {
limit?: number;
storageApi: StorageApi;
identityApi: IdentityApi;
transformPathname?: (pathname: string) => string;
canSave?: (visit: VisitInput) => boolean | Promise<boolean>;
enrichVisit?: (
visit: VisitInput,
) => Promise<Record<string, any>> | Record<string, any>;
};
// @public
+18
View File
@@ -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<Visit[]>;
/**
* 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<boolean>;
/**
* Add additional data to the visit before saving.
* @param visit - page visit data
*/
enrichVisit?(
visit: VisitInput,
): Promise<Record<string, any>> | Record<string, any>;
}
/** @public */
@@ -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);
});
});
});
+89 -8
View File
@@ -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<boolean>;
enrichVisit?: (
visit: VisitInput,
) => Promise<Record<string, any>> | Record<string, any>;
};
type ArrayElement<A> = 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<boolean>;
private readonly enrichVisitImpl?: (
visit: VisitInput,
) => Promise<Record<string, any>> | Record<string, any>;
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<boolean> {
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<Record<string, any>> {
if (!this.enrichVisitImpl) {
return {};
}
return Promise.resolve(this.enrichVisitImpl(visit));
}
/**
* Saves a visit through the visitsApi
*/
async save(saveParams: VisitsApiSaveParams): Promise<Visit> {
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<Visit>) {
+1
View File
@@ -17,3 +17,4 @@
export * from './VisitsStorageApi';
export * from './VisitsWebStorageApi';
export * from './VisitsApi';
export type { VisitInput } from './VisitsStorageApi';
@@ -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';
+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 = () => {