diff --git a/plugins/home/api-report.md b/plugins/home/api-report.md
index 497b11224d..3ea2b23df1 100644
--- a/plugins/home/api-report.md
+++ b/plugins/home/api-report.md
@@ -5,6 +5,7 @@
```ts
///
+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,
-) => JSX_2.Element;
+) => JSX.Element;
// @public
export const HomePageToolkit: (
props: CardExtensionProps_2,
-) => JSX_2.Element;
+) => JSX.Element;
+
+// @public
+export const HomePageVisitedByType: (
+ props: CardExtensionProps_2,
+) => 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;
+ 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;
+ // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen
+ saveVisit(pageVisit: Omit): Promise;
+}
+
+// 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;
+ 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;
+
// @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
```
diff --git a/plugins/home/package.json b/plugins/home/package.json
index bba62e9706..9a692557b1 100644
--- a/plugins/home/package.json
+++ b/plugins/home/package.json
@@ -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",
diff --git a/plugins/home/src/api/VisitsApi.ts b/plugins/home/src/api/VisitsApi.ts
new file mode 100644
index 0000000000..a00897fb78
--- /dev/null
+++ b/plugins/home/src/api/VisitsApi.ts
@@ -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;
+ /**
+ * 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: }, { 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): Promise;
+ /**
+ * Get the logged user visits.
+ * @param queryParams | optional search query params.
+ */
+ listUserVisits(queryParams: VisitsApiQueryParams): Promise;
+}
+
+export const visitsApiRef = createApiRef({
+ id: 'homepage.visits',
+});
diff --git a/plugins/home/src/api/index.ts b/plugins/home/src/api/index.ts
new file mode 100644
index 0000000000..29a8fb5468
--- /dev/null
+++ b/plugins/home/src/api/index.ts
@@ -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';
diff --git a/plugins/home/src/components/VisitList/ItemCategory.tsx b/plugins/home/src/components/VisitList/ItemCategory.tsx
new file mode 100644
index 0000000000..0f9636ce31
--- /dev/null
+++ b/plugins/home/src/components/VisitList/ItemCategory.tsx
@@ -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 (
+
+ );
+};
diff --git a/plugins/home/src/components/VisitList/ItemDetail.tsx b/plugins/home/src/components/VisitList/ItemDetail.tsx
new file mode 100644
index 0000000000..5bdbdc0db4
--- /dev/null
+++ b/plugins/home/src/components/VisitList/ItemDetail.tsx
@@ -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 }) => (
+
+ {visit.hits} time{visit.hits > 1 ? 's' : ''}
+
+);
+
+const ItemDetailTimeAgo = ({ visit }: { visit: Visit }) => (
+
+ {isToday(visit.timestamp)
+ ? format(visit.timestamp, 'HH:mm')
+ : formatDistance(visit.timestamp, Date.now(), {
+ addSuffix: true,
+ })}
+
+);
+
+export type ItemDetailType = 'time-ago' | 'hits';
+
+export const ItemDetail = ({
+ visit,
+ type,
+}: {
+ visit: Visit;
+ type: ItemDetailType;
+}) =>
+ type === 'time-ago' ? (
+
+ ) : (
+
+ );
diff --git a/plugins/home/src/components/VisitList/ItemName.tsx b/plugins/home/src/components/VisitList/ItemName.tsx
new file mode 100644
index 0000000000..965ad80d5e
--- /dev/null
+++ b/plugins/home/src/components/VisitList/ItemName.tsx
@@ -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 (
+
+ {visit.name}
+
+ );
+};
diff --git a/plugins/home/src/components/VisitList/VisitList.tsx b/plugins/home/src/components/VisitList/VisitList.tsx
new file mode 100644
index 0000000000..e118058857
--- /dev/null
+++ b/plugins/home/src/components/VisitList/VisitList.tsx
@@ -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;
+ title: string;
+ detailType: ItemDetailType;
+ numVisitsOpen?: number;
+ numVisitsTotal?: number;
+ collapsed: boolean;
+ loading: boolean;
+}) => {
+ const classes = useStyles();
+
+ let listBody: React.ReactElement = <>>;
+ if (loading) {
+ listBody = (
+
+ );
+ } else if (visits.length === 0) {
+ listBody = ;
+ } else if (visits.length < numVisitsOpen) {
+ listBody = (
+ <>
+ {visits.map((visit, index) => (
+
+ ))}
+
+ >
+ );
+ } else {
+ listBody = (
+ <>
+ {visits.slice(0, numVisitsOpen).map((visit, index) => (
+
+ ))}
+ {visits.length > numVisitsOpen && (
+
+ {visits.slice(numVisitsOpen, numVisitsTotal).map((visit, index) => (
+
+ ))}
+
+ )}
+ >
+ );
+ }
+
+ return (
+ <>
+
+ {title}
+
+
+ {listBody}
+
+ >
+ );
+};
diff --git a/plugins/home/src/components/VisitList/VisitListEmpty.tsx b/plugins/home/src/components/VisitList/VisitListEmpty.tsx
new file mode 100644
index 0000000000..3996f0f863
--- /dev/null
+++ b/plugins/home/src/components/VisitList/VisitListEmpty.tsx
@@ -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 = () => (
+ <>
+
+ There are no visits to show yet.
+
+
+ Once you start using Backstage, your visits will appear here as a quick
+ link to carry on where you left off.
+
+ >
+);
diff --git a/plugins/home/src/components/VisitList/VisitListFew.tsx b/plugins/home/src/components/VisitList/VisitListFew.tsx
new file mode 100644
index 0000000000..c28f1af931
--- /dev/null
+++ b/plugins/home/src/components/VisitList/VisitListFew.tsx
@@ -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 = () => (
+ <>
+
+ The more pages you visit, the more pages will appear here.
+
+ >
+);
diff --git a/plugins/home/src/components/VisitList/VisitListItem.tsx b/plugins/home/src/components/VisitList/VisitListItem.tsx
new file mode 100644
index 0000000000..20087c884a
--- /dev/null
+++ b/plugins/home/src/components/VisitList/VisitListItem.tsx
@@ -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 (
+
+
+
+
+ }
+ secondary={}
+ disableTypography
+ />
+
+ );
+};
diff --git a/plugins/home/src/components/VisitList/VisitListSkeleton.tsx b/plugins/home/src/components/VisitList/VisitListSkeleton.tsx
new file mode 100644
index 0000000000..94ba840d9d
--- /dev/null
+++ b/plugins/home/src/components/VisitList/VisitListSkeleton.tsx
@@ -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 (
+
+
+
+
+ }
+ disableTypography
+ />
+
+ );
+};
+
+export const VisitListSkeleton = ({
+ numVisitsOpen,
+ numVisitsTotal,
+ collapsed,
+}: {
+ numVisitsOpen: number;
+ numVisitsTotal: number;
+ collapsed: boolean;
+}) => (
+ <>
+ {Array(numVisitsOpen)
+ .fill(null)
+ .map((_e, index) => (
+
+ ))}
+ {numVisitsTotal > numVisitsOpen && (
+
+ {Array(numVisitsTotal - numVisitsOpen)
+ .fill(null)
+ .map((_e, index) => (
+
+ ))}
+
+ )}
+ >
+);
diff --git a/plugins/home/src/components/VisitList/index.ts b/plugins/home/src/components/VisitList/index.ts
new file mode 100644
index 0000000000..2d9513893b
--- /dev/null
+++ b/plugins/home/src/components/VisitList/index.ts
@@ -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';
diff --git a/plugins/home/src/homePageComponents/RecentlyVisited/Actions.tsx b/plugins/home/src/homePageComponents/RecentlyVisited/Actions.tsx
new file mode 100644
index 0000000000..45e67802c9
--- /dev/null
+++ b/plugins/home/src/homePageComponents/RecentlyVisited/Actions.tsx
@@ -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 (
+
+ );
+};
diff --git a/plugins/home/src/homePageComponents/RecentlyVisited/Content.tsx b/plugins/home/src/homePageComponents/RecentlyVisited/Content.tsx
index 4d06751e22..d9b6128cc4 100644
--- a/plugins/home/src/homePageComponents/RecentlyVisited/Content.tsx
+++ b/plugins/home/src/homePageComponents/RecentlyVisited/Content.tsx
@@ -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;
+ numVisitsOpen?: number;
+ numVisitsTotal?: number;
+ loading?: boolean;
+};
/**
* Display recently visited pages for the homepage
* @public
*/
-export const Content = () => ;
+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 ;
+};
diff --git a/plugins/home/src/homePageComponents/RecentlyVisited/Context.tsx b/plugins/home/src/homePageComponents/RecentlyVisited/Context.tsx
new file mode 100644
index 0000000000..44c8fe322b
--- /dev/null
+++ b/plugins/home/src/homePageComponents/RecentlyVisited/Context.tsx
@@ -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>;
+ numVisitsOpen: number;
+ setNumVisitsOpen: React.Dispatch>;
+ numVisitsTotal: number;
+ setNumVisitsTotal: React.Dispatch>;
+ visits: Array;
+ setVisits: React.Dispatch>>;
+ loading: boolean;
+ setLoading: React.Dispatch;
+};
+
+const defaultContextValue = {
+ collapsed: true,
+ setCollapsed: () => {},
+ numVisitsOpen: 3,
+ setNumVisitsOpen: () => {},
+ numVisitsTotal: 8,
+ setNumVisitsTotal: () => {},
+ visits: [],
+ setVisits: () => {},
+ loading: true,
+ setLoading: () => {},
+};
+
+const Context = createContext(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>(
+ defaultContextValue.visits,
+ );
+ const [loading, setLoading] = React.useState(
+ defaultContextValue.loading,
+ );
+
+ const value: ContextValue = {
+ collapsed,
+ setCollapsed,
+ numVisitsOpen,
+ setNumVisitsOpen,
+ numVisitsTotal,
+ setNumVisitsTotal,
+ visits,
+ setVisits,
+ loading,
+ setLoading,
+ };
+
+ return {children};
+};
+
+export const useContext = () => {
+ const value = React.useContext(Context);
+
+ if (value === undefined)
+ throw new Error(
+ 'RecentlyVisited useContext found undefined ContextValue, could be missing',
+ );
+
+ return value;
+};
+
+export default Context;
diff --git a/plugins/home/src/homePageComponents/RecentlyVisited/RecentlyVisited.stories.tsx b/plugins/home/src/homePageComponents/RecentlyVisited/RecentlyVisited.stories.tsx
index bb04a1ebc7..cd3db0eb7b 100644
--- a/plugins/home/src/homePageComponents/RecentlyVisited/RecentlyVisited.stories.tsx
+++ b/plugins/home/src/homePageComponents/RecentlyVisited/RecentlyVisited.stories.tsx
@@ -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 = [
+ {
+ 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>) => wrapInTestApp(),
+ (Story: ComponentType>) =>
+ wrapInTestApp(
+
+
+ ,
+ ),
],
};
@@ -34,3 +110,35 @@ export const Default = () => {
);
};
+
+export const Empty = () => {
+ return (
+
+
+
+ );
+};
+
+export const FewItems = () => {
+ return (
+
+
+
+ );
+};
+
+export const MoreItems = () => {
+ return (
+
+
+
+ );
+};
+
+export const Loading = () => {
+ return (
+
+
+
+ );
+};
diff --git a/plugins/home/src/homePageComponents/RecentlyVisited/RecentlyVisited.tsx b/plugins/home/src/homePageComponents/RecentlyVisited/RecentlyVisited.tsx
index 6f2abe3b60..121d15b1d2 100644
--- a/plugins/home/src/homePageComponents/RecentlyVisited/RecentlyVisited.tsx
+++ b/plugins/home/src/homePageComponents/RecentlyVisited/RecentlyVisited.tsx
@@ -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 = () => RecentlyVisited;
+export const RecentlyVisited = () => {
+ const { collapsed, numVisitsOpen, numVisitsTotal, visits, loading } =
+ useContext();
+
+ return (
+
+ );
+};
diff --git a/plugins/home/src/homePageComponents/RecentlyVisited/index.ts b/plugins/home/src/homePageComponents/RecentlyVisited/index.ts
index f1cdf3734f..8caf533ab1 100644
--- a/plugins/home/src/homePageComponents/RecentlyVisited/index.ts
+++ b/plugins/home/src/homePageComponents/RecentlyVisited/index.ts
@@ -15,3 +15,6 @@
*/
export { Content } from './Content';
+export { Actions } from './Actions';
+export { ContextProvider } from './Context';
+export type { RecentlyVisitedProps } from './Content';
diff --git a/plugins/home/src/homePageComponents/index.ts b/plugins/home/src/homePageComponents/index.ts
index 36d2335afe..026f068d3f 100644
--- a/plugins/home/src/homePageComponents/index.ts
+++ b/plugins/home/src/homePageComponents/index.ts
@@ -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';
diff --git a/plugins/home/src/index.ts b/plugins/home/src/index.ts
index 63407c8531..e133ad49b5 100644
--- a/plugins/home/src/index.ts
+++ b/plugins/home/src/index.ts
@@ -37,3 +37,4 @@ export * from './components';
export * from './assets';
export * from './homePageComponents';
export * from './deprecated';
+export * from './api';
diff --git a/plugins/home/src/plugin.ts b/plugins/home/src/plugin.ts
index 20cfddc724..90ac5c3519 100644
--- a/plugins/home/src/plugin.ts
+++ b/plugins/home/src/plugin.ts
@@ -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({
name: 'HomePageRecentlyVisited',
components: () => import('./homePageComponents/RecentlyVisited'),
}),
diff --git a/yarn.lock b/yarn.lock
index 5a115047db..65ba1a0ec0 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -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