From 46aa692057bbeee57aea03c54f96ce962ede6549 Mon Sep 17 00:00:00 2001 From: Renan Mendes Carvalho Date: Mon, 21 Aug 2023 14:47:05 +0200 Subject: [PATCH] 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 --- plugins/home/api-report.md | 96 ++++++++++++--- plugins/home/package.json | 2 + plugins/home/src/api/VisitsApi.ts | 106 +++++++++++++++++ plugins/home/src/api/index.ts | 17 +++ .../src/components/VisitList/ItemCategory.tsx | 79 ++++++++++++ .../src/components/VisitList/ItemDetail.tsx | 56 +++++++++ .../src/components/VisitList/ItemName.tsx | 41 +++++++ .../src/components/VisitList/VisitList.tsx | 102 ++++++++++++++++ .../components/VisitList/VisitListEmpty.tsx | 30 +++++ .../src/components/VisitList/VisitListFew.tsx | 26 ++++ .../components/VisitList/VisitListItem.tsx | 55 +++++++++ .../VisitList/VisitListSkeleton.tsx | 79 ++++++++++++ .../home/src/components/VisitList/index.ts | 17 +++ .../RecentlyVisited/Actions.tsx | 37 ++++++ .../RecentlyVisited/Content.tsx | 62 +++++++++- .../RecentlyVisited/Context.tsx | 92 ++++++++++++++ .../RecentlyVisited.stories.tsx | 112 +++++++++++++++++- .../RecentlyVisited/RecentlyVisited.tsx | 21 +++- .../RecentlyVisited/index.ts | 3 + plugins/home/src/homePageComponents/index.ts | 1 + plugins/home/src/index.ts | 1 + plugins/home/src/plugin.ts | 7 +- yarn.lock | 2 + 23 files changed, 1020 insertions(+), 24 deletions(-) create mode 100644 plugins/home/src/api/VisitsApi.ts create mode 100644 plugins/home/src/api/index.ts create mode 100644 plugins/home/src/components/VisitList/ItemCategory.tsx create mode 100644 plugins/home/src/components/VisitList/ItemDetail.tsx create mode 100644 plugins/home/src/components/VisitList/ItemName.tsx create mode 100644 plugins/home/src/components/VisitList/VisitList.tsx create mode 100644 plugins/home/src/components/VisitList/VisitListEmpty.tsx create mode 100644 plugins/home/src/components/VisitList/VisitListFew.tsx create mode 100644 plugins/home/src/components/VisitList/VisitListItem.tsx create mode 100644 plugins/home/src/components/VisitList/VisitListSkeleton.tsx create mode 100644 plugins/home/src/components/VisitList/index.ts create mode 100644 plugins/home/src/homePageComponents/RecentlyVisited/Actions.tsx create mode 100644 plugins/home/src/homePageComponents/RecentlyVisited/Context.tsx 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