feature(home-plugin): Recently Visited presentation use cases

This patch adds all the presentation use cases for Recently Visited.
Handling loading, few items, custom list sizes etc.

Signed-off-by: Renan Mendes Carvalho <aitherios@gmail.com>
This commit is contained in:
Renan Mendes Carvalho
2023-08-21 14:47:05 +02:00
committed by Camila Belo
parent ec7fce2d41
commit 46aa692057
23 changed files with 1020 additions and 24 deletions
+81 -15
View File
@@ -5,6 +5,7 @@
```ts
/// <reference types="react" />
import { ApiRef } from '@backstage/core-plugin-api';
import { BackstagePlugin } from '@backstage/core-plugin-api';
import { CardConfig as CardConfig_2 } from '@backstage/plugin-home-react';
import { CardExtensionProps as CardExtensionProps_2 } from '@backstage/plugin-home-react';
@@ -13,7 +14,7 @@ import { CardSettings as CardSettings_2 } from '@backstage/plugin-home-react';
import { ComponentParts as ComponentParts_2 } from '@backstage/plugin-home-react';
import { ComponentRenderer as ComponentRenderer_2 } from '@backstage/plugin-home-react';
import { createCardExtension as createCardExtension_2 } from '@backstage/plugin-home-react';
import { JSX as JSX_2 } from 'react';
import { JsonValue } from '@backstage/types';
import { default as React_2 } from 'react';
import { ReactElement } from 'react';
import { ReactNode } from 'react';
@@ -49,7 +50,7 @@ export const ComponentAccordion: (props: {
Actions?: (() => JSX.Element) | undefined;
Settings?: (() => JSX.Element) | undefined;
ContextProvider?: ((props: any) => JSX.Element) | undefined;
}) => JSX_2.Element;
}) => JSX.Element;
// @public @deprecated (undocumented)
export type ComponentParts = ComponentParts_2;
@@ -62,7 +63,7 @@ export const ComponentTab: (props: {
title: string;
Content: () => JSX.Element;
ContextProvider?: ((props: any) => JSX.Element) | undefined;
}) => JSX_2.Element;
}) => JSX.Element;
// @public (undocumented)
export const ComponentTabs: (props: {
@@ -71,7 +72,7 @@ export const ComponentTabs: (props: {
label: string;
Component: () => JSX.Element;
}[];
}) => JSX_2.Element;
}) => JSX.Element;
// @public @deprecated (undocumented)
export const createCardExtension: typeof createCardExtension_2;
@@ -79,7 +80,7 @@ export const createCardExtension: typeof createCardExtension_2;
// @public
export const CustomHomepageGrid: (
props: CustomHomepageGridProps,
) => React_2.JSX.Element;
) => JSX.Element;
// @public
export type CustomHomepageGridProps = {
@@ -101,36 +102,41 @@ export type CustomHomepageGridProps = {
export const HeaderWorldClock: (props: {
clockConfigs: ClockConfig[];
customTimeFormat?: Intl.DateTimeFormatOptions | undefined;
}) => JSX_2.Element | null;
}) => JSX.Element | null;
// @public
export const HomePageCompanyLogo: (props: {
logo?: ReactNode;
className?: string | undefined;
}) => JSX_2.Element;
}) => JSX.Element;
// @public (undocumented)
export const HomepageCompositionRoot: (props: {
title?: string | undefined;
children?: ReactNode;
}) => JSX_2.Element;
}) => JSX.Element;
// @public (undocumented)
export const HomePageRandomJoke: (
props: CardExtensionProps_2<{
defaultCategory?: 'any' | 'programming' | undefined;
}>,
) => JSX_2.Element;
) => JSX.Element;
// @public
export const HomePageStarredEntities: (
props: CardExtensionProps_2<unknown>,
) => JSX_2.Element;
) => JSX.Element;
// @public
export const HomePageToolkit: (
props: CardExtensionProps_2<ToolkitContentProps>,
) => JSX_2.Element;
) => JSX.Element;
// @public
export const HomePageVisitedByType: (
props: CardExtensionProps_2<VisitedByTypeProps>,
) => JSX.Element;
// @public (undocumented)
export const homePlugin: BackstagePlugin<
@@ -161,7 +167,7 @@ export const SettingsModal: (props: {
close: Function;
componentName?: string | undefined;
children: JSX.Element;
}) => JSX_2.Element;
}) => JSX.Element;
// @public (undocumented)
export const TemplateBackstageLogo: (props: {
@@ -169,10 +175,10 @@ export const TemplateBackstageLogo: (props: {
svg: string;
path: string;
};
}) => React_2.JSX.Element;
}) => JSX.Element;
// @public (undocumented)
export const TemplateBackstageLogoIcon: () => React_2.JSX.Element;
export const TemplateBackstageLogoIcon: () => JSX.Element;
// @public (undocumented)
export type Tool = {
@@ -186,13 +192,73 @@ export type ToolkitContentProps = {
tools: Tool[];
};
// Warning: (ae-missing-release-tag) "Visit" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public
export type Visit = {
id: string;
name: string;
pathname: string;
hits: number;
timestamp: number;
entityRef?: string;
};
// Warning: (ae-missing-release-tag) "VisitedByTypeProps" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export type VisitedByTypeProps = {
visits?: Array<Visit>;
numVisitsOpen?: number;
numVisitsTotal?: number;
loading?: boolean;
kind: VisitedByTypeKind;
};
// Warning: (ae-missing-release-tag) "VisitFilter" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export type VisitFilter = {
field: string;
operator: '<' | '<=' | '==' | '>' | '>=' | 'contains';
value: JsonValue;
};
// Warning: (ae-missing-release-tag) "VisitsApi" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public
export interface VisitsApi {
// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen
listUserVisits(queryParams: VisitsApiQueryParams): Promise<Visit[]>;
// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen
saveVisit(pageVisit: Omit<Visit, 'id' | 'hits' | 'timestamp'>): Promise<void>;
}
// Warning: (ae-missing-release-tag) "VisitsApiQueryParams" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public
export type VisitsApiQueryParams = {
limit?: number;
orderBy?: Record<string, 'asc' | 'desc'>;
filterBy?: VisitFilter[];
};
// Warning: (ae-missing-release-tag) "visitsApiRef" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export const visitsApiRef: ApiRef<VisitsApi>;
// @public
export const WelcomeTitle: ({
language,
}: WelcomeTitleLanguageProps) => JSX_2.Element;
}: WelcomeTitleLanguageProps) => JSX.Element;
// @public (undocumented)
export type WelcomeTitleLanguageProps = {
language?: string[];
};
// Warnings were encountered during analysis:
//
// src/homePageComponents/VisitedByType/Content.d.ts:9:5 - (ae-forgotten-export) The symbol "VisitedByTypeKind" needs to be exported by the entry point index.d.ts
```
+2
View File
@@ -41,6 +41,7 @@
"@backstage/plugin-catalog-react": "workspace:^",
"@backstage/plugin-home-react": "workspace:^",
"@backstage/theme": "workspace:^",
"@backstage/types": "workspace:^",
"@material-ui/core": "^4.12.2",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.61",
@@ -49,6 +50,7 @@
"@rjsf/utils": "5.13.0",
"@rjsf/validator-ajv8": "5.13.0",
"@types/react": "^16.13.1 || ^17.0.0",
"date-fns": "^2.30.0",
"lodash": "^4.17.21",
"react-grid-layout": "^1.3.4",
"react-resizable": "^3.0.4",
+106
View File
@@ -0,0 +1,106 @@
/*
* 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 { createApiRef } from '@backstage/core-plugin-api';
import { JsonValue } from '@backstage/types';
/**
@experimental
* Model for a visit entity.
*/
export type Visit = {
/**
* The auto-generated visit identification.
*/
id: string;
/**
* The visited entity, usually an entity id.
*/
name: string;
/**
* The visited url pathname, usually the entity route.
*/
pathname: string;
/**
* An individual view count.
*/
hits: number;
/**
* Last date and time of visit. Format: unix epoch in ms.
*/
timestamp: number;
/**
* Optional entity reference. See stringifyEntityRef from catalog-model.
*/
entityRef?: string;
};
export type VisitFilter = {
field: string;
operator: '<' | '<=' | '==' | '>' | '>=' | 'contains';
value: JsonValue;
};
/**
@experimental
* This data structure represents the parameters associated with search queries for visits.
*/
export type VisitsApiQueryParams = {
/**
* Limits the number of results returned. The default is 8.
*/
limit?: number;
/**
* A record for which the key is a field name to sort on, and the value is the sort direction.
* For a multi-field sorting query, add multi entries to the record.
* @example
* Sort ascending by the timestamp field.
* ```
* { orderBy: { timestamp: 'asc' } }
* ```
*/
orderBy?: Record<string, 'asc' | 'desc'>;
/**
* Allows filtering visits on number of hits, timestamp and/or entityRef attributes.
* @example
* Most popular docs on the past 7 days
* ```
* { orderBy: { hits: 'desc' }, filterBy: [{ field: 'timestamp', operator: '>=', value: <date> }, { field: 'entityRef', operator: 'contains', value: 'docs' }] }
* ```
*/
filterBy?: VisitFilter[];
};
/**
* @experimental
* Visits API public contract.
*/
export interface VisitsApi {
/**
* Persist a new visit.
* @param pageVisit | a new visit data.
*/
saveVisit(pageVisit: Omit<Visit, 'id' | 'hits' | 'timestamp'>): Promise<void>;
/**
* Get the logged user visits.
* @param queryParams | optional search query params.
*/
listUserVisits(queryParams: VisitsApiQueryParams): Promise<Visit[]>;
}
export const visitsApiRef = createApiRef<VisitsApi>({
id: 'homepage.visits',
});
+17
View File
@@ -0,0 +1,17 @@
/*
* 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.
*/
export * from './VisitsApi';
@@ -0,0 +1,79 @@
/*
* 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 React from 'react';
import { Chip, makeStyles } from '@material-ui/core';
import { colorVariants } from '@backstage/theme';
import { Visit } from '../../api/VisitsApi';
import { CompoundEntityRef, parseEntityRef } from '@backstage/catalog-model';
const useStyles = makeStyles(theme => ({
chip: {
color: theme.palette.common.white,
fontWeight: 'bold',
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);
return (
<Chip
size="small"
className={classes.chip}
label={(entity?.kind ?? 'Other').toLocaleLowerCase('en-US')}
style={{ background: getChipColor(entity) }}
/>
);
};
@@ -0,0 +1,56 @@
/*
* 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 React from 'react';
import { Typography } from '@material-ui/core';
import { Visit } from '../../api/VisitsApi';
import { format, formatDistance, formatISO, isToday } from 'date-fns';
const ItemDetailHits = ({ visit }: { visit: Visit }) => (
<Typography component="span" variant="caption" color="textSecondary">
{visit.hits} time{visit.hits > 1 ? 's' : ''}
</Typography>
);
const ItemDetailTimeAgo = ({ visit }: { visit: Visit }) => (
<Typography
component="time"
variant="caption"
color="textSecondary"
dateTime={formatISO(visit.timestamp)}
>
{isToday(visit.timestamp)
? format(visit.timestamp, 'HH:mm')
: formatDistance(visit.timestamp, Date.now(), {
addSuffix: true,
})}
</Typography>
);
export type ItemDetailType = 'time-ago' | 'hits';
export const ItemDetail = ({
visit,
type,
}: {
visit: Visit;
type: ItemDetailType;
}) =>
type === 'time-ago' ? (
<ItemDetailTimeAgo visit={visit} />
) : (
<ItemDetailHits visit={visit} />
);
@@ -0,0 +1,41 @@
/*
* 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 React from 'react';
import { Typography, makeStyles } from '@material-ui/core';
import { Visit } from '../../api/VisitsApi';
import { Link } from '@backstage/core-components';
const useStyles = makeStyles(_theme => ({
name: {
marginLeft: '0.8rem',
marginRight: '0.8rem',
},
}));
export const ItemName = ({ visit }: { visit: Visit }) => {
const classes = useStyles();
return (
<Typography
component={Link}
to={visit.pathname}
noWrap
className={classes.name}
>
{visit.name}
</Typography>
);
};
@@ -0,0 +1,102 @@
/*
* 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 React from 'react';
import { Collapse, List, Typography, makeStyles } from '@material-ui/core';
import { Visit } from '../../api/VisitsApi';
import { VisitListItem } from './VisitListItem';
import { ItemDetailType } from './ItemDetail';
import { VisitListEmpty } from './VisitListEmpty';
import { VisitListFew } from './VisitListFew';
import { VisitListSkeleton } from './VisitListSkeleton';
const useStyles = makeStyles(_theme => ({
title: {
marginBottom: '2rem',
},
}));
export const VisitList = ({
visits,
title,
detailType,
numVisitsOpen = 3,
numVisitsTotal = 8,
collapsed = true,
loading = false,
}: {
visits: Array<Visit>;
title: string;
detailType: ItemDetailType;
numVisitsOpen?: number;
numVisitsTotal?: number;
collapsed: boolean;
loading: boolean;
}) => {
const classes = useStyles();
let listBody: React.ReactElement = <></>;
if (loading) {
listBody = (
<VisitListSkeleton
numVisitsOpen={numVisitsOpen}
numVisitsTotal={numVisitsTotal}
collapsed={collapsed}
/>
);
} else if (visits.length === 0) {
listBody = <VisitListEmpty />;
} else if (visits.length < numVisitsOpen) {
listBody = (
<>
{visits.map((visit, index) => (
<VisitListItem visit={visit} key={index} detailType={detailType} />
))}
<VisitListFew />
</>
);
} else {
listBody = (
<>
{visits.slice(0, numVisitsOpen).map((visit, index) => (
<VisitListItem visit={visit} key={index} detailType={detailType} />
))}
{visits.length > numVisitsOpen && (
<Collapse in={!collapsed}>
{visits.slice(numVisitsOpen, numVisitsTotal).map((visit, index) => (
<VisitListItem
visit={visit}
key={index}
detailType={detailType}
/>
))}
</Collapse>
)}
</>
);
}
return (
<>
<Typography variant="h5" className={classes.title}>
{title}
</Typography>
<List dense disablePadding>
{listBody}
</List>
</>
);
};
@@ -0,0 +1,30 @@
/*
* 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 React from 'react';
import { Typography } from '@material-ui/core';
export const VisitListEmpty = () => (
<>
<Typography variant="body2" color="textSecondary">
There are no visits to show yet.
</Typography>
<Typography variant="body2" color="textSecondary">
Once you start using Backstage, your visits will appear here as a quick
link to carry on where you left off.
</Typography>
</>
);
@@ -0,0 +1,26 @@
/*
* 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 React from 'react';
import { Typography } from '@material-ui/core';
export const VisitListFew = () => (
<>
<Typography variant="body2" color="textSecondary">
The more pages you visit, the more pages will appear here.
</Typography>
</>
);
@@ -0,0 +1,55 @@
/*
* 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 React from 'react';
import {
ListItem,
ListItemAvatar,
ListItemText,
makeStyles,
} from '@material-ui/core';
import { Visit } from '../../api/VisitsApi';
import { ItemName } from './ItemName';
import { ItemDetail, ItemDetailType } from './ItemDetail';
import { ItemCategory } from './ItemCategory';
const useStyles = makeStyles(_theme => ({
avatar: {
minWidth: 0,
},
}));
export const VisitListItem = ({
visit,
detailType,
}: {
visit: Visit;
detailType: ItemDetailType;
}) => {
const classes = useStyles();
return (
<ListItem disableGutters>
<ListItemAvatar className={classes.avatar}>
<ItemCategory visit={visit} />
</ListItemAvatar>
<ListItemText
primary={<ItemName visit={visit} />}
secondary={<ItemDetail visit={visit} type={detailType} />}
disableTypography
/>
</ListItem>
);
};
@@ -0,0 +1,79 @@
/*
* 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 React from 'react';
import {
Collapse,
ListItem,
ListItemAvatar,
ListItemText,
makeStyles,
} from '@material-ui/core';
import { Skeleton } from '@material-ui/lab';
const useStyles = makeStyles(_theme => ({
skeleton: {
borderRadius: 30,
},
}));
const VisitListItemSkeleton = () => {
const classes = useStyles();
return (
<ListItem disableGutters>
<ListItemAvatar>
<Skeleton
className={classes.skeleton}
variant="rect"
width={50}
height={24}
/>
</ListItemAvatar>
<ListItemText
primary={<Skeleton variant="text" width="100%" height={28} />}
disableTypography
/>
</ListItem>
);
};
export const VisitListSkeleton = ({
numVisitsOpen,
numVisitsTotal,
collapsed,
}: {
numVisitsOpen: number;
numVisitsTotal: number;
collapsed: boolean;
}) => (
<>
{Array(numVisitsOpen)
.fill(null)
.map((_e, index) => (
<VisitListItemSkeleton key={index} />
))}
{numVisitsTotal > numVisitsOpen && (
<Collapse in={!collapsed}>
{Array(numVisitsTotal - numVisitsOpen)
.fill(null)
.map((_e, index) => (
<VisitListItemSkeleton key={index} />
))}
</Collapse>
)}
</>
);
@@ -0,0 +1,17 @@
/*
* 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.
*/
export { VisitList } from './VisitList';
@@ -0,0 +1,37 @@
/*
* 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 React, { useCallback } from 'react';
import { Button } from '@material-ui/core';
import { useContext } from './Context';
export const Actions = () => {
const { collapsed, setCollapsed, visits, numVisitsOpen, loading } =
useContext();
const onClick = useCallback(
() => setCollapsed(prevCollapsed => !prevCollapsed),
[setCollapsed],
);
const label = collapsed ? 'View More' : 'View Less';
if (!loading && visits.length <= numVisitsOpen) return <></>;
return (
<Button variant="text" onClick={onClick}>
{label}
</Button>
);
};
@@ -14,11 +14,69 @@
* limitations under the License.
*/
import React from 'react';
import React, { useEffect } from 'react';
import { RecentlyVisited } from './RecentlyVisited';
import { Visit, visitsApiRef } from '../../api/VisitsApi';
import { useContext } from './Context';
import { useApi } from '@backstage/core-plugin-api';
import useAsync from 'react-use/lib/useAsync';
export type RecentlyVisitedProps = {
visits?: Array<Visit>;
numVisitsOpen?: number;
numVisitsTotal?: number;
loading?: boolean;
};
/**
* Display recently visited pages for the homepage
* @public
*/
export const Content = () => <RecentlyVisited />;
export const Content = ({
visits,
numVisitsOpen,
numVisitsTotal,
loading,
}: RecentlyVisitedProps) => {
const { setVisits, setNumVisitsOpen, setNumVisitsTotal, setLoading } =
useContext();
// Allows behavior override from properties
useEffect(() => {
if (visits) {
setVisits(visits);
setLoading(false);
} else if (loading) {
setLoading(loading);
}
if (numVisitsOpen) setNumVisitsOpen(numVisitsOpen);
if (numVisitsTotal) setNumVisitsTotal(numVisitsTotal);
}, [
visits,
numVisitsOpen,
numVisitsTotal,
loading,
setVisits,
setNumVisitsOpen,
setNumVisitsTotal,
setLoading,
]);
// Fetches data from visitsApi in case visits and loading are not provided
const visitsApi = useApi(visitsApiRef);
const { loading: reqLoading } = useAsync(async () => {
if (!visits && !loading) {
await visitsApi
.listUserVisits({
limit: numVisitsTotal ?? 8,
orderBy: { timestamp: 'desc' },
})
.then(setVisits);
}
}, [visitsApi, visits, loading, setVisits]);
useEffect(() => {
if (!loading) {
setLoading(reqLoading);
}
}, [loading, setLoading, reqLoading]);
return <RecentlyVisited />;
};
@@ -0,0 +1,92 @@
/*
* 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 React, { createContext } from 'react';
import { Visit } from '../../api/VisitsApi';
export type ContextValue = {
collapsed: boolean;
setCollapsed: React.Dispatch<React.SetStateAction<boolean>>;
numVisitsOpen: number;
setNumVisitsOpen: React.Dispatch<React.SetStateAction<number>>;
numVisitsTotal: number;
setNumVisitsTotal: React.Dispatch<React.SetStateAction<number>>;
visits: Array<Visit>;
setVisits: React.Dispatch<React.SetStateAction<Array<Visit>>>;
loading: boolean;
setLoading: React.Dispatch<boolean>;
};
const defaultContextValue = {
collapsed: true,
setCollapsed: () => {},
numVisitsOpen: 3,
setNumVisitsOpen: () => {},
numVisitsTotal: 8,
setNumVisitsTotal: () => {},
visits: [],
setVisits: () => {},
loading: true,
setLoading: () => {},
};
const Context = createContext<ContextValue>(defaultContextValue);
export const ContextProvider = ({ children }: { children: JSX.Element }) => {
const [collapsed, setCollapsed] = React.useState(
defaultContextValue.collapsed,
);
const [numVisitsOpen, setNumVisitsOpen] = React.useState(
defaultContextValue.numVisitsOpen,
);
const [numVisitsTotal, setNumVisitsTotal] = React.useState(
defaultContextValue.numVisitsTotal,
);
const [visits, setVisits] = React.useState<Array<Visit>>(
defaultContextValue.visits,
);
const [loading, setLoading] = React.useState<boolean>(
defaultContextValue.loading,
);
const value: ContextValue = {
collapsed,
setCollapsed,
numVisitsOpen,
setNumVisitsOpen,
numVisitsTotal,
setNumVisitsTotal,
visits,
setVisits,
loading,
setLoading,
};
return <Context.Provider value={value}>{children}</Context.Provider>;
};
export const useContext = () => {
const value = React.useContext(Context);
if (value === undefined)
throw new Error(
'RecentlyVisited useContext found undefined ContextValue, <ContextProvider/> could be missing',
);
return value;
};
export default Context;
@@ -15,15 +15,91 @@
*/
import React from 'react';
import { wrapInTestApp } from '@backstage/test-utils';
import { TestApiProvider, wrapInTestApp } from '@backstage/test-utils';
import { ComponentType, PropsWithChildren } from 'react';
import { Grid } from '@material-ui/core';
import { HomePageRecentlyVisited } from '../../plugin';
import { Visit, visitsApiRef } from '../../api/VisitsApi';
const visits: Array<Visit> = [
{
id: 'explore',
name: 'Explore Backstage',
pathname: '/explore',
hits: 35,
timestamp: Date.now() - 86400_000 * 1,
},
{
id: 'user-1',
name: 'Guest',
pathname: '/catalog/default/user/guest',
hits: 30,
timestamp: Date.now() - 86400_000 * 2,
entityRef: 'User:default/guest',
},
{
id: 'audio-playback',
name: 'Audio Playback',
pathname: '/catalog/default/system/audio-playback',
hits: 25,
timestamp: Date.now() - 86400_000 * 3,
entityRef: 'System:default/audio-playback',
},
{
id: 'team-a',
name: 'Team A',
pathname: '/catalog/default/group/team-a',
hits: 20,
timestamp: Date.now() - 86400_000 * 4,
entityRef: 'Group:default/team-a',
},
{
id: 'playback-order',
name: 'Playback Order',
pathname: '/catalog/default/component/playback-order',
hits: 15,
timestamp: Date.now() - 86400_000 * 5,
entityRef: 'Component:default/playback-order',
},
{
id: 'playback',
name: 'Playback',
pathname: '/catalog/default/domain/playback',
hits: 10,
timestamp: Date.now() - 86400_000 * 6,
entityRef: 'Domain:default/playback',
},
{
id: 'hello-world',
name: 'Hello World gRPC',
pathname: '/catalog/default/api/hello-world',
hits: 5,
timestamp: Date.now() - 86400_000 * 7,
entityRef: 'API:default/hello-world',
},
{
id: 'tech-radar',
name: 'Tech Radar',
pathname: '/tech-radar',
hits: 1,
timestamp: Date.now() - 360_000,
},
];
const mockVisitsApi = {
saveVisit: async () => {},
listUserVisits: async () => visits,
};
export default {
title: 'Plugins/Home/Components/RecentlyVisited',
decorators: [
(Story: ComponentType<PropsWithChildren<{}>>) => wrapInTestApp(<Story />),
(Story: ComponentType<PropsWithChildren<{}>>) =>
wrapInTestApp(
<TestApiProvider apis={[[visitsApiRef, mockVisitsApi]]}>
<Story />
</TestApiProvider>,
),
],
};
@@ -34,3 +110,35 @@ export const Default = () => {
</Grid>
);
};
export const Empty = () => {
return (
<Grid item xs={12} md={6}>
<HomePageRecentlyVisited visits={[]} />
</Grid>
);
};
export const FewItems = () => {
return (
<Grid item xs={12} md={6}>
<HomePageRecentlyVisited visits={visits.slice(0, 1)} />
</Grid>
);
};
export const MoreItems = () => {
return (
<Grid item xs={12} md={6}>
<HomePageRecentlyVisited numVisitsOpen={5} numVisitsTotal={6} />
</Grid>
);
};
export const Loading = () => {
return (
<Grid item xs={12} md={6}>
<HomePageRecentlyVisited numVisitsOpen={5} numVisitsTotal={6} loading />
</Grid>
);
};
@@ -15,10 +15,25 @@
*/
import React from 'react';
import Typography from '@material-ui/core/Typography';
import { VisitList } from '../../components/VisitList';
import { useContext } from './Context';
/**
* Display recently visited pages for the homepage
* @public
*/
export const RecentlyVisited = () => <Typography>RecentlyVisited</Typography>;
export const RecentlyVisited = () => {
const { collapsed, numVisitsOpen, numVisitsTotal, visits, loading } =
useContext();
return (
<VisitList
visits={visits}
title="Recently visited"
detailType="time-ago"
collapsed={collapsed}
numVisitsOpen={numVisitsOpen}
numVisitsTotal={numVisitsTotal}
loading={loading}
/>
);
};
@@ -15,3 +15,6 @@
*/
export { Content } from './Content';
export { Actions } from './Actions';
export { ContextProvider } from './Context';
export type { RecentlyVisitedProps } from './Content';
@@ -17,3 +17,4 @@
export type { ToolkitContentProps, Tool } from './Toolkit';
export type { ClockConfig } from './HeaderWorldClock';
export type { WelcomeTitleLanguageProps } from './WelcomeTitle';
export type { RecentlyVisitedProps } from './RecentlyVisited';
+1
View File
@@ -37,3 +37,4 @@ export * from './components';
export * from './assets';
export * from './homePageComponents';
export * from './deprecated';
export * from './api';
+5 -2
View File
@@ -20,7 +20,10 @@ import {
createRoutableExtension,
} from '@backstage/core-plugin-api';
import { createCardExtension } from '@backstage/plugin-home-react';
import { ToolkitContentProps } from './homePageComponents';
import {
ToolkitContentProps,
RecentlyVisitedProps,
} from './homePageComponents';
import { rootRouteRef } from './routes';
/** @public */
@@ -178,7 +181,7 @@ export const HeaderWorldClock = homePlugin.provide(
* @public
*/
export const HomePageRecentlyVisited = homePlugin.provide(
createCardExtension({
createCardExtension<RecentlyVisitedProps>({
name: 'HomePageRecentlyVisited',
components: () => import('./homePageComponents/RecentlyVisited'),
}),
+2
View File
@@ -7395,6 +7395,7 @@ __metadata:
"@backstage/plugin-home-react": "workspace:^"
"@backstage/test-utils": "workspace:^"
"@backstage/theme": "workspace:^"
"@backstage/types": "workspace:^"
"@material-ui/core": ^4.12.2
"@material-ui/icons": ^4.9.1
"@material-ui/lab": 4.0.0-alpha.61
@@ -7408,6 +7409,7 @@ __metadata:
"@testing-library/user-event": ^14.0.0
"@types/react": ^16.13.1 || ^17.0.0
"@types/react-grid-layout": ^1.3.2
date-fns: ^2.30.0
lodash: ^4.17.21
msw: ^1.0.0
react-grid-layout: ^1.3.4