From be5853235aafd378c52fa957b5b3fe17f748843c Mon Sep 17 00:00:00 2001 From: Renan Mendes Carvalho Date: Tue, 19 Sep 2023 14:31:24 +0200 Subject: [PATCH 01/28] doc(home-plugin): Adds documentation on usage (#19688) * doc(home-plugin): Adds documentation on usage Signed-off-by: Renan Mendes Carvalho * Update plugins/home/README.md Co-authored-by: Adam Harvey <33203301+adamdmharvey@users.noreply.github.com> Signed-off-by: Renan Mendes Carvalho * Update plugins/home/README.md Co-authored-by: Camila Belo Signed-off-by: Renan Mendes Carvalho --------- Signed-off-by: Renan Mendes Carvalho Co-authored-by: Adam Harvey <33203301+adamdmharvey@users.noreply.github.com> Co-authored-by: Camila Belo --- plugins/home/README.md | 79 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/plugins/home/README.md b/plugins/home/README.md index e147cdfbdf..f6a223701b 100644 --- a/plugins/home/README.md +++ b/plugins/home/README.md @@ -242,6 +242,85 @@ const defaultConfig = [ ``` +## Page visit homepage component (HomePageVisitedByType) + +This component shows the homepage user a view for "Recently visited" or "Top visited". +Being provided by the `` component, see it in use on a homepage example below: + +```tsx +// packages/app/src/components/home/HomePage.tsx +import React from 'react'; +import Grid from '@material-ui/core/Grid'; +import { HomePageVisitedByType } from '@backstage/plugin-home'; + +export const homePage = ( + + + + + + + + +); +``` + +There are some requirements to provide its functionality, so please ensure the following: + +These components need an API to handle visit data, please refer to the [utility-apis](../../docs/api/utility-apis.md) +documentation for more information. Bellow you can see an example for two options: + +```ts +// packages/app/src/apis.ts +// ... +import { + CoreStorageVisitsApi, + LocalStoreVisitsApi, + visitsApiRef, +} from '@backstage/plugin-home'; +// ... +export const apis: AnyApiFactory[] = [ + // Implementation that relies on the integration with storageApi + createApiFactory({ + api: visitsApiRef, + deps: { + storageApi: storageApiRef, + identityApi: identityApiRef, + }, + factory: ({ storageApi, identityApi }) => + CoreStorageVisitsApi.create({ storageApi, identityApi }), + }), + + // Or a local data implementation, relies on the browser's window.localStorage + createApiFactory({ + api: visitsApiRef, + deps: { + identityApi: identityApiRef, + }, + factory: ({ identityApi }) => LocalStoreVisitsApi.create({ identityApi }), + }), + // ... +``` + +To monitor page visit activity and save it on behalf of the user a component is provided, please add it to your app. +See the example usage: + +```ts +// packages/app/src/App.tsx +import { VisitsListener } from '@backstage/plugin-home'; +// ... +export default app.createRoot( + <> + + + + + {routes} + + , +); +``` + ## Contributing ### Homepage Components From ec7fce2d41f94e0ff2c958ae575c30da4e8a84e8 Mon Sep 17 00:00:00 2001 From: Renan Mendes Carvalho Date: Wed, 16 Aug 2023 10:52:29 +0200 Subject: [PATCH 02/28] feature(home-plugin): Bootstrap skeleton. Signed-off-by: Renan Mendes Carvalho --- .../RecentlyVisited/Content.tsx | 24 +++++++++++++ .../RecentlyVisited.stories.tsx | 36 +++++++++++++++++++ .../RecentlyVisited/RecentlyVisited.test.tsx | 26 ++++++++++++++ .../RecentlyVisited/RecentlyVisited.tsx | 24 +++++++++++++ .../RecentlyVisited/index.ts | 17 +++++++++ plugins/home/src/plugin.ts | 11 ++++++ 6 files changed, 138 insertions(+) create mode 100644 plugins/home/src/homePageComponents/RecentlyVisited/Content.tsx create mode 100644 plugins/home/src/homePageComponents/RecentlyVisited/RecentlyVisited.stories.tsx create mode 100644 plugins/home/src/homePageComponents/RecentlyVisited/RecentlyVisited.test.tsx create mode 100644 plugins/home/src/homePageComponents/RecentlyVisited/RecentlyVisited.tsx create mode 100644 plugins/home/src/homePageComponents/RecentlyVisited/index.ts diff --git a/plugins/home/src/homePageComponents/RecentlyVisited/Content.tsx b/plugins/home/src/homePageComponents/RecentlyVisited/Content.tsx new file mode 100644 index 0000000000..4d06751e22 --- /dev/null +++ b/plugins/home/src/homePageComponents/RecentlyVisited/Content.tsx @@ -0,0 +1,24 @@ +/* + * 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 { RecentlyVisited } from './RecentlyVisited'; + +/** + * Display recently visited pages for the homepage + * @public + */ +export const Content = () => ; diff --git a/plugins/home/src/homePageComponents/RecentlyVisited/RecentlyVisited.stories.tsx b/plugins/home/src/homePageComponents/RecentlyVisited/RecentlyVisited.stories.tsx new file mode 100644 index 0000000000..bb04a1ebc7 --- /dev/null +++ b/plugins/home/src/homePageComponents/RecentlyVisited/RecentlyVisited.stories.tsx @@ -0,0 +1,36 @@ +/* + * 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 { wrapInTestApp } from '@backstage/test-utils'; +import { ComponentType, PropsWithChildren } from 'react'; +import { Grid } from '@material-ui/core'; +import { HomePageRecentlyVisited } from '../../plugin'; + +export default { + title: 'Plugins/Home/Components/RecentlyVisited', + decorators: [ + (Story: ComponentType>) => wrapInTestApp(), + ], +}; + +export const Default = () => { + return ( + + + + ); +}; diff --git a/plugins/home/src/homePageComponents/RecentlyVisited/RecentlyVisited.test.tsx b/plugins/home/src/homePageComponents/RecentlyVisited/RecentlyVisited.test.tsx new file mode 100644 index 0000000000..5ccab63fa8 --- /dev/null +++ b/plugins/home/src/homePageComponents/RecentlyVisited/RecentlyVisited.test.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 { RecentlyVisited } from './RecentlyVisited'; +import { renderInTestApp } from '@backstage/test-utils'; + +describe('', () => { + it('should render', async () => { + const { getByText } = await renderInTestApp(); + expect(getByText('RecentlyVisited')).toBeInTheDocument(); + }); +}); diff --git a/plugins/home/src/homePageComponents/RecentlyVisited/RecentlyVisited.tsx b/plugins/home/src/homePageComponents/RecentlyVisited/RecentlyVisited.tsx new file mode 100644 index 0000000000..6f2abe3b60 --- /dev/null +++ b/plugins/home/src/homePageComponents/RecentlyVisited/RecentlyVisited.tsx @@ -0,0 +1,24 @@ +/* + * 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/Typography'; + +/** + * Display recently visited pages for the homepage + * @public + */ +export const RecentlyVisited = () => RecentlyVisited; diff --git a/plugins/home/src/homePageComponents/RecentlyVisited/index.ts b/plugins/home/src/homePageComponents/RecentlyVisited/index.ts new file mode 100644 index 0000000000..f1cdf3734f --- /dev/null +++ b/plugins/home/src/homePageComponents/RecentlyVisited/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 { Content } from './Content'; diff --git a/plugins/home/src/plugin.ts b/plugins/home/src/plugin.ts index 74a1c11390..20cfddc724 100644 --- a/plugins/home/src/plugin.ts +++ b/plugins/home/src/plugin.ts @@ -172,3 +172,14 @@ export const HeaderWorldClock = homePlugin.provide( }, }), ); + +/** + * Display recently visited pages for the homepage + * @public + */ +export const HomePageRecentlyVisited = homePlugin.provide( + createCardExtension({ + name: 'HomePageRecentlyVisited', + components: () => import('./homePageComponents/RecentlyVisited'), + }), +); From 46aa692057bbeee57aea03c54f96ce962ede6549 Mon Sep 17 00:00:00 2001 From: Renan Mendes Carvalho Date: Mon, 21 Aug 2023 14:47:05 +0200 Subject: [PATCH 03/28] 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 From 160761b049b22ad16807ea0636322057e54f80ad Mon Sep 17 00:00:00 2001 From: Renan Mendes Carvalho Date: Wed, 23 Aug 2023 16:13:39 +0200 Subject: [PATCH 04/28] feature(home-plugin): Rename component to VisitedByType This renaming is to enable Top Visited and Recently Visited to be served by the same component. This is to reduce code duplication. Signed-off-by: Renan Mendes Carvalho --- plugins/home/api-report.md | 54 +++--- plugins/home/src/api/VisitsApi.ts | 14 +- .../components/VisitList/VisitList.test.tsx | 160 ++++++++++++++++++ .../src/components/VisitList/VisitList.tsx | 8 +- .../RecentlyVisited/Context.tsx | 92 ---------- .../RecentlyVisited/RecentlyVisited.test.tsx | 26 --- .../Actions.tsx | 0 .../VisitedByType/Content.test.tsx | 136 +++++++++++++++ .../Content.tsx | 57 ++++--- .../VisitedByType/Context.tsx | 121 +++++++++++++ .../HomePageVisitedByType.stories.tsx} | 94 +++++++--- .../VisitedByType/VisitedByType.test.tsx | 89 ++++++++++ .../VisitedByType.tsx} | 13 +- .../index.ts | 2 +- plugins/home/src/homePageComponents/index.ts | 2 +- plugins/home/src/index.ts | 1 + plugins/home/src/plugin.ts | 15 +- 17 files changed, 659 insertions(+), 225 deletions(-) create mode 100644 plugins/home/src/components/VisitList/VisitList.test.tsx delete mode 100644 plugins/home/src/homePageComponents/RecentlyVisited/Context.tsx delete mode 100644 plugins/home/src/homePageComponents/RecentlyVisited/RecentlyVisited.test.tsx rename plugins/home/src/homePageComponents/{RecentlyVisited => VisitedByType}/Actions.tsx (100%) create mode 100644 plugins/home/src/homePageComponents/VisitedByType/Content.test.tsx rename plugins/home/src/homePageComponents/{RecentlyVisited => VisitedByType}/Content.tsx (59%) create mode 100644 plugins/home/src/homePageComponents/VisitedByType/Context.tsx rename plugins/home/src/homePageComponents/{RecentlyVisited/RecentlyVisited.stories.tsx => VisitedByType/HomePageVisitedByType.stories.tsx} (65%) create mode 100644 plugins/home/src/homePageComponents/VisitedByType/VisitedByType.test.tsx rename plugins/home/src/homePageComponents/{RecentlyVisited/RecentlyVisited.tsx => VisitedByType/VisitedByType.tsx} (85%) rename plugins/home/src/homePageComponents/{RecentlyVisited => VisitedByType}/index.ts (90%) diff --git a/plugins/home/api-report.md b/plugins/home/api-report.md index 3ea2b23df1..536032a8eb 100644 --- a/plugins/home/api-report.md +++ b/plugins/home/api-report.md @@ -15,6 +15,7 @@ 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 { JsonValue } from '@backstage/types'; +import { JSX as JSX_2 } from 'react'; import { default as React_2 } from 'react'; import { ReactElement } from 'react'; import { ReactNode } from 'react'; @@ -50,7 +51,7 @@ export const ComponentAccordion: (props: { Actions?: (() => JSX.Element) | undefined; Settings?: (() => JSX.Element) | undefined; ContextProvider?: ((props: any) => JSX.Element) | undefined; -}) => JSX.Element; +}) => JSX_2.Element; // @public @deprecated (undocumented) export type ComponentParts = ComponentParts_2; @@ -63,7 +64,7 @@ export const ComponentTab: (props: { title: string; Content: () => JSX.Element; ContextProvider?: ((props: any) => JSX.Element) | undefined; -}) => JSX.Element; +}) => JSX_2.Element; // @public (undocumented) export const ComponentTabs: (props: { @@ -72,7 +73,7 @@ export const ComponentTabs: (props: { label: string; Component: () => JSX.Element; }[]; -}) => JSX.Element; +}) => JSX_2.Element; // @public @deprecated (undocumented) export const createCardExtension: typeof createCardExtension_2; @@ -80,7 +81,7 @@ export const createCardExtension: typeof createCardExtension_2; // @public export const CustomHomepageGrid: ( props: CustomHomepageGridProps, -) => JSX.Element; +) => React_2.JSX.Element; // @public export type CustomHomepageGridProps = { @@ -102,41 +103,41 @@ export type CustomHomepageGridProps = { export const HeaderWorldClock: (props: { clockConfigs: ClockConfig[]; customTimeFormat?: Intl.DateTimeFormatOptions | undefined; -}) => JSX.Element | null; +}) => JSX_2.Element | null; // @public export const HomePageCompanyLogo: (props: { logo?: ReactNode; className?: string | undefined; -}) => JSX.Element; +}) => JSX_2.Element; // @public (undocumented) export const HomepageCompositionRoot: (props: { title?: string | undefined; children?: ReactNode; -}) => JSX.Element; +}) => JSX_2.Element; // @public (undocumented) export const HomePageRandomJoke: ( props: CardExtensionProps_2<{ defaultCategory?: 'any' | 'programming' | undefined; }>, -) => JSX.Element; +) => JSX_2.Element; // @public export const HomePageStarredEntities: ( props: CardExtensionProps_2, -) => JSX.Element; +) => JSX_2.Element; // @public export const HomePageToolkit: ( props: CardExtensionProps_2, -) => JSX.Element; +) => JSX_2.Element; // @public export const HomePageVisitedByType: ( props: CardExtensionProps_2, -) => JSX.Element; +) => JSX_2.Element; // @public (undocumented) export const homePlugin: BackstagePlugin< @@ -167,7 +168,7 @@ export const SettingsModal: (props: { close: Function; componentName?: string | undefined; children: JSX.Element; -}) => JSX.Element; +}) => JSX_2.Element; // @public (undocumented) export const TemplateBackstageLogo: (props: { @@ -175,10 +176,10 @@ export const TemplateBackstageLogo: (props: { svg: string; path: string; }; -}) => JSX.Element; +}) => React_2.JSX.Element; // @public (undocumented) -export const TemplateBackstageLogoIcon: () => JSX.Element; +export const TemplateBackstageLogoIcon: () => React_2.JSX.Element; // @public (undocumented) export type Tool = { @@ -192,8 +193,6 @@ 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; @@ -204,8 +203,9 @@ export type Visit = { 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 VisitedByTypeKind = 'recent' | 'top'; + // @public (undocumented) export type VisitedByTypeProps = { visits?: Array; @@ -215,8 +215,6 @@ export type VisitedByTypeProps = { 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; @@ -224,18 +222,12 @@ export type VisitFilter = { 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 + listUserVisits(queryParams?: VisitsApiQueryParams): Promise; 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; @@ -243,22 +235,16 @@ export type VisitsApiQueryParams = { 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.Element; +}: WelcomeTitleLanguageProps) => JSX_2.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/src/api/VisitsApi.ts b/plugins/home/src/api/VisitsApi.ts index a00897fb78..8d5ae3f8cb 100644 --- a/plugins/home/src/api/VisitsApi.ts +++ b/plugins/home/src/api/VisitsApi.ts @@ -18,7 +18,7 @@ import { createApiRef } from '@backstage/core-plugin-api'; import { JsonValue } from '@backstage/types'; /** - @experimental + * @public * Model for a visit entity. */ export type Visit = { @@ -48,6 +48,7 @@ export type Visit = { entityRef?: string; }; +/** @public */ export type VisitFilter = { field: string; operator: '<' | '<=' | '==' | '>' | '>=' | 'contains'; @@ -55,7 +56,7 @@ export type VisitFilter = { }; /** - @experimental + * @public * This data structure represents the parameters associated with search queries for visits. */ export type VisitsApiQueryParams = { @@ -85,22 +86,23 @@ export type VisitsApiQueryParams = { }; /** - * @experimental + * @public * Visits API public contract. */ export interface VisitsApi { /** * Persist a new visit. - * @param pageVisit | a new visit data. + * @param pageVisit - a new visit data */ saveVisit(pageVisit: Omit): Promise; /** * Get the logged user visits. - * @param queryParams | optional search query params. + * @param queryParams - optional search query params. */ - listUserVisits(queryParams: VisitsApiQueryParams): Promise; + listUserVisits(queryParams?: VisitsApiQueryParams): Promise; } +/** @public */ export const visitsApiRef = createApiRef({ id: 'homepage.visits', }); diff --git a/plugins/home/src/components/VisitList/VisitList.test.tsx b/plugins/home/src/components/VisitList/VisitList.test.tsx new file mode 100644 index 0000000000..077c428706 --- /dev/null +++ b/plugins/home/src/components/VisitList/VisitList.test.tsx @@ -0,0 +1,160 @@ +/* + * 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 { VisitList } from './VisitList'; +import { render } from '@testing-library/react'; +import { BrowserRouter } from 'react-router-dom'; + +describe('', () => { + it('renders with mandatory parameters', async () => { + const { getByText } = await render( + , + ); + expect(getByText('My title')).toBeInTheDocument(); + }); + + it('renders skeleton when loading is true', async () => { + const { container } = await render( + , + ); + expect(container.querySelectorAll('li')).toHaveLength(8); + expect(container.querySelectorAll('.MuiSkeleton-root')).toHaveLength(16); + }); + + it('renders specified amount of items', async () => { + const { container } = await render( + , + ); + expect(container.querySelectorAll('li')).toHaveLength(2); + }); + + it('renders some items hidden', async () => { + const { container } = await render( + , + ); + expect(container.querySelectorAll('li')[0]).toBeVisible(); + expect(container.querySelectorAll('li')[1]).not.toBeVisible(); + }); + + it('renders all items when not collapsed', async () => { + const { container } = await render( + , + ); + expect(container.querySelectorAll('li')[0]).toBeVisible(); + expect(container.querySelectorAll('li')[1]).toBeVisible(); + }); + + it('renders visit with time-ago', async () => { + const { container, getByText } = await render( + + + , + , + ); + expect(container.querySelectorAll('li')).toHaveLength(1); + expect(getByText('Explore Backstage')).toBeInTheDocument(); + expect(getByText('1 day ago')).toBeInTheDocument(); + }); + + it('renders visit with hits', async () => { + const { container, getByText } = await render( + + + , + , + ); + expect(container.querySelectorAll('li')).toHaveLength(1); + expect(getByText('Explore Backstage')).toBeInTheDocument(); + expect(getByText('35 times')).toBeInTheDocument(); + }); + + it('renders text warning about few items', async () => { + const { getByText } = await render( + + + , + , + ); + expect( + getByText('The more pages you visit, the more pages will appear here.'), + ).toBeInTheDocument(); + }); + + it('renders text warning about no items', async () => { + const { getByText } = await render( + + , + , + ); + expect(getByText('There are no visits to show yet.')).toBeInTheDocument(); + }); +}); diff --git a/plugins/home/src/components/VisitList/VisitList.tsx b/plugins/home/src/components/VisitList/VisitList.tsx index e118058857..f1dc855d9d 100644 --- a/plugins/home/src/components/VisitList/VisitList.tsx +++ b/plugins/home/src/components/VisitList/VisitList.tsx @@ -30,21 +30,21 @@ const useStyles = makeStyles(_theme => ({ })); export const VisitList = ({ - visits, title, detailType, + visits = [], numVisitsOpen = 3, numVisitsTotal = 8, collapsed = true, loading = false, }: { - visits: Array; title: string; detailType: ItemDetailType; + visits?: Visit[]; numVisitsOpen?: number; numVisitsTotal?: number; - collapsed: boolean; - loading: boolean; + collapsed?: boolean; + loading?: boolean; }) => { const classes = useStyles(); diff --git a/plugins/home/src/homePageComponents/RecentlyVisited/Context.tsx b/plugins/home/src/homePageComponents/RecentlyVisited/Context.tsx deleted file mode 100644 index 44c8fe322b..0000000000 --- a/plugins/home/src/homePageComponents/RecentlyVisited/Context.tsx +++ /dev/null @@ -1,92 +0,0 @@ -/* - * 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.test.tsx b/plugins/home/src/homePageComponents/RecentlyVisited/RecentlyVisited.test.tsx deleted file mode 100644 index 5ccab63fa8..0000000000 --- a/plugins/home/src/homePageComponents/RecentlyVisited/RecentlyVisited.test.tsx +++ /dev/null @@ -1,26 +0,0 @@ -/* - * 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 { RecentlyVisited } from './RecentlyVisited'; -import { renderInTestApp } from '@backstage/test-utils'; - -describe('', () => { - it('should render', async () => { - const { getByText } = await renderInTestApp(); - expect(getByText('RecentlyVisited')).toBeInTheDocument(); - }); -}); diff --git a/plugins/home/src/homePageComponents/RecentlyVisited/Actions.tsx b/plugins/home/src/homePageComponents/VisitedByType/Actions.tsx similarity index 100% rename from plugins/home/src/homePageComponents/RecentlyVisited/Actions.tsx rename to plugins/home/src/homePageComponents/VisitedByType/Actions.tsx diff --git a/plugins/home/src/homePageComponents/VisitedByType/Content.test.tsx b/plugins/home/src/homePageComponents/VisitedByType/Content.test.tsx new file mode 100644 index 0000000000..329bf4e845 --- /dev/null +++ b/plugins/home/src/homePageComponents/VisitedByType/Content.test.tsx @@ -0,0 +1,136 @@ +/* + * 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 { Content } from './Content'; +import { TestApiProvider, renderInTestApp } from '@backstage/test-utils'; +import { visitsApiRef } from '../../api'; +import { ContextProvider } from './Context'; +import { waitFor } from '@testing-library/react'; + +const visits = [ + { + id: 'explore', + name: 'Explore Backstage', + pathname: '/explore', + hits: 35, + timestamp: Date.now() - 86400_000, + }, +]; + +const mockVisitsApi = { + saveVisit: async () => {}, + listUserVisits: async () => visits, +}; + +describe('', () => { + it('renders', async () => { + const { getByText } = await renderInTestApp( + + + + + , + ); + expect(getByText('Recently Visited')).toBeInTheDocument(); + await waitFor(() => + expect(getByText('Explore Backstage')).toBeInTheDocument(), + ); + }); + + it('allows visits to be overridden', async () => { + const { getByText } = await renderInTestApp( + + + + + , + ); + expect(getByText('Recently Visited')).toBeInTheDocument(); + await waitFor(() => expect(getByText('Tech Radar')).toBeInTheDocument()); + }); + + it('allows loading to be overridden', async () => { + const { container } = await renderInTestApp( + + + + + , + ); + expect(container.querySelector('.MuiSkeleton-root')).toBeInTheDocument(); + }); + + it('allows number of items to be specified', async () => { + const { container } = await renderInTestApp( + + + + + , + ); + expect(container.querySelectorAll('li')).toHaveLength(2); + expect(container.querySelectorAll('li')[0]).toBeVisible(); + expect(container.querySelectorAll('li')[1]).not.toBeVisible(); + }); +}); + +describe('', () => { + it('renders', async () => { + const { getByText } = await renderInTestApp( + + + + + , + ); + expect(getByText('Top Visited')).toBeInTheDocument(); + await waitFor(() => + expect(getByText('Explore Backstage')).toBeInTheDocument(), + ); + }); +}); diff --git a/plugins/home/src/homePageComponents/RecentlyVisited/Content.tsx b/plugins/home/src/homePageComponents/VisitedByType/Content.tsx similarity index 59% rename from plugins/home/src/homePageComponents/RecentlyVisited/Content.tsx rename to plugins/home/src/homePageComponents/VisitedByType/Content.tsx index d9b6128cc4..62b0de0d7d 100644 --- a/plugins/home/src/homePageComponents/RecentlyVisited/Content.tsx +++ b/plugins/home/src/homePageComponents/VisitedByType/Content.tsx @@ -15,17 +15,22 @@ */ import React, { useEffect } from 'react'; -import { RecentlyVisited } from './RecentlyVisited'; +import { VisitedByType } from './VisitedByType'; import { Visit, visitsApiRef } from '../../api/VisitsApi'; -import { useContext } from './Context'; +import { ContextValueOnly, useContext } from './Context'; import { useApi } from '@backstage/core-plugin-api'; import useAsync from 'react-use/lib/useAsync'; -export type RecentlyVisitedProps = { +/** @public */ +export type VisitedByTypeKind = 'recent' | 'top'; + +/** @public */ +export type VisitedByTypeProps = { visits?: Array; numVisitsOpen?: number; numVisitsTotal?: number; loading?: boolean; + kind: VisitedByTypeKind; }; /** @@ -37,40 +42,44 @@ export const Content = ({ numVisitsOpen, numVisitsTotal, loading, -}: RecentlyVisitedProps) => { - const { setVisits, setNumVisitsOpen, setNumVisitsTotal, setLoading } = - useContext(); + kind, +}: VisitedByTypeProps) => { + const { setContext, setVisits, setLoading } = useContext(); // Allows behavior override from properties useEffect(() => { + const context: Partial = {}; + context.kind = kind; if (visits) { - setVisits(visits); - setLoading(false); + context.visits = visits; + context.loading = false; } else if (loading) { - setLoading(loading); + context.loading = loading; } - if (numVisitsOpen) setNumVisitsOpen(numVisitsOpen); - if (numVisitsTotal) setNumVisitsTotal(numVisitsTotal); - }, [ - visits, - numVisitsOpen, - numVisitsTotal, - loading, - setVisits, - setNumVisitsOpen, - setNumVisitsTotal, - setLoading, - ]); + if (numVisitsOpen) context.numVisitsOpen = numVisitsOpen; + if (numVisitsTotal) context.numVisitsTotal = numVisitsTotal; + setContext(state => ({ ...state, ...context })); + }, [setContext, kind, visits, loading, numVisitsOpen, numVisitsTotal]); + // 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 + if (!visits && !loading && kind === 'recent') { + return await visitsApi .listUserVisits({ limit: numVisitsTotal ?? 8, orderBy: { timestamp: 'desc' }, }) .then(setVisits); } + if (!visits && !loading && kind === 'top') { + return await visitsApi + .listUserVisits({ + limit: numVisitsTotal ?? 8, + orderBy: { hits: 'desc' }, + }) + .then(setVisits); + } + return undefined; }, [visitsApi, visits, loading, setVisits]); useEffect(() => { if (!loading) { @@ -78,5 +87,5 @@ export const Content = ({ } }, [loading, setLoading, reqLoading]); - return ; + return ; }; diff --git a/plugins/home/src/homePageComponents/VisitedByType/Context.tsx b/plugins/home/src/homePageComponents/VisitedByType/Context.tsx new file mode 100644 index 0000000000..0d1c2100f2 --- /dev/null +++ b/plugins/home/src/homePageComponents/VisitedByType/Context.tsx @@ -0,0 +1,121 @@ +/* + * 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, { Dispatch, SetStateAction, createContext, useMemo } from 'react'; +import { Visit } from '../../api/VisitsApi'; +import { VisitedByTypeKind } from './Content'; + +export type ContextValueOnly = { + collapsed: boolean; + numVisitsOpen: number; + numVisitsTotal: number; + visits: Array; + loading: boolean; + kind: VisitedByTypeKind; +}; + +export type ContextValue = ContextValueOnly & { + setCollapsed: Dispatch>; + setNumVisitsOpen: Dispatch>; + setNumVisitsTotal: Dispatch>; + setVisits: Dispatch>>; + setLoading: Dispatch>; + setKind: Dispatch>; + setContext: Dispatch>; +}; + +const defaultContextValueOnly: ContextValueOnly = { + collapsed: true, + numVisitsOpen: 3, + numVisitsTotal: 8, + visits: [], + loading: true, + kind: 'recent', +}; + +export const defaultContextValue: ContextValue = { + ...defaultContextValueOnly, + setCollapsed: () => {}, + setNumVisitsOpen: () => {}, + setNumVisitsTotal: () => {}, + setVisits: () => {}, + setLoading: () => {}, + setKind: () => {}, + setContext: () => {}, +}; + +export const Context = createContext(defaultContextValue); + +const getFilteredSet = + ( + setContext: Dispatch>, + contextKey: keyof ContextValueOnly, + ) => + (e: SetStateAction) => + setContext(state => ({ + ...state, + [contextKey]: typeof e === 'function' ? e(state[contextKey]) : e, + })); + +export const ContextProvider = ({ children }: { children: JSX.Element }) => { + const [context, setContext] = React.useState( + defaultContextValueOnly, + ); + const { + setCollapsed, + setNumVisitsOpen, + setNumVisitsTotal, + setVisits, + setLoading, + setKind, + } = useMemo( + () => ({ + setCollapsed: getFilteredSet(setContext, 'collapsed'), + setNumVisitsOpen: getFilteredSet(setContext, 'numVisitsOpen'), + setNumVisitsTotal: getFilteredSet(setContext, 'numVisitsTotal'), + setVisits: getFilteredSet(setContext, 'visits'), + setLoading: getFilteredSet(setContext, 'loading'), + setKind: getFilteredSet(setContext, 'kind'), + }), + [setContext], + ); + + const value: ContextValue = { + ...context, + setContext, + setCollapsed, + setNumVisitsOpen, + setNumVisitsTotal, + setVisits, + setLoading, + setKind, + }; + + return {children}; +}; + +export const useContext = () => { + const value = React.useContext(Context); + + if (value === undefined) + throw new Error( + 'VisitedByType 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/VisitedByType/HomePageVisitedByType.stories.tsx similarity index 65% rename from plugins/home/src/homePageComponents/RecentlyVisited/RecentlyVisited.stories.tsx rename to plugins/home/src/homePageComponents/VisitedByType/HomePageVisitedByType.stories.tsx index cd3db0eb7b..64698da921 100644 --- a/plugins/home/src/homePageComponents/RecentlyVisited/RecentlyVisited.stories.tsx +++ b/plugins/home/src/homePageComponents/VisitedByType/HomePageVisitedByType.stories.tsx @@ -18,10 +18,17 @@ import React from 'react'; import { TestApiProvider, wrapInTestApp } from '@backstage/test-utils'; import { ComponentType, PropsWithChildren } from 'react'; import { Grid } from '@material-ui/core'; -import { HomePageRecentlyVisited } from '../../plugin'; +import { HomePageVisitedByType } from '../../plugin'; import { Visit, visitsApiRef } from '../../api/VisitsApi'; const visits: Array = [ + { + id: 'tech-radar', + name: 'Tech Radar', + pathname: '/tech-radar', + hits: 40, + timestamp: Date.now() - 360_000, + }, { id: 'explore', name: 'Explore Backstage', @@ -73,17 +80,10 @@ const visits: Array = [ id: 'hello-world', name: 'Hello World gRPC', pathname: '/catalog/default/api/hello-world', - hits: 5, + hits: 1, 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 = { @@ -92,7 +92,7 @@ const mockVisitsApi = { }; export default { - title: 'Plugins/Home/Components/RecentlyVisited', + title: 'Plugins/Home/Components/VisitedByType', decorators: [ (Story: ComponentType>) => wrapInTestApp( @@ -103,42 +103,96 @@ export default { ], }; -export const Default = () => { +export const RecentlyDefault = () => { return ( - + ); }; -export const Empty = () => { +export const RecentlyEmpty = () => { return ( - + ); }; -export const FewItems = () => { +export const RecentlyFewItems = () => { return ( - + ); }; -export const MoreItems = () => { +export const RecentlyMoreItems = () => { return ( - + ); }; -export const Loading = () => { +export const RecentlyLoading = () => { return ( - + + + ); +}; + +export const TopDefault = () => { + return ( + + + + ); +}; + +export const TopEmpty = () => { + return ( + + + + ); +}; + +export const TopFewItems = () => { + return ( + + + + ); +}; + +export const TopMoreItems = () => { + return ( + + + + ); +}; + +export const TopLoading = () => { + return ( + + ); }; diff --git a/plugins/home/src/homePageComponents/VisitedByType/VisitedByType.test.tsx b/plugins/home/src/homePageComponents/VisitedByType/VisitedByType.test.tsx new file mode 100644 index 0000000000..9e363f55aa --- /dev/null +++ b/plugins/home/src/homePageComponents/VisitedByType/VisitedByType.test.tsx @@ -0,0 +1,89 @@ +/* + * 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 { VisitedByType } from './VisitedByType'; +import { Context, defaultContextValue } from './Context'; +import { renderInTestApp } from '@backstage/test-utils'; +import { waitFor } from '@testing-library/react'; + +describe(' kind="top"', () => { + it('should render', async () => { + const { getByText } = await renderInTestApp( + + + , + ); + expect(getByText('Top Visited')).toBeInTheDocument(); + }); + it('should display hits', async () => { + const { getByText } = await renderInTestApp( + + + , + ); + await waitFor(() => expect(getByText('40 times')).toBeInTheDocument()); + }); +}); + +describe(' kind="recent"', () => { + it('should render', async () => { + const { getByText } = await renderInTestApp( + + + , + ); + expect(getByText('Recently Visited')).toBeInTheDocument(); + }); + it('should display how long ago a visit happened', async () => { + const { getByText } = await renderInTestApp( + + + , + ); + await waitFor(() => expect(getByText('1 day ago')).toBeInTheDocument()); + }); +}); diff --git a/plugins/home/src/homePageComponents/RecentlyVisited/RecentlyVisited.tsx b/plugins/home/src/homePageComponents/VisitedByType/VisitedByType.tsx similarity index 85% rename from plugins/home/src/homePageComponents/RecentlyVisited/RecentlyVisited.tsx rename to plugins/home/src/homePageComponents/VisitedByType/VisitedByType.tsx index 121d15b1d2..23d7f3fffd 100644 --- a/plugins/home/src/homePageComponents/RecentlyVisited/RecentlyVisited.tsx +++ b/plugins/home/src/homePageComponents/VisitedByType/VisitedByType.tsx @@ -17,19 +17,16 @@ import React from 'react'; import { VisitList } from '../../components/VisitList'; import { useContext } from './Context'; -/** - * Display recently visited pages for the homepage - * @public - */ -export const RecentlyVisited = () => { - const { collapsed, numVisitsOpen, numVisitsTotal, visits, loading } = + +export const VisitedByType = () => { + const { collapsed, numVisitsOpen, numVisitsTotal, visits, loading, kind } = useContext(); return ( ({ - name: 'HomePageRecentlyVisited', - components: () => import('./homePageComponents/RecentlyVisited'), +export const HomePageVisitedByType = homePlugin.provide( + createCardExtension({ + name: 'HomePageVisitedByType', + components: () => import('./homePageComponents/VisitedByType'), }), ); From 6d30b374a07fa910a315c85ad71daddcf1669025 Mon Sep 17 00:00:00 2001 From: Renan Mendes Carvalho Date: Fri, 8 Sep 2023 11:07:50 +0200 Subject: [PATCH 05/28] fix(home): Use luxon instead of date-fns Signed-off-by: Renan Mendes Carvalho --- plugins/home/package.json | 2 +- .../src/components/VisitList/ItemDetail.tsx | 32 ++++++++++--------- yarn.lock | 4 +-- 3 files changed, 20 insertions(+), 18 deletions(-) diff --git a/plugins/home/package.json b/plugins/home/package.json index 9a692557b1..f69db9d4ee 100644 --- a/plugins/home/package.json +++ b/plugins/home/package.json @@ -50,8 +50,8 @@ "@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", + "luxon": "^3.4.3", "react-grid-layout": "^1.3.4", "react-resizable": "^3.0.4", "react-use": "^17.2.4", diff --git a/plugins/home/src/components/VisitList/ItemDetail.tsx b/plugins/home/src/components/VisitList/ItemDetail.tsx index 5bdbdc0db4..3f55557f73 100644 --- a/plugins/home/src/components/VisitList/ItemDetail.tsx +++ b/plugins/home/src/components/VisitList/ItemDetail.tsx @@ -17,7 +17,7 @@ import React from 'react'; import { Typography } from '@material-ui/core'; import { Visit } from '../../api/VisitsApi'; -import { format, formatDistance, formatISO, isToday } from 'date-fns'; +import { DateTime } from 'luxon'; const ItemDetailHits = ({ visit }: { visit: Visit }) => ( @@ -25,20 +25,22 @@ const ItemDetailHits = ({ visit }: { visit: Visit }) => ( ); -const ItemDetailTimeAgo = ({ visit }: { visit: Visit }) => ( - - {isToday(visit.timestamp) - ? format(visit.timestamp, 'HH:mm') - : formatDistance(visit.timestamp, Date.now(), { - addSuffix: true, - })} - -); +const ItemDetailTimeAgo = ({ visit }: { visit: Visit }) => { + const visitDate = DateTime.fromMillis(visit.timestamp); + + return ( + + {visitDate >= DateTime.now().startOf('day') + ? visitDate.toFormat('HH:mm') + : visitDate.toRelative()} + + ); +}; export type ItemDetailType = 'time-ago' | 'hits'; diff --git a/yarn.lock b/yarn.lock index 65ba1a0ec0..c9aa122ad9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7409,8 +7409,8 @@ __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 + luxon: ^3.4.3 msw: ^1.0.0 react-grid-layout: ^1.3.4 react-resizable: ^3.0.4 @@ -31784,7 +31784,7 @@ __metadata: languageName: node linkType: hard -"luxon@npm:^3.0.0, luxon@npm:^3.3.0": +"luxon@npm:^3.0.0, luxon@npm:^3.3.0, luxon@npm:^3.4.3": version: 3.4.3 resolution: "luxon@npm:3.4.3" checksum: 3eade81506224d038ed24035a0cd0dd4887848d7eba9361dce9ad8ef81380596a68153240be3988721f9690c624fb449fcf8fd8c3fc0681a6a8496faf48e92a3 From cac684376ed62dc2864a7a100837df862f7f2957 Mon Sep 17 00:00:00 2001 From: Renan Mendes Carvalho Date: Fri, 8 Sep 2023 11:19:47 +0200 Subject: [PATCH 06/28] refactor(home): Change parameters on saveVisit Result of a feedback on: https://github.com/backstage/backstage/pull/19645#discussion_r1313865318 Signed-off-by: Renan Mendes Carvalho --- plugins/home/api-report.md | 7 ++++++- plugins/home/src/api/VisitsApi.ts | 10 +++++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/plugins/home/api-report.md b/plugins/home/api-report.md index 536032a8eb..8c0177e251 100644 --- a/plugins/home/api-report.md +++ b/plugins/home/api-report.md @@ -225,7 +225,7 @@ export type VisitFilter = { // @public export interface VisitsApi { listUserVisits(queryParams?: VisitsApiQueryParams): Promise; - saveVisit(pageVisit: Omit): Promise; + saveVisit(saveParams: VisitsApiSaveParams): Promise; } // @public @@ -238,6 +238,11 @@ export type VisitsApiQueryParams = { // @public (undocumented) export const visitsApiRef: ApiRef; +// @public +export type VisitsApiSaveParams = { + visit: Omit; +}; + // @public export const WelcomeTitle: ({ language, diff --git a/plugins/home/src/api/VisitsApi.ts b/plugins/home/src/api/VisitsApi.ts index 8d5ae3f8cb..c461a5159b 100644 --- a/plugins/home/src/api/VisitsApi.ts +++ b/plugins/home/src/api/VisitsApi.ts @@ -85,6 +85,14 @@ export type VisitsApiQueryParams = { filterBy?: VisitFilter[]; }; +/** + * @public + * This data structure represents the parameters associated with saving visits. + */ +export type VisitsApiSaveParams = { + visit: Omit; +}; + /** * @public * Visits API public contract. @@ -94,7 +102,7 @@ export interface VisitsApi { * Persist a new visit. * @param pageVisit - a new visit data */ - saveVisit(pageVisit: Omit): Promise; + saveVisit(saveParams: VisitsApiSaveParams): Promise; /** * Get the logged user visits. * @param queryParams - optional search query params. From c0700220c97500b12ab1d65bde2f2abead4281ba Mon Sep 17 00:00:00 2001 From: Renan Mendes Carvalho Date: Thu, 24 Aug 2023 17:56:59 +0200 Subject: [PATCH 07/28] feature(home-plugin): Creates the component This patch creates the component to be used on the app to register user visits enabling the homepage Recently Visited and Top Visited experience () to be functional. Signed-off-by: Renan Mendes Carvalho --- plugins/home/api-report.md | 62 +++++- plugins/home/package.json | 1 + plugins/home/src/api/VisitsApi.ts | 36 ++-- .../src/components/VisitListener.test.tsx | 203 ++++++++++++++++++ plugins/home/src/components/VisitListener.tsx | 163 ++++++++++++++ plugins/home/src/components/index.ts | 1 + .../VisitedByType/Content.test.tsx | 2 +- .../VisitedByType/Content.tsx | 4 +- .../HomePageVisitedByType.stories.tsx | 2 +- yarn.lock | 1 + 10 files changed, 447 insertions(+), 28 deletions(-) create mode 100644 plugins/home/src/components/VisitListener.test.tsx create mode 100644 plugins/home/src/components/VisitListener.tsx diff --git a/plugins/home/api-report.md b/plugins/home/api-report.md index 8c0177e251..acc073f685 100644 --- a/plugins/home/api-report.md +++ b/plugins/home/api-report.md @@ -14,13 +14,15 @@ 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 { JsonValue } from '@backstage/types'; +import { Dispatch } from 'react'; import { JSX as JSX_2 } from 'react'; import { default as React_2 } from 'react'; import { ReactElement } from 'react'; import { ReactNode } from 'react'; import { RendererProps as RendererProps_2 } from '@backstage/plugin-home-react'; import { RouteRef } from '@backstage/core-plugin-api'; +import { SetStateAction } from 'react'; +import { stringifyEntityRef } from '@backstage/catalog-model'; // @public export type Breakpoint = 'xxs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl'; @@ -99,6 +101,25 @@ export type CustomHomepageGridProps = { preventCollision?: boolean; }; +// @public +export const DoNotTrack: ({ + children, +}: { + children?: ReactNode; +}) => JSX.Element; + +// @public +export const getToEntityRef: ({ + rootPath, + stringifyEntityRefImpl, +}?: { + rootPath?: string | undefined; + stringifyEntityRefImpl?: typeof stringifyEntityRef | undefined; +}) => ({ pathname }: { pathname: string }) => string | undefined; + +// @public +export const getVisitName: (document: Document) => () => string; + // @public export const HeaderWorldClock: (props: { clockConfigs: ClockConfig[]; @@ -193,6 +214,9 @@ export type ToolkitContentProps = { tools: Tool[]; }; +// @public +export const useVisitListener: () => VisitListenerContextValue; + // @public export type Visit = { id: string; @@ -215,24 +239,46 @@ export type VisitedByTypeProps = { kind: VisitedByTypeKind; }; +// @public +export const VisitListener: ({ + children, + toEntityRef, + visitName, +}: { + children?: React_2.ReactNode; + toEntityRef?: + | (({ pathname }: { pathname: string }) => string | undefined) + | undefined; + visitName?: (({ pathname }: { pathname: string }) => string) | undefined; +}) => JSX.Element; + // @public (undocumented) -export type VisitFilter = { - field: string; - operator: '<' | '<=' | '==' | '>' | '>=' | 'contains'; - value: JsonValue; +export const VisitListenerContext: React_2.Context; + +// @public (undocumented) +export type VisitListenerContextValue = { + doNotTrack: boolean; + setDoNotTrack: Dispatch>; }; // @public export interface VisitsApi { listUserVisits(queryParams?: VisitsApiQueryParams): Promise; - saveVisit(saveParams: VisitsApiSaveParams): Promise; + saveVisit(saveParams: VisitsApiSaveParams): Promise; } // @public export type VisitsApiQueryParams = { limit?: number; - orderBy?: Record; - filterBy?: VisitFilter[]; + orderBy?: Array<{ + field: keyof Visit; + direction: 'asc' | 'desc'; + }>; + filterBy?: Array<{ + field: keyof Visit; + operator: '<' | '<=' | '==' | '>' | '>=' | 'contains'; + value: string | number; + }>; }; // @public (undocumented) diff --git a/plugins/home/package.json b/plugins/home/package.json index f69db9d4ee..6a9c5306c1 100644 --- a/plugins/home/package.json +++ b/plugins/home/package.json @@ -70,6 +70,7 @@ "@testing-library/dom": "^8.0.0", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^12.1.3", + "@testing-library/react-hooks": "^8.0.1", "@testing-library/user-event": "^14.0.0", "@types/react-grid-layout": "^1.3.2", "msw": "^1.0.0" diff --git a/plugins/home/src/api/VisitsApi.ts b/plugins/home/src/api/VisitsApi.ts index c461a5159b..4ffb37e8b6 100644 --- a/plugins/home/src/api/VisitsApi.ts +++ b/plugins/home/src/api/VisitsApi.ts @@ -15,7 +15,6 @@ */ import { createApiRef } from '@backstage/core-plugin-api'; -import { JsonValue } from '@backstage/types'; /** * @public @@ -48,13 +47,6 @@ export type Visit = { entityRef?: string; }; -/** @public */ -export type VisitFilter = { - field: string; - operator: '<' | '<=' | '==' | '>' | '>=' | 'contains'; - value: JsonValue; -}; - /** * @public * This data structure represents the parameters associated with search queries for visits. @@ -65,24 +57,36 @@ export type VisitsApiQueryParams = { */ 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. + * Allows ordering visits on entity properties. * @example * Sort ascending by the timestamp field. * ``` - * { orderBy: { timestamp: 'asc' } } + * { orderBy: [{ field: 'timestamp', direction: 'asc' }] } * ``` */ - orderBy?: Record; + orderBy?: Array<{ + field: keyof Visit; + direction: 'asc' | 'desc'; + }>; /** - * Allows filtering visits on number of hits, timestamp and/or entityRef attributes. + * Allows filtering visits on entity properties. * @example * Most popular docs on the past 7 days * ``` - * { orderBy: { hits: 'desc' }, filterBy: [{ field: 'timestamp', operator: '>=', value: }, { field: 'entityRef', operator: 'contains', value: 'docs' }] } + * { + * orderBy: [{ field: 'hits', direction: 'desc' }], + * filterBy: [ + * { field: 'timestamp', operator: '>=', value: }, + * { field: 'entityRef', operator: 'contains', value: 'docs' } + * ] + * } * ``` */ - filterBy?: VisitFilter[]; + filterBy?: Array<{ + field: keyof Visit; + operator: '<' | '<=' | '==' | '>' | '>=' | 'contains'; + value: string | number; + }>; }; /** @@ -102,7 +106,7 @@ export interface VisitsApi { * Persist a new visit. * @param pageVisit - a new visit data */ - saveVisit(saveParams: VisitsApiSaveParams): Promise; + saveVisit(saveParams: VisitsApiSaveParams): Promise; /** * Get the logged user visits. * @param queryParams - optional search query params. diff --git a/plugins/home/src/components/VisitListener.test.tsx b/plugins/home/src/components/VisitListener.test.tsx new file mode 100644 index 0000000000..81cff08a09 --- /dev/null +++ b/plugins/home/src/components/VisitListener.test.tsx @@ -0,0 +1,203 @@ +/* + * 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 { TestApiProvider, renderInTestApp } from '@backstage/test-utils'; +import { Visit, visitsApiRef } from '../api'; +import { DoNotTrack, VisitListener, useVisitListener } from './VisitListener'; +import { waitFor } from '@testing-library/react'; +import { act, renderHook } from '@testing-library/react-hooks'; +import { MemoryRouter } from 'react-router-dom'; + +const visits: Array = [ + { + id: 'tech-radar', + name: 'Tech Radar', + pathname: '/tech-radar', + hits: 40, + timestamp: Date.now() - 360_000, + }, + { + 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', + }, +]; + +const mockVisitsApi = { + saveVisit: jest.fn(async () => visits[0]), + listVisits: jest.fn(async () => visits), +}; + +describe('', () => { + afterEach(jest.resetAllMocks); + + it('registers a visit', async () => { + jest.spyOn(document, 'title', 'get').mockReturnValue('MockedTitle'); + const pathname = '/catalog/default/component/playback-order'; + + await renderInTestApp( + + + , + { routeEntries: [pathname] }, + ); + + await waitFor(() => + expect(mockVisitsApi.saveVisit).toHaveBeenCalledTimes(1), + ); + expect(mockVisitsApi.saveVisit).toHaveBeenCalledWith({ + visit: { + pathname, + entityRef: 'component:default/playback-order', + name: 'MockedTitle', + }, + }); + }); + + it('renders its children', async () => { + const { getByTestId } = await renderInTestApp( + + +
child
+
+
, + ); + + expect(getByTestId('child')).toBeTruthy(); + }); + + it('is able to override how visit names are defined', async () => { + jest.spyOn(document, 'title', 'get').mockReturnValue('MockedTitle'); + const pathname = '/catalog/default/component/playback-order'; + + const visitNameOverride = ({ pathname: path }: { pathname: string }) => + path; + + await renderInTestApp( + + + , + { routeEntries: [pathname] }, + ); + + await waitFor(() => + expect(mockVisitsApi.saveVisit).toHaveBeenCalledWith({ + visit: { + pathname, + entityRef: 'component:default/playback-order', + name: pathname, + }, + }), + ); + }); + + it('is able to override how entityRefs are defined', async () => { + jest.spyOn(document, 'title', 'get').mockReturnValue('MockedTitle'); + const pathname = '/catalog/default/component/playback-order'; + + const toEntityRefOverride = ({ pathname: path }: { pathname: string }) => + path; + + await renderInTestApp( + + + , + { routeEntries: [pathname] }, + ); + + await waitFor(() => + expect(mockVisitsApi.saveVisit).toHaveBeenCalledWith({ + visit: { + pathname, + entityRef: pathname, + name: 'MockedTitle', + }, + }), + ); + }); +}); + +describe('', () => { + afterEach(jest.resetAllMocks); + + it("doesn't register a visit", async () => { + const requestAnimationFrameSpy = jest.spyOn( + window, + 'requestAnimationFrame', + ); + await renderInTestApp( + + + + + , + ); + await waitFor(() => expect(requestAnimationFrameSpy).toHaveBeenCalled()); + expect(mockVisitsApi.saveVisit).not.toHaveBeenCalled(); + }); + + it('renders its children', async () => { + const requestAnimationFrameSpy = jest.spyOn( + window, + 'requestAnimationFrame', + ); + const { getByTestId } = await renderInTestApp( + + + +
child
+
+
+
, + ); + await waitFor(() => expect(requestAnimationFrameSpy).toHaveBeenCalled()); + expect(getByTestId('child')).toBeTruthy(); + }); +}); + +describe('useVisitListener()', () => { + it('returns the default context', () => { + const { result } = renderHook(() => useVisitListener()); + expect(result.current.doNotTrack).toBeFalsy(); + expect(result.current.setDoNotTrack).toBeInstanceOf(Function); + }); + + it('changes the doNotTrack flag', () => { + const { result } = renderHook(() => useVisitListener(), { + wrapper: ({ children }) => ( + + + {children} + + + ), + }); + act(() => { + result.current.setDoNotTrack(true); + }); + expect(result.current.doNotTrack).toBeTruthy(); + }); +}); diff --git a/plugins/home/src/components/VisitListener.tsx b/plugins/home/src/components/VisitListener.tsx new file mode 100644 index 0000000000..cb92b47738 --- /dev/null +++ b/plugins/home/src/components/VisitListener.tsx @@ -0,0 +1,163 @@ +/* + * 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, + useState, + useEffect, + useContext, + Dispatch, + SetStateAction, + ReactNode, +} from 'react'; + +import { useLocation } from 'react-router-dom'; + +import { visitsApiRef } from '../api'; +import { useApi } from '@backstage/core-plugin-api'; +import { stringifyEntityRef } from '@backstage/catalog-model'; + +/** @public */ +export type VisitListenerContextValue = { + doNotTrack: boolean; + setDoNotTrack: Dispatch>; +}; + +const defaultVisitListenerContext: VisitListenerContextValue = { + doNotTrack: false, + setDoNotTrack: () => {}, +}; + +/** @public */ +export const VisitListenerContext = createContext( + defaultVisitListenerContext, +); + +/** + * @public + * This function returns an implementation of toEntityRef which is responsible + * for receiving a pathname and maybe returning an entityRef compatible with the + * catalog-model. + * By default this function uses the url root "/catalog" and the + * stringifyEntityRef implementation from catalog-model. + * Example: + * const toEntityRef = getToEntityRef(); + * toEntityRef(\{ pathname: "/catalog/default/component/playback-order" \}) + * // returns "component:default/playback-order" + */ +export const getToEntityRef = + ({ + rootPath = 'catalog', + stringifyEntityRefImpl = stringifyEntityRef, + } = {}) => + ({ pathname }: { pathname: string }): string | undefined => { + const regex = new RegExp( + `^\/${rootPath}\/(?[^\/]+)\/(?[^\/]+)\/(?[^\/]+)`, + ); + const result = regex.exec(pathname); + if (!result || !result?.groups) return undefined; + const entity = { + namespace: result.groups.namespace, + kind: result.groups.kind, + name: result.groups.name, + }; + return stringifyEntityRefImpl(entity); + }; + +/** + * @public + * This function returns an implementation of visitName which is responsible + * for receiving a pathname and returning a string (name). The default + * implementation ignores the pathname and uses the document.title . + */ +export const getVisitName = (document: Document) => () => document.title; + +/** + * @public + * Component responsible for listening to location changes and calling + * the visitsApi to save visits. + */ +export const VisitListener = ({ + children, + toEntityRef, + visitName, +}: { + children?: React.ReactNode; + toEntityRef?: ({ pathname }: { pathname: string }) => string | undefined; + visitName?: ({ pathname }: { pathname: string }) => string; +}): JSX.Element => { + const [doNotTrack, setDoNotTrack] = useState( + defaultVisitListenerContext.doNotTrack, + ); + const visitsApi = useApi(visitsApiRef); + const { pathname } = useLocation(); + const toEntityRefImpl = toEntityRef ?? getToEntityRef(); + const visitNameImpl = visitName ?? getVisitName(document); + useEffect(() => { + // Wait for the browser to finish with paint with the assumption react + // has finished with dom reconciliation and the doNotTrack state update. + const requestId = requestAnimationFrame(() => { + if (!doNotTrack) + visitsApi.saveVisit({ + visit: { + name: visitNameImpl({ pathname }), + pathname, + entityRef: toEntityRefImpl({ pathname }), + }, + }); + }); + return () => cancelAnimationFrame(requestId); + }, [doNotTrack, visitsApi, pathname, toEntityRefImpl, visitNameImpl]); + + return ( + + {children} + + ); +}; + +/** + * @public + * Hook used to access visit listener context. Is able to control if tracking + * should be disabled. + */ +export const useVisitListener = () => { + const value = useContext(VisitListenerContext); + + if (value === undefined) + throw new Error( + 'useVisitListener found an undefined context, could be missing', + ); + + return value; +}; + +/** + * @public + * Use this component to warn VisitListener to disable tracking. + */ +export const DoNotTrack = ({ + children, +}: { + children?: ReactNode; +}): JSX.Element => { + const { setDoNotTrack } = useVisitListener(); + useEffect(() => { + setDoNotTrack(true); + return () => setDoNotTrack(false); + }, [setDoNotTrack]); + + return <>{children}; +}; diff --git a/plugins/home/src/components/index.ts b/plugins/home/src/components/index.ts index e528e0795d..a6a4148e36 100644 --- a/plugins/home/src/components/index.ts +++ b/plugins/home/src/components/index.ts @@ -16,3 +16,4 @@ export { HomepageCompositionRoot } from './HomepageCompositionRoot'; export * from './CustomHomepage'; +export * from './VisitListener'; diff --git a/plugins/home/src/homePageComponents/VisitedByType/Content.test.tsx b/plugins/home/src/homePageComponents/VisitedByType/Content.test.tsx index 329bf4e845..d73a9d1a75 100644 --- a/plugins/home/src/homePageComponents/VisitedByType/Content.test.tsx +++ b/plugins/home/src/homePageComponents/VisitedByType/Content.test.tsx @@ -32,7 +32,7 @@ const visits = [ ]; const mockVisitsApi = { - saveVisit: async () => {}, + saveVisit: async () => visits[0], listUserVisits: async () => visits, }; diff --git a/plugins/home/src/homePageComponents/VisitedByType/Content.tsx b/plugins/home/src/homePageComponents/VisitedByType/Content.tsx index 62b0de0d7d..4eef2a87ad 100644 --- a/plugins/home/src/homePageComponents/VisitedByType/Content.tsx +++ b/plugins/home/src/homePageComponents/VisitedByType/Content.tsx @@ -67,7 +67,7 @@ export const Content = ({ return await visitsApi .listUserVisits({ limit: numVisitsTotal ?? 8, - orderBy: { timestamp: 'desc' }, + orderBy: [{ field: 'timestamp', direction: 'desc' }], }) .then(setVisits); } @@ -75,7 +75,7 @@ export const Content = ({ return await visitsApi .listUserVisits({ limit: numVisitsTotal ?? 8, - orderBy: { hits: 'desc' }, + orderBy: [{ field: 'hits', direction: 'desc' }], }) .then(setVisits); } diff --git a/plugins/home/src/homePageComponents/VisitedByType/HomePageVisitedByType.stories.tsx b/plugins/home/src/homePageComponents/VisitedByType/HomePageVisitedByType.stories.tsx index 64698da921..0dd9c9e873 100644 --- a/plugins/home/src/homePageComponents/VisitedByType/HomePageVisitedByType.stories.tsx +++ b/plugins/home/src/homePageComponents/VisitedByType/HomePageVisitedByType.stories.tsx @@ -87,7 +87,7 @@ const visits: Array = [ ]; const mockVisitsApi = { - saveVisit: async () => {}, + saveVisit: async () => visits[0], listUserVisits: async () => visits, }; diff --git a/yarn.lock b/yarn.lock index c9aa122ad9..2fdd9e041f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7406,6 +7406,7 @@ __metadata: "@testing-library/dom": ^8.0.0 "@testing-library/jest-dom": ^5.10.1 "@testing-library/react": ^12.1.3 + "@testing-library/react-hooks": ^8.0.1 "@testing-library/user-event": ^14.0.0 "@types/react": ^16.13.1 || ^17.0.0 "@types/react-grid-layout": ^1.3.2 From 09a7f8f285e45bedbac4af2064c3d631882e21b3 Mon Sep 17 00:00:00 2001 From: Renan Mendes Carvalho Date: Tue, 19 Sep 2023 11:19:46 +0200 Subject: [PATCH 08/28] refactoring(plugins/home): Don't export getToEntityRef Signed-off-by: Renan Mendes Carvalho Co-authored-by: Camila Belo --- plugins/home/api-report.md | 10 ---------- plugins/home/src/components/VisitListener.tsx | 3 +-- 2 files changed, 1 insertion(+), 12 deletions(-) diff --git a/plugins/home/api-report.md b/plugins/home/api-report.md index acc073f685..7f96f24240 100644 --- a/plugins/home/api-report.md +++ b/plugins/home/api-report.md @@ -22,7 +22,6 @@ import { ReactNode } from 'react'; import { RendererProps as RendererProps_2 } from '@backstage/plugin-home-react'; import { RouteRef } from '@backstage/core-plugin-api'; import { SetStateAction } from 'react'; -import { stringifyEntityRef } from '@backstage/catalog-model'; // @public export type Breakpoint = 'xxs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl'; @@ -108,15 +107,6 @@ export const DoNotTrack: ({ children?: ReactNode; }) => JSX.Element; -// @public -export const getToEntityRef: ({ - rootPath, - stringifyEntityRefImpl, -}?: { - rootPath?: string | undefined; - stringifyEntityRefImpl?: typeof stringifyEntityRef | undefined; -}) => ({ pathname }: { pathname: string }) => string | undefined; - // @public export const getVisitName: (document: Document) => () => string; diff --git a/plugins/home/src/components/VisitListener.tsx b/plugins/home/src/components/VisitListener.tsx index cb92b47738..c564301525 100644 --- a/plugins/home/src/components/VisitListener.tsx +++ b/plugins/home/src/components/VisitListener.tsx @@ -46,7 +46,6 @@ export const VisitListenerContext = createContext( ); /** - * @public * This function returns an implementation of toEntityRef which is responsible * for receiving a pathname and maybe returning an entityRef compatible with the * catalog-model. @@ -57,7 +56,7 @@ export const VisitListenerContext = createContext( * toEntityRef(\{ pathname: "/catalog/default/component/playback-order" \}) * // returns "component:default/playback-order" */ -export const getToEntityRef = +const getToEntityRef = ({ rootPath = 'catalog', stringifyEntityRefImpl = stringifyEntityRef, From a84cac67caf447244c1645fea6a67c08f92d41f9 Mon Sep 17 00:00:00 2001 From: Renan Mendes Carvalho Date: Tue, 19 Sep 2023 11:30:22 +0200 Subject: [PATCH 09/28] feature(plugins/home): Remove DoNotTrack functionality Signed-off-by: Renan Mendes Carvalho Co-authored-by: Patrik Oldsberg --- plugins/home/api-report.md | 21 ----- .../src/components/VisitListener.test.tsx | 66 +------------- plugins/home/src/components/VisitListener.tsx | 86 +++---------------- 3 files changed, 11 insertions(+), 162 deletions(-) diff --git a/plugins/home/api-report.md b/plugins/home/api-report.md index 7f96f24240..a3c56dd7dd 100644 --- a/plugins/home/api-report.md +++ b/plugins/home/api-report.md @@ -14,14 +14,12 @@ 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 { Dispatch } from 'react'; import { JSX as JSX_2 } from 'react'; import { default as React_2 } from 'react'; import { ReactElement } from 'react'; import { ReactNode } from 'react'; import { RendererProps as RendererProps_2 } from '@backstage/plugin-home-react'; import { RouteRef } from '@backstage/core-plugin-api'; -import { SetStateAction } from 'react'; // @public export type Breakpoint = 'xxs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl'; @@ -100,13 +98,6 @@ export type CustomHomepageGridProps = { preventCollision?: boolean; }; -// @public -export const DoNotTrack: ({ - children, -}: { - children?: ReactNode; -}) => JSX.Element; - // @public export const getVisitName: (document: Document) => () => string; @@ -204,9 +195,6 @@ export type ToolkitContentProps = { tools: Tool[]; }; -// @public -export const useVisitListener: () => VisitListenerContextValue; - // @public export type Visit = { id: string; @@ -242,15 +230,6 @@ export const VisitListener: ({ visitName?: (({ pathname }: { pathname: string }) => string) | undefined; }) => JSX.Element; -// @public (undocumented) -export const VisitListenerContext: React_2.Context; - -// @public (undocumented) -export type VisitListenerContextValue = { - doNotTrack: boolean; - setDoNotTrack: Dispatch>; -}; - // @public export interface VisitsApi { listUserVisits(queryParams?: VisitsApiQueryParams): Promise; diff --git a/plugins/home/src/components/VisitListener.test.tsx b/plugins/home/src/components/VisitListener.test.tsx index 81cff08a09..81587b4067 100644 --- a/plugins/home/src/components/VisitListener.test.tsx +++ b/plugins/home/src/components/VisitListener.test.tsx @@ -16,10 +16,8 @@ import React from 'react'; import { TestApiProvider, renderInTestApp } from '@backstage/test-utils'; import { Visit, visitsApiRef } from '../api'; -import { DoNotTrack, VisitListener, useVisitListener } from './VisitListener'; +import { VisitListener } from './VisitListener'; import { waitFor } from '@testing-library/react'; -import { act, renderHook } from '@testing-library/react-hooks'; -import { MemoryRouter } from 'react-router-dom'; const visits: Array = [ { @@ -139,65 +137,3 @@ describe('', () => { ); }); }); - -describe('', () => { - afterEach(jest.resetAllMocks); - - it("doesn't register a visit", async () => { - const requestAnimationFrameSpy = jest.spyOn( - window, - 'requestAnimationFrame', - ); - await renderInTestApp( - - - - - , - ); - await waitFor(() => expect(requestAnimationFrameSpy).toHaveBeenCalled()); - expect(mockVisitsApi.saveVisit).not.toHaveBeenCalled(); - }); - - it('renders its children', async () => { - const requestAnimationFrameSpy = jest.spyOn( - window, - 'requestAnimationFrame', - ); - const { getByTestId } = await renderInTestApp( - - - -
child
-
-
-
, - ); - await waitFor(() => expect(requestAnimationFrameSpy).toHaveBeenCalled()); - expect(getByTestId('child')).toBeTruthy(); - }); -}); - -describe('useVisitListener()', () => { - it('returns the default context', () => { - const { result } = renderHook(() => useVisitListener()); - expect(result.current.doNotTrack).toBeFalsy(); - expect(result.current.setDoNotTrack).toBeInstanceOf(Function); - }); - - it('changes the doNotTrack flag', () => { - const { result } = renderHook(() => useVisitListener(), { - wrapper: ({ children }) => ( - - - {children} - - - ), - }); - act(() => { - result.current.setDoNotTrack(true); - }); - expect(result.current.doNotTrack).toBeTruthy(); - }); -}); diff --git a/plugins/home/src/components/VisitListener.tsx b/plugins/home/src/components/VisitListener.tsx index c564301525..c0cf34bdc9 100644 --- a/plugins/home/src/components/VisitListener.tsx +++ b/plugins/home/src/components/VisitListener.tsx @@ -13,15 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React, { - createContext, - useState, - useEffect, - useContext, - Dispatch, - SetStateAction, - ReactNode, -} from 'react'; +import React, { useEffect } from 'react'; import { useLocation } from 'react-router-dom'; @@ -29,22 +21,6 @@ import { visitsApiRef } from '../api'; import { useApi } from '@backstage/core-plugin-api'; import { stringifyEntityRef } from '@backstage/catalog-model'; -/** @public */ -export type VisitListenerContextValue = { - doNotTrack: boolean; - setDoNotTrack: Dispatch>; -}; - -const defaultVisitListenerContext: VisitListenerContextValue = { - doNotTrack: false, - setDoNotTrack: () => {}, -}; - -/** @public */ -export const VisitListenerContext = createContext( - defaultVisitListenerContext, -); - /** * This function returns an implementation of toEntityRef which is responsible * for receiving a pathname and maybe returning an entityRef compatible with the @@ -97,66 +73,24 @@ export const VisitListener = ({ toEntityRef?: ({ pathname }: { pathname: string }) => string | undefined; visitName?: ({ pathname }: { pathname: string }) => string; }): JSX.Element => { - const [doNotTrack, setDoNotTrack] = useState( - defaultVisitListenerContext.doNotTrack, - ); const visitsApi = useApi(visitsApiRef); const { pathname } = useLocation(); const toEntityRefImpl = toEntityRef ?? getToEntityRef(); const visitNameImpl = visitName ?? getVisitName(document); useEffect(() => { // Wait for the browser to finish with paint with the assumption react - // has finished with dom reconciliation and the doNotTrack state update. + // has finished with dom reconciliation. const requestId = requestAnimationFrame(() => { - if (!doNotTrack) - visitsApi.saveVisit({ - visit: { - name: visitNameImpl({ pathname }), - pathname, - entityRef: toEntityRefImpl({ pathname }), - }, - }); + visitsApi.saveVisit({ + visit: { + name: visitNameImpl({ pathname }), + pathname, + entityRef: toEntityRefImpl({ pathname }), + }, + }); }); return () => cancelAnimationFrame(requestId); - }, [doNotTrack, visitsApi, pathname, toEntityRefImpl, visitNameImpl]); - - return ( - - {children} - - ); -}; - -/** - * @public - * Hook used to access visit listener context. Is able to control if tracking - * should be disabled. - */ -export const useVisitListener = () => { - const value = useContext(VisitListenerContext); - - if (value === undefined) - throw new Error( - 'useVisitListener found an undefined context, could be missing', - ); - - return value; -}; - -/** - * @public - * Use this component to warn VisitListener to disable tracking. - */ -export const DoNotTrack = ({ - children, -}: { - children?: ReactNode; -}): JSX.Element => { - const { setDoNotTrack } = useVisitListener(); - useEffect(() => { - setDoNotTrack(true); - return () => setDoNotTrack(false); - }, [setDoNotTrack]); + }, [visitsApi, pathname, toEntityRefImpl, visitNameImpl]); return <>{children}; }; From 5d671e622021a2484d93e6bd6754dc7088dce0ab Mon Sep 17 00:00:00 2001 From: Renan Mendes Carvalho Date: Tue, 29 Aug 2023 13:45:54 +0200 Subject: [PATCH 10/28] feature(home-plugin): Create reference implementation (localStorage) This is a reference implementation using local storage. Signed-off-by: Renan Mendes Carvalho --- plugins/home/api-report.md | 42 ++- .../src/api/LocalStorageVisitsApi.test.ts | 66 ++++ plugins/home/src/api/LocalStorageVisitsApi.ts | 54 +++ plugins/home/src/api/VisitsApi.ts | 4 +- plugins/home/src/api/VisitsApiFactory.test.ts | 325 ++++++++++++++++++ plugins/home/src/api/VisitsApiFactory.ts | 119 +++++++ plugins/home/src/api/index.ts | 2 + .../VisitedByType/Content.tsx | 4 +- 8 files changed, 611 insertions(+), 5 deletions(-) create mode 100644 plugins/home/src/api/LocalStorageVisitsApi.test.ts create mode 100644 plugins/home/src/api/LocalStorageVisitsApi.ts create mode 100644 plugins/home/src/api/VisitsApiFactory.test.ts create mode 100644 plugins/home/src/api/VisitsApiFactory.ts diff --git a/plugins/home/api-report.md b/plugins/home/api-report.md index a3c56dd7dd..56fa3ef281 100644 --- a/plugins/home/api-report.md +++ b/plugins/home/api-report.md @@ -161,6 +161,19 @@ export type LayoutConfiguration = { resizable?: boolean; }; +// @public +export class LocalStorageVisitsApi extends VisitsApiFactory { + constructor({ + localStorage, + randomUUID, + limit, + }?: { + localStorage?: Window['localStorage']; + randomUUID?: Window['crypto']['randomUUID']; + limit?: number; + }); +} + // @public @deprecated (undocumented) export type RendererProps = RendererProps_2; @@ -232,7 +245,34 @@ export const VisitListener: ({ // @public export interface VisitsApi { - listUserVisits(queryParams?: VisitsApiQueryParams): Promise; + listVisits(queryParams?: VisitsApiQueryParams): Promise; + saveVisit(saveParams: VisitsApiSaveParams): Promise; +} + +// @public +export class VisitsApiFactory implements VisitsApi { + constructor({ + randomUUID, + limit, + retrieveAll, + persistAll, + }: { + randomUUID: Window['crypto']['randomUUID']; + limit: number; + retrieveAll?: () => Promise>; + persistAll?: (visits: Array) => Promise; + }); + // (undocumented) + protected readonly limit: number; + // (undocumented) + listVisits(queryParams?: VisitsApiQueryParams): Promise; + // (undocumented) + protected persistAll: (visits: Array) => Promise; + // (undocumented) + protected readonly randomUUID: Window['crypto']['randomUUID']; + // (undocumented) + protected retrieveAll: () => Promise>; + // (undocumented) saveVisit(saveParams: VisitsApiSaveParams): Promise; } diff --git a/plugins/home/src/api/LocalStorageVisitsApi.test.ts b/plugins/home/src/api/LocalStorageVisitsApi.test.ts new file mode 100644 index 0000000000..8cf94e6c25 --- /dev/null +++ b/plugins/home/src/api/LocalStorageVisitsApi.test.ts @@ -0,0 +1,66 @@ +/* + * 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 { LocalStorageVisitsApi } from './LocalStorageVisitsApi'; + +describe('new LocalStorageVisitsApi()', () => { + const mockRandomUUID = () => + '068f3129-7440-4e0e-8fd4-xxxxxxxxxxxx'.replace( + /x/g, + () => Math.floor(Math.random() * 16).toString(16), // 0x0 to 0xf + ) as `${string}-${string}-${string}-${string}-${string}`; + + beforeEach(() => { + window.crypto.randomUUID = mockRandomUUID; + }); + + afterEach(() => { + window.localStorage.clear(); + }); + + it('instantiates with no configuration', () => { + const api = new LocalStorageVisitsApi(); + expect(api).toBeTruthy(); + }); + + it('saves a visit', async () => { + const api = new LocalStorageVisitsApi(); + const visit = { + pathname: '/catalog/default/component/playback-order', + entityRef: 'component:default/playback-order', + name: 'Playback Order', + }; + const returnedVisit = await api.saveVisit({ visit }); + expect(returnedVisit).toEqual(expect.objectContaining(visit)); + expect(returnedVisit.id).toBeTruthy(); + expect(returnedVisit.timestamp).toBeTruthy(); + expect(returnedVisit.hits).toBeTruthy(); + }); + + it('retrieves visits', async () => { + const api = new LocalStorageVisitsApi(); + const visit = { + pathname: '/catalog/default/component/playback-order', + entityRef: 'component:default/playback-order', + name: 'Playback Order', + }; + const returnedVisit = await api.saveVisit({ visit }); + const visits = await api.listVisits(); + expect(visits).toHaveLength(1); + expect(visits).toEqual([expect.objectContaining(visit)]); + expect(visits).toEqual([returnedVisit]); + }); +}); diff --git a/plugins/home/src/api/LocalStorageVisitsApi.ts b/plugins/home/src/api/LocalStorageVisitsApi.ts new file mode 100644 index 0000000000..5999930e3a --- /dev/null +++ b/plugins/home/src/api/LocalStorageVisitsApi.ts @@ -0,0 +1,54 @@ +/* + * 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 { Visit } from './VisitsApi'; +import { VisitsApiFactory } from './VisitsApiFactory'; + +/** + * @public + * This is a reference implementation of VisitsApi using window.localStorage. + */ +export class LocalStorageVisitsApi extends VisitsApiFactory { + private readonly localStorage: Window['localStorage']; + private readonly storageKey = '@backstage/plugin-home:visits'; + + constructor({ + localStorage = window?.localStorage, + randomUUID = window?.crypto?.randomUUID, + limit = 100, + }: { + localStorage?: Window['localStorage']; + randomUUID?: Window['crypto']['randomUUID']; + limit?: number; + } = {}) { + super({ randomUUID, limit }); + this.localStorage = localStorage; + this.retrieveAll = async (): Promise> => { + let visits: Array; + try { + visits = JSON.parse(this.localStorage.getItem(this.storageKey) ?? '[]'); + } catch { + visits = []; + } + return visits; + }; + this.persistAll = async (visits: Array) => { + this.localStorage.setItem( + this.storageKey, + JSON.stringify(visits.splice(0, this.limit)), + ); + }; + } +} diff --git a/plugins/home/src/api/VisitsApi.ts b/plugins/home/src/api/VisitsApi.ts index 4ffb37e8b6..ab53ea7f10 100644 --- a/plugins/home/src/api/VisitsApi.ts +++ b/plugins/home/src/api/VisitsApi.ts @@ -108,10 +108,10 @@ export interface VisitsApi { */ saveVisit(saveParams: VisitsApiSaveParams): Promise; /** - * Get the logged user visits. + * Get user visits. * @param queryParams - optional search query params. */ - listUserVisits(queryParams?: VisitsApiQueryParams): Promise; + listVisits(queryParams?: VisitsApiQueryParams): Promise; } /** @public */ diff --git a/plugins/home/src/api/VisitsApiFactory.test.ts b/plugins/home/src/api/VisitsApiFactory.test.ts new file mode 100644 index 0000000000..3281c320b7 --- /dev/null +++ b/plugins/home/src/api/VisitsApiFactory.test.ts @@ -0,0 +1,325 @@ +/* + * 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 { Visit } from './VisitsApi'; +import { VisitsApiFactory } from './VisitsApiFactory'; + +class MemoryVisitsApi extends VisitsApiFactory { + private visits: Array = []; + + constructor({ + randomUUID = window?.crypto?.randomUUID, + limit = 100, + }: { + randomUUID?: Window['crypto']['randomUUID']; + limit?: number; + } = {}) { + super({ randomUUID, limit }); + this.retrieveAll = async (): Promise> => { + let visits: Array; + try { + visits = this.visits; + } catch { + visits = []; + } + return visits; + }; + this.persistAll = async (visits: Array) => { + this.visits = visits; + }; + } +} + +describe('new MemoryVisitsApi()', () => { + const mockRandomUUID = () => + '068f3129-7440-4e0e-8fd4-xxxxxxxxxxxx'.replace( + /x/g, + () => Math.floor(Math.random() * 16).toString(16), // 0x0 to 0xf + ) as `${string}-${string}-${string}-${string}-${string}`; + + beforeEach(() => { + jest.useFakeTimers(); + window.crypto.randomUUID = mockRandomUUID; + }); + + afterEach(() => { + jest.resetAllMocks(); + jest.useRealTimers(); + window.localStorage.clear(); + }); + + it('instantiates with no configuration', () => { + const api = new MemoryVisitsApi(); + expect(api).toBeTruthy(); + }); + + describe('.saveVisit()', () => { + it('saves a visit', async () => { + const api = new MemoryVisitsApi(); + const visit = { + pathname: '/catalog/default/component/playback-order', + entityRef: 'component:default/playback-order', + name: 'Playback Order', + }; + const returnedVisit = await api.saveVisit({ visit }); + expect(returnedVisit).toEqual(expect.objectContaining(visit)); + expect(returnedVisit.id).toBeTruthy(); + expect(returnedVisit.timestamp).toBeTruthy(); + expect(returnedVisit.hits).toBeTruthy(); + }); + + it('can control the number of stored entities', async () => { + const api = new MemoryVisitsApi({ limit: 2 }); + const baseDate = Date.now(); + const visit1 = { + pathname: '/catalog/default/component/playback-order-1', + entityRef: 'component:default/playback-order', + name: 'Playback Order', + }; + jest.setSystemTime(baseDate); + await api.saveVisit({ visit: visit1 }); + const visit2 = { + pathname: '/catalog/default/component/playback-order-2', + entityRef: 'component:default/playback-order', + name: 'Playback Order', + }; + jest.setSystemTime(baseDate + 360_000); + await api.saveVisit({ visit: visit2 }); + const visit3 = { + pathname: '/catalog/default/component/playback-order-3', + entityRef: 'component:default/playback-order', + name: 'Playback Order', + }; + jest.setSystemTime(baseDate + 360_000 * 2); + await api.saveVisit({ visit: visit3 }); + const visits = await api.listVisits(); + expect(visits).toHaveLength(2); + expect(visits).toContainEqual(expect.objectContaining(visit2)); + expect(visits).toContainEqual(expect.objectContaining(visit3)); + }); + + it('correctly bumps the hits from a previous visit', async () => { + const api = new MemoryVisitsApi(); + const visit = { + pathname: '/catalog/default/component/playback-order', + entityRef: 'component:default/playback-order', + name: 'Playback Order', + }; + const visit1 = await api.saveVisit({ visit }); + const visit2 = await api.saveVisit({ visit }); + const visits = await api.listVisits(); + expect(visits).toHaveLength(1); + expect(visits).toContainEqual(expect.objectContaining(visit)); + // keeps the original id created on the first visit + expect(visits).toContainEqual(expect.objectContaining({ id: visit1.id })); + // updates timestamp and hits + expect(visits).toContainEqual( + expect.objectContaining({ timestamp: visit2.timestamp, hits: 2 }), + ); + }); + }); + + describe('.listVisits()', () => { + let api: MemoryVisitsApi; + let visitsToSave: Array>; + let baseDate: number; + beforeEach(() => { + api = new MemoryVisitsApi(); + visitsToSave = [ + { + pathname: '/catalog/default/component/playback-order-1', + entityRef: 'component:default/playback-order-1', + name: 'Playback Order Odd', + }, + { + pathname: '/catalog/default/component/playback-order-2', + entityRef: 'component:default/playback-order-2', + name: 'Playback Order Even', + }, + { + pathname: '/catalog/default/component/playback-order-3', + entityRef: 'component:default/playback-order-3', + name: 'Playback Order Odd', + }, + ]; + baseDate = Date.now(); + // Chaining items to ensure the right setSystemTime + return visitsToSave.reduce( + (acc, visit, index) => + acc.then(() => { + jest.setSystemTime(baseDate + 360_000 * index); + return api.saveVisit({ visit }); + }), + Promise.resolve({}), + ); + }); + + it('retrieves visits', async () => { + const visits = await api.listVisits(); + expect(visits).toHaveLength(3); + expect(visits).toEqual([ + expect.objectContaining(visitsToSave[2]), + expect.objectContaining(visitsToSave[1]), + expect.objectContaining(visitsToSave[0]), + ]); + }); + + it('orders by timestamp asc', async () => { + const visits = await api.listVisits({ + orderBy: [{ field: 'timestamp', direction: 'asc' }], + }); + expect(visits).toEqual([ + expect.objectContaining(visitsToSave[0]), + expect.objectContaining(visitsToSave[1]), + expect.objectContaining(visitsToSave[2]), + ]); + }); + + it('orders by timestamp desc', async () => { + const visits = await api.listVisits({ + orderBy: [{ field: 'timestamp', direction: 'desc' }], + }); + expect(visits).toEqual([ + expect.objectContaining(visitsToSave[2]), + expect.objectContaining(visitsToSave[1]), + expect.objectContaining(visitsToSave[0]), + ]); + }); + + it('orders by entityRef asc', async () => { + const visits = await api.listVisits({ + orderBy: [{ field: 'entityRef', direction: 'asc' }], + }); + expect(visits).toEqual([ + expect.objectContaining(visitsToSave[0]), + expect.objectContaining(visitsToSave[1]), + expect.objectContaining(visitsToSave[2]), + ]); + }); + + it('orders by entityRef desc', async () => { + const visits = await api.listVisits({ + orderBy: [{ field: 'entityRef', direction: 'desc' }], + }); + expect(visits).toEqual([ + expect.objectContaining(visitsToSave[2]), + expect.objectContaining(visitsToSave[1]), + expect.objectContaining(visitsToSave[0]), + ]); + }); + + it('orders by name asc then by entityRef asc', async () => { + const visits = await api.listVisits({ + orderBy: [ + { field: 'name', direction: 'asc' }, + { field: 'entityRef', direction: 'asc' }, + ], + }); + expect(visits).toEqual([ + expect.objectContaining(visitsToSave[1]), // Playback Order Even, playback-order-2 + expect.objectContaining(visitsToSave[0]), // Playback Order Odd, playback-order-1 + expect.objectContaining(visitsToSave[2]), // Playback Order Odd, playback-order-3 + ]); + }); + + it('orders by name desc then by entityRef asc', async () => { + const visits = await api.listVisits({ + orderBy: [ + { field: 'name', direction: 'desc' }, + { field: 'entityRef', direction: 'asc' }, + ], + }); + expect(visits).toEqual([ + expect.objectContaining(visitsToSave[0]), // Playback Order Odd, playback-order-1 + expect.objectContaining(visitsToSave[2]), // Playback Order Odd, playback-order-3 + expect.objectContaining(visitsToSave[1]), // Playback Order Even, playback-order-2 + ]); + }); + + it('filters by timestamp with >', async () => { + const visits = await api.listVisits({ + filterBy: [{ field: 'timestamp', operator: '>', value: baseDate }], + }); + expect(visits).toHaveLength(2); + expect(visits).toEqual([ + expect.objectContaining(visitsToSave[2]), + expect.objectContaining(visitsToSave[1]), + ]); + }); + + it('filters by timestamp with >=', async () => { + const visits = await api.listVisits({ + filterBy: [ + { field: 'timestamp', operator: '>=', value: baseDate + 360_000 * 2 }, + ], + }); + expect(visits).toHaveLength(1); + expect(visits).toEqual([expect.objectContaining(visitsToSave[2])]); + }); + + it('filters by timestamp with <', async () => { + const visits = await api.listVisits({ + filterBy: [{ field: 'timestamp', operator: '<', value: baseDate + 1 }], + }); + expect(visits).toHaveLength(1); + expect(visits).toEqual([expect.objectContaining(visitsToSave[0])]); + }); + + it('filters by timestamp with <=', async () => { + const visits = await api.listVisits({ + filterBy: [ + { field: 'timestamp', operator: '<=', value: baseDate + 360_000 }, + ], + }); + expect(visits).toHaveLength(2); + expect(visits).toEqual([ + expect.objectContaining(visitsToSave[1]), + expect.objectContaining(visitsToSave[0]), + ]); + }); + + it('filters by timestamp with ==', async () => { + const visits = await api.listVisits({ + filterBy: [ + { field: 'timestamp', operator: '==', value: baseDate + 360_000 }, + ], + }); + expect(visits).toHaveLength(1); + expect(visits).toEqual([expect.objectContaining(visitsToSave[1])]); + }); + + it('filters by entityRef with contains', async () => { + const visits = await api.listVisits({ + filterBy: [ + { field: 'entityRef', operator: 'contains', value: 'order-2' }, + ], + }); + expect(visits).toHaveLength(1); + expect(visits).toEqual([expect.objectContaining(visitsToSave[1])]); + }); + + it('filters by timestamp with <= then by name with contains', async () => { + const visits = await api.listVisits({ + filterBy: [ + { field: 'timestamp', operator: '<=', value: baseDate + 360_000 }, + { field: 'name', operator: 'contains', value: 'Odd' }, + ], + }); + expect(visits).toHaveLength(1); + expect(visits).toEqual([expect.objectContaining(visitsToSave[0])]); + }); + }); +}); diff --git a/plugins/home/src/api/VisitsApiFactory.ts b/plugins/home/src/api/VisitsApiFactory.ts new file mode 100644 index 0000000000..ba3fe1fe3e --- /dev/null +++ b/plugins/home/src/api/VisitsApiFactory.ts @@ -0,0 +1,119 @@ +/* + * 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 { Visit, VisitsApi, VisitsApiQueryParams, VisitsApiSaveParams } from './VisitsApi'; + +type ArrayElement = A extends readonly (infer T)[] ? T : never; + +/** + * @public + * This helps the creation of VisitApi implementations. Important to note + * that it implements features like orderBy and filterBy on memory, therefore + * is intended to handle few visits. The default is 100. + * See LocalStorageVisitsApi for an usage example. + */ +export class VisitsApiFactory implements VisitsApi { + protected readonly randomUUID: Window['crypto']['randomUUID']; + protected readonly limit: number; + protected retrieveAll: () => Promise>; + protected persistAll: (visits: Array) => Promise; + + constructor({ + randomUUID = window?.crypto?.randomUUID, + limit = 100, + retrieveAll, + persistAll, + }: { + randomUUID: Window['crypto']['randomUUID']; + limit: number; + retrieveAll?: () => Promise>; + persistAll?: (visits: Array) => Promise; + }) { + this.randomUUID = randomUUID; + this.limit = Math.abs(limit); + this.retrieveAll = retrieveAll ?? (async () => []); + this.persistAll = persistAll ?? (async () => {}); + } + + async listVisits(queryParams?: VisitsApiQueryParams): Promise { + let visits = await this.retrieveAll(); + + // reversing order to guarantee orderBy priority + (queryParams?.orderBy ?? []).reverse().forEach(order => { + if (order.direction === 'asc') { + visits.sort((a, b) => this.compare(order, a, b)); + } else { + visits.sort((a, b) => this.compare(order, b, a)); + } + }); + + (queryParams?.filterBy ?? []).reverse().forEach(filter => { + visits = visits.filter(visit => { + const field = visit[filter.field] as number | string; + if (filter.operator === '>') return field > filter.value; + if (filter.operator === '>=') return field >= filter.value; + if (filter.operator === '<') return field < filter.value; + if (filter.operator === '<=') return field <= filter.value; + if (filter.operator === '==') return field === filter.value; + if (filter.operator === 'contains') + return `${field}`.includes(`${filter.value}`); + return false; + }); + }); + + return visits; + } + + async saveVisit( + saveParams: VisitsApiSaveParams, + ): Promise { + const visits = await this.retrieveAll(); + + const visit: Visit = { + ...saveParams.visit, + id: this.randomUUID(), + hits: 1, + timestamp: Date.now(), + }; + + // Updates entry if pathname is already registered + const visitIndex = visits.findIndex(e => e.pathname === visit.pathname); + if (visitIndex >= 0) { + visit.id = visits[visitIndex].id; + visit.hits = visits[visitIndex].hits + 1; + visits[visitIndex] = visit; + } else { + visits.push(visit); + } + + // Sort by time, most recent first + visits.sort((a, b) => b.timestamp - a.timestamp); + // Keep the most recent items up to limit + await this.persistAll(visits.splice(0, this.limit)); + return visit; + } + + // This assumes Visit fields are either numbers or strings + private compare( + order: ArrayElement, + a: Visit, + b: Visit, + ): number { + const isNumber = typeof a[order.field] === 'number'; + return isNumber + ? (a[order.field] as number) - (b[order.field] as number) + : `${a[order.field]}`.localeCompare(`${b[order.field]}`); + } +} diff --git a/plugins/home/src/api/index.ts b/plugins/home/src/api/index.ts index 29a8fb5468..b90a14299c 100644 --- a/plugins/home/src/api/index.ts +++ b/plugins/home/src/api/index.ts @@ -15,3 +15,5 @@ */ export * from './VisitsApi'; +export * from './LocalStorageVisitsApi'; +export * from './VisitsApiFactory'; diff --git a/plugins/home/src/homePageComponents/VisitedByType/Content.tsx b/plugins/home/src/homePageComponents/VisitedByType/Content.tsx index 4eef2a87ad..8f492a2099 100644 --- a/plugins/home/src/homePageComponents/VisitedByType/Content.tsx +++ b/plugins/home/src/homePageComponents/VisitedByType/Content.tsx @@ -65,7 +65,7 @@ export const Content = ({ const { loading: reqLoading } = useAsync(async () => { if (!visits && !loading && kind === 'recent') { return await visitsApi - .listUserVisits({ + .listVisits({ limit: numVisitsTotal ?? 8, orderBy: [{ field: 'timestamp', direction: 'desc' }], }) @@ -73,7 +73,7 @@ export const Content = ({ } if (!visits && !loading && kind === 'top') { return await visitsApi - .listUserVisits({ + .listVisits({ limit: numVisitsTotal ?? 8, orderBy: [{ field: 'hits', direction: 'desc' }], }) From c4854690988f1e23d7deabd44570f8db28bf936e Mon Sep 17 00:00:00 2001 From: Renan Mendes Carvalho Date: Tue, 29 Aug 2023 17:34:52 +0200 Subject: [PATCH 11/28] feature(home-plugin): VisitApi implementation backed by StorageApi Signed-off-by: Renan Mendes Carvalho --- plugins/home/api-report.md | 14 ++++ .../home/src/api/CoreStorageVisitsApi.test.ts | 73 +++++++++++++++++++ plugins/home/src/api/CoreStorageVisitsApi.ts | 52 +++++++++++++ plugins/home/src/api/index.ts | 3 +- .../VisitedByType/Content.test.tsx | 2 +- 5 files changed, 142 insertions(+), 2 deletions(-) create mode 100644 plugins/home/src/api/CoreStorageVisitsApi.test.ts create mode 100644 plugins/home/src/api/CoreStorageVisitsApi.ts diff --git a/plugins/home/api-report.md b/plugins/home/api-report.md index 56fa3ef281..3352a291bc 100644 --- a/plugins/home/api-report.md +++ b/plugins/home/api-report.md @@ -20,6 +20,7 @@ import { ReactElement } from 'react'; import { ReactNode } from 'react'; import { RendererProps as RendererProps_2 } from '@backstage/plugin-home-react'; import { RouteRef } from '@backstage/core-plugin-api'; +import { StorageApi } from '@backstage/core-plugin-api'; // @public export type Breakpoint = 'xxs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl'; @@ -74,6 +75,19 @@ export const ComponentTabs: (props: { }[]; }) => JSX_2.Element; +// @public +export class CoreStorageVisitsApi extends VisitsApiFactory { + constructor({ + storageApi, + randomUUID, + limit, + }: { + storageApi: StorageApi; + randomUUID?: Window['crypto']['randomUUID']; + limit?: number; + }); +} + // @public @deprecated (undocumented) export const createCardExtension: typeof createCardExtension_2; diff --git a/plugins/home/src/api/CoreStorageVisitsApi.test.ts b/plugins/home/src/api/CoreStorageVisitsApi.test.ts new file mode 100644 index 0000000000..9185ece2c6 --- /dev/null +++ b/plugins/home/src/api/CoreStorageVisitsApi.test.ts @@ -0,0 +1,73 @@ +/* + * 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 { CoreStorageVisitsApi } from './CoreStorageVisitsApi'; +import { MockStorageApi } from '@backstage/test-utils'; + +describe('new CoreStorageVisitsApi({ storageApi: MockStorageApi.create() })', () => { + const mockRandomUUID = () => + '068f3129-7440-4e0e-8fd4-xxxxxxxxxxxx'.replace( + /x/g, + () => Math.floor(Math.random() * 16).toString(16), // 0x0 to 0xf + ) as `${string}-${string}-${string}-${string}-${string}`; + + beforeEach(() => { + window.crypto.randomUUID = mockRandomUUID; + }); + + afterEach(() => { + window.localStorage.clear(); + }); + + it('instantiates with no configuration', () => { + const api = new CoreStorageVisitsApi({ + storageApi: MockStorageApi.create(), + }); + expect(api).toBeTruthy(); + }); + + it('saves a visit', async () => { + const api = new CoreStorageVisitsApi({ + storageApi: MockStorageApi.create(), + }); + const visit = { + pathname: '/catalog/default/component/playback-order', + entityRef: 'component:default/playback-order', + name: 'Playback Order', + }; + const returnedVisit = await api.saveVisit({ visit }); + expect(returnedVisit).toEqual(expect.objectContaining(visit)); + expect(returnedVisit.id).toBeTruthy(); + expect(returnedVisit.timestamp).toBeTruthy(); + expect(returnedVisit.hits).toBeTruthy(); + }); + + it('retrieves visits', async () => { + const api = new CoreStorageVisitsApi({ + storageApi: MockStorageApi.create(), + }); + const visit = { + pathname: '/catalog/default/component/playback-order', + entityRef: 'component:default/playback-order', + name: 'Playback Order', + }; + const returnedVisit = await api.saveVisit({ visit }); + const visits = await api.listVisits(); + expect(visits).toHaveLength(1); + expect(visits).toEqual([expect.objectContaining(visit)]); + expect(visits).toEqual([returnedVisit]); + }); +}); diff --git a/plugins/home/src/api/CoreStorageVisitsApi.ts b/plugins/home/src/api/CoreStorageVisitsApi.ts new file mode 100644 index 0000000000..8b54fcc416 --- /dev/null +++ b/plugins/home/src/api/CoreStorageVisitsApi.ts @@ -0,0 +1,52 @@ +/* + * 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 { StorageApi } from '@backstage/core-plugin-api'; +import { Visit } from './VisitsApi'; +import { VisitsApiFactory } from './VisitsApiFactory'; + +/** + * @public + * This is an implementation of VisitsApi that relies on a StorageApi + */ +export class CoreStorageVisitsApi extends VisitsApiFactory { + private readonly storageApi: StorageApi; + private readonly storageKey = '@backstage/plugin-home:visits'; + + constructor({ + storageApi, + randomUUID = window?.crypto?.randomUUID, + limit = 100, + }: { + storageApi: StorageApi; + randomUUID?: Window['crypto']['randomUUID']; + limit?: number; + }) { + super({ randomUUID, limit }); + this.storageApi = storageApi; + this.retrieveAll = async (): Promise> => { + let visits: Array; + try { + visits = + this.storageApi.snapshot>(this.storageKey).value ?? []; + } catch { + visits = []; + } + return visits; + }; + this.persistAll = async (visits: Array) => + this.storageApi.set>(this.storageKey, visits); + } +} diff --git a/plugins/home/src/api/index.ts b/plugins/home/src/api/index.ts index b90a14299c..cb283aa2cf 100644 --- a/plugins/home/src/api/index.ts +++ b/plugins/home/src/api/index.ts @@ -14,6 +14,7 @@ * limitations under the License. */ -export * from './VisitsApi'; +export * from './CoreStorageVisitsApi'; export * from './LocalStorageVisitsApi'; +export * from './VisitsApi'; export * from './VisitsApiFactory'; diff --git a/plugins/home/src/homePageComponents/VisitedByType/Content.test.tsx b/plugins/home/src/homePageComponents/VisitedByType/Content.test.tsx index d73a9d1a75..a9ff2cdd7c 100644 --- a/plugins/home/src/homePageComponents/VisitedByType/Content.test.tsx +++ b/plugins/home/src/homePageComponents/VisitedByType/Content.test.tsx @@ -33,7 +33,7 @@ const visits = [ const mockVisitsApi = { saveVisit: async () => visits[0], - listUserVisits: async () => visits, + listVisits: async () => visits, }; describe('', () => { From 6cfcf8a4a759fad3b1775f8002eeadc65c675f47 Mon Sep 17 00:00:00 2001 From: Renan Mendes Carvalho Date: Wed, 30 Aug 2023 10:26:16 +0200 Subject: [PATCH 12/28] feature(home-plugin): Adds user scope to storage calls This patch adds the userEntityRef to the storage key ensuring they are scoped per user. Signed-off-by: Renan Mendes Carvalho --- plugins/home/api-report.md | 7 +++++- .../home/src/api/CoreStorageVisitsApi.test.ts | 14 +++++++++++- plugins/home/src/api/CoreStorageVisitsApi.ts | 22 ++++++++++++++----- .../src/api/LocalStorageVisitsApi.test.ts | 18 +++++++++++---- plugins/home/src/api/LocalStorageVisitsApi.ts | 21 +++++++++++++----- 5 files changed, 65 insertions(+), 17 deletions(-) diff --git a/plugins/home/api-report.md b/plugins/home/api-report.md index 3352a291bc..9eddb81da5 100644 --- a/plugins/home/api-report.md +++ b/plugins/home/api-report.md @@ -14,6 +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 { IdentityApi } from '@backstage/core-plugin-api'; import { JSX as JSX_2 } from 'react'; import { default as React_2 } from 'react'; import { ReactElement } from 'react'; @@ -79,12 +80,14 @@ export const ComponentTabs: (props: { export class CoreStorageVisitsApi extends VisitsApiFactory { constructor({ storageApi, + identityApi, randomUUID, limit, }: { storageApi: StorageApi; randomUUID?: Window['crypto']['randomUUID']; limit?: number; + identityApi: IdentityApi; }); } @@ -181,10 +184,12 @@ export class LocalStorageVisitsApi extends VisitsApiFactory { localStorage, randomUUID, limit, - }?: { + identityApi, + }: { localStorage?: Window['localStorage']; randomUUID?: Window['crypto']['randomUUID']; limit?: number; + identityApi: IdentityApi; }); } diff --git a/plugins/home/src/api/CoreStorageVisitsApi.test.ts b/plugins/home/src/api/CoreStorageVisitsApi.test.ts index 9185ece2c6..e14f71ad60 100644 --- a/plugins/home/src/api/CoreStorageVisitsApi.test.ts +++ b/plugins/home/src/api/CoreStorageVisitsApi.test.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import { BackstageUserIdentity, IdentityApi } from '@backstage/core-plugin-api'; import { CoreStorageVisitsApi } from './CoreStorageVisitsApi'; import { MockStorageApi } from '@backstage/test-utils'; @@ -24,6 +25,14 @@ describe('new CoreStorageVisitsApi({ storageApi: MockStorageApi.create() })', () () => Math.floor(Math.random() * 16).toString(16), // 0x0 to 0xf ) as `${string}-${string}-${string}-${string}-${string}`; + const mockIdentityApi: IdentityApi = { + signOut: jest.fn(), + getProfileInfo: jest.fn(), + getBackstageIdentity: async () => + ({ userEntityRef: 'user:default/guest' } as BackstageUserIdentity), + getCredentials: jest.fn(), + }; + beforeEach(() => { window.crypto.randomUUID = mockRandomUUID; }); @@ -32,9 +41,10 @@ describe('new CoreStorageVisitsApi({ storageApi: MockStorageApi.create() })', () window.localStorage.clear(); }); - it('instantiates with no configuration', () => { + it('instantiates', () => { const api = new CoreStorageVisitsApi({ storageApi: MockStorageApi.create(), + identityApi: mockIdentityApi, }); expect(api).toBeTruthy(); }); @@ -42,6 +52,7 @@ describe('new CoreStorageVisitsApi({ storageApi: MockStorageApi.create() })', () it('saves a visit', async () => { const api = new CoreStorageVisitsApi({ storageApi: MockStorageApi.create(), + identityApi: mockIdentityApi, }); const visit = { pathname: '/catalog/default/component/playback-order', @@ -58,6 +69,7 @@ describe('new CoreStorageVisitsApi({ storageApi: MockStorageApi.create() })', () it('retrieves visits', async () => { const api = new CoreStorageVisitsApi({ storageApi: MockStorageApi.create(), + identityApi: mockIdentityApi, }); const visit = { pathname: '/catalog/default/component/playback-order', diff --git a/plugins/home/src/api/CoreStorageVisitsApi.ts b/plugins/home/src/api/CoreStorageVisitsApi.ts index 8b54fcc416..ae7fac14e1 100644 --- a/plugins/home/src/api/CoreStorageVisitsApi.ts +++ b/plugins/home/src/api/CoreStorageVisitsApi.ts @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { StorageApi } from '@backstage/core-plugin-api'; +import { IdentityApi, StorageApi } from '@backstage/core-plugin-api'; import { Visit } from './VisitsApi'; import { VisitsApiFactory } from './VisitsApiFactory'; @@ -23,30 +23,40 @@ import { VisitsApiFactory } from './VisitsApiFactory'; */ export class CoreStorageVisitsApi extends VisitsApiFactory { private readonly storageApi: StorageApi; - private readonly storageKey = '@backstage/plugin-home:visits'; + private readonly storageKeyPrefix = '@backstage/plugin-home:visits'; + private readonly identityApi: IdentityApi; constructor({ storageApi, + identityApi, randomUUID = window?.crypto?.randomUUID, limit = 100, }: { storageApi: StorageApi; randomUUID?: Window['crypto']['randomUUID']; limit?: number; + identityApi: IdentityApi; }) { super({ randomUUID, limit }); this.storageApi = storageApi; + this.identityApi = identityApi; this.retrieveAll = async (): Promise> => { let visits: Array; + const { userEntityRef } = await this.identityApi.getBackstageIdentity(); + const storageKey = `${this.storageKeyPrefix}:${userEntityRef}`; + try { - visits = - this.storageApi.snapshot>(this.storageKey).value ?? []; + visits = this.storageApi.snapshot>(storageKey).value ?? []; } catch { visits = []; } return visits; }; - this.persistAll = async (visits: Array) => - this.storageApi.set>(this.storageKey, visits); + this.persistAll = async (visits: Array) => { + const { userEntityRef } = await this.identityApi.getBackstageIdentity(); + const storageKey = `${this.storageKeyPrefix}:${userEntityRef}`; + + return this.storageApi.set>(storageKey, visits); + }; } } diff --git a/plugins/home/src/api/LocalStorageVisitsApi.test.ts b/plugins/home/src/api/LocalStorageVisitsApi.test.ts index 8cf94e6c25..4e2e3f9c7a 100644 --- a/plugins/home/src/api/LocalStorageVisitsApi.test.ts +++ b/plugins/home/src/api/LocalStorageVisitsApi.test.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import { BackstageUserIdentity, IdentityApi } from '@backstage/core-plugin-api'; import { LocalStorageVisitsApi } from './LocalStorageVisitsApi'; describe('new LocalStorageVisitsApi()', () => { @@ -23,21 +24,30 @@ describe('new LocalStorageVisitsApi()', () => { () => Math.floor(Math.random() * 16).toString(16), // 0x0 to 0xf ) as `${string}-${string}-${string}-${string}-${string}`; + const mockIdentityApi: IdentityApi = { + signOut: jest.fn(), + getProfileInfo: jest.fn(), + getBackstageIdentity: async () => + ({ userEntityRef: 'user:default/guest' } as BackstageUserIdentity), + getCredentials: jest.fn(), + }; + beforeEach(() => { window.crypto.randomUUID = mockRandomUUID; }); afterEach(() => { window.localStorage.clear(); + jest.resetAllMocks(); }); - it('instantiates with no configuration', () => { - const api = new LocalStorageVisitsApi(); + it('instantiates with only identitiyApi', () => { + const api = new LocalStorageVisitsApi({ identityApi: mockIdentityApi }); expect(api).toBeTruthy(); }); it('saves a visit', async () => { - const api = new LocalStorageVisitsApi(); + const api = new LocalStorageVisitsApi({ identityApi: mockIdentityApi }); const visit = { pathname: '/catalog/default/component/playback-order', entityRef: 'component:default/playback-order', @@ -51,7 +61,7 @@ describe('new LocalStorageVisitsApi()', () => { }); it('retrieves visits', async () => { - const api = new LocalStorageVisitsApi(); + const api = new LocalStorageVisitsApi({ identityApi: mockIdentityApi }); const visit = { pathname: '/catalog/default/component/playback-order', entityRef: 'component:default/playback-order', diff --git a/plugins/home/src/api/LocalStorageVisitsApi.ts b/plugins/home/src/api/LocalStorageVisitsApi.ts index 5999930e3a..21a8e5cb6f 100644 --- a/plugins/home/src/api/LocalStorageVisitsApi.ts +++ b/plugins/home/src/api/LocalStorageVisitsApi.ts @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { IdentityApi } from '@backstage/core-plugin-api'; import { Visit } from './VisitsApi'; import { VisitsApiFactory } from './VisitsApiFactory'; @@ -22,31 +23,41 @@ import { VisitsApiFactory } from './VisitsApiFactory'; */ export class LocalStorageVisitsApi extends VisitsApiFactory { private readonly localStorage: Window['localStorage']; - private readonly storageKey = '@backstage/plugin-home:visits'; + private readonly storageKeyPrefix = '@backstage/plugin-home:visits'; + private readonly identityApi: IdentityApi; constructor({ localStorage = window?.localStorage, randomUUID = window?.crypto?.randomUUID, limit = 100, + identityApi, }: { localStorage?: Window['localStorage']; randomUUID?: Window['crypto']['randomUUID']; limit?: number; - } = {}) { + identityApi: IdentityApi; + }) { super({ randomUUID, limit }); this.localStorage = localStorage; + this.identityApi = identityApi; this.retrieveAll = async (): Promise> => { let visits: Array; + const { userEntityRef } = await this.identityApi.getBackstageIdentity(); + const storageKey = `${this.storageKeyPrefix}:${userEntityRef}`; + try { - visits = JSON.parse(this.localStorage.getItem(this.storageKey) ?? '[]'); + visits = JSON.parse(this.localStorage.getItem(storageKey) ?? '[]'); } catch { visits = []; } return visits; }; this.persistAll = async (visits: Array) => { - this.localStorage.setItem( - this.storageKey, + const { userEntityRef } = await this.identityApi.getBackstageIdentity(); + const storageKey = `${this.storageKeyPrefix}:${userEntityRef}`; + + return this.localStorage.setItem( + storageKey, JSON.stringify(visits.splice(0, this.limit)), ); }; From b53d3537ddd30362ec9dc8ff298eedbb9aea14b0 Mon Sep 17 00:00:00 2001 From: Renan Mendes Carvalho Date: Wed, 30 Aug 2023 17:23:31 +0200 Subject: [PATCH 13/28] feature(home-plugin): Use static methods on Api classes Confirming with the style guide: 7.Keep constructors private, prefer static factory methods for creating instances. Signed-off-by: Renan Mendes Carvalho --- plugins/home/api-report.md | 59 ++++++++++--------- .../home/src/api/CoreStorageVisitsApi.test.ts | 8 +-- plugins/home/src/api/CoreStorageVisitsApi.ts | 21 ++++--- .../src/api/LocalStorageVisitsApi.test.ts | 8 +-- plugins/home/src/api/LocalStorageVisitsApi.ts | 21 ++++--- plugins/home/src/api/VisitsApiFactory.ts | 18 +++--- 6 files changed, 77 insertions(+), 58 deletions(-) diff --git a/plugins/home/api-report.md b/plugins/home/api-report.md index 9eddb81da5..2dc1aca8e2 100644 --- a/plugins/home/api-report.md +++ b/plugins/home/api-report.md @@ -78,19 +78,18 @@ export const ComponentTabs: (props: { // @public export class CoreStorageVisitsApi extends VisitsApiFactory { - constructor({ - storageApi, - identityApi, - randomUUID, - limit, - }: { - storageApi: StorageApi; - randomUUID?: Window['crypto']['randomUUID']; - limit?: number; - identityApi: IdentityApi; - }); + // (undocumented) + static create(options: CoreStorageVisitsApiOptions): CoreStorageVisitsApi; } +// @public (undocumented) +export type CoreStorageVisitsApiOptions = { + storageApi: StorageApi; + randomUUID?: Window['crypto']['randomUUID']; + limit?: number; + identityApi: IdentityApi; +}; + // @public @deprecated (undocumented) export const createCardExtension: typeof createCardExtension_2; @@ -180,19 +179,18 @@ export type LayoutConfiguration = { // @public export class LocalStorageVisitsApi extends VisitsApiFactory { - constructor({ - localStorage, - randomUUID, - limit, - identityApi, - }: { - localStorage?: Window['localStorage']; - randomUUID?: Window['crypto']['randomUUID']; - limit?: number; - identityApi: IdentityApi; - }); + // (undocumented) + static create(options: LocalStorageVisitsApiOptions): LocalStorageVisitsApi; } +// @public (undocumented) +export type LocalStorageVisitsApiOptions = { + localStorage?: Window['localStorage']; + randomUUID?: Window['crypto']['randomUUID']; + limit?: number; + identityApi: IdentityApi; +}; + // @public @deprecated (undocumented) export type RendererProps = RendererProps_2; @@ -270,17 +268,12 @@ export interface VisitsApi { // @public export class VisitsApiFactory implements VisitsApi { - constructor({ + protected constructor({ randomUUID, limit, retrieveAll, persistAll, - }: { - randomUUID: Window['crypto']['randomUUID']; - limit: number; - retrieveAll?: () => Promise>; - persistAll?: (visits: Array) => Promise; - }); + }: VisitsApiFactoryOptions); // (undocumented) protected readonly limit: number; // (undocumented) @@ -295,6 +288,14 @@ export class VisitsApiFactory implements VisitsApi { saveVisit(saveParams: VisitsApiSaveParams): Promise; } +// @public (undocumented) +export type VisitsApiFactoryOptions = { + randomUUID: Window['crypto']['randomUUID']; + limit: number; + retrieveAll?: () => Promise>; + persistAll?: (visits: Array) => Promise; +}; + // @public export type VisitsApiQueryParams = { limit?: number; diff --git a/plugins/home/src/api/CoreStorageVisitsApi.test.ts b/plugins/home/src/api/CoreStorageVisitsApi.test.ts index e14f71ad60..ab5d66c495 100644 --- a/plugins/home/src/api/CoreStorageVisitsApi.test.ts +++ b/plugins/home/src/api/CoreStorageVisitsApi.test.ts @@ -18,7 +18,7 @@ import { BackstageUserIdentity, IdentityApi } from '@backstage/core-plugin-api'; import { CoreStorageVisitsApi } from './CoreStorageVisitsApi'; import { MockStorageApi } from '@backstage/test-utils'; -describe('new CoreStorageVisitsApi({ storageApi: MockStorageApi.create() })', () => { +describe('CoreStorageVisitsApi.create({ storageApi: MockStorageApi.create() })', () => { const mockRandomUUID = () => '068f3129-7440-4e0e-8fd4-xxxxxxxxxxxx'.replace( /x/g, @@ -42,7 +42,7 @@ describe('new CoreStorageVisitsApi({ storageApi: MockStorageApi.create() })', () }); it('instantiates', () => { - const api = new CoreStorageVisitsApi({ + const api = CoreStorageVisitsApi.create({ storageApi: MockStorageApi.create(), identityApi: mockIdentityApi, }); @@ -50,7 +50,7 @@ describe('new CoreStorageVisitsApi({ storageApi: MockStorageApi.create() })', () }); it('saves a visit', async () => { - const api = new CoreStorageVisitsApi({ + const api = CoreStorageVisitsApi.create({ storageApi: MockStorageApi.create(), identityApi: mockIdentityApi, }); @@ -67,7 +67,7 @@ describe('new CoreStorageVisitsApi({ storageApi: MockStorageApi.create() })', () }); it('retrieves visits', async () => { - const api = new CoreStorageVisitsApi({ + const api = CoreStorageVisitsApi.create({ storageApi: MockStorageApi.create(), identityApi: mockIdentityApi, }); diff --git a/plugins/home/src/api/CoreStorageVisitsApi.ts b/plugins/home/src/api/CoreStorageVisitsApi.ts index ae7fac14e1..c4b70eff88 100644 --- a/plugins/home/src/api/CoreStorageVisitsApi.ts +++ b/plugins/home/src/api/CoreStorageVisitsApi.ts @@ -17,6 +17,14 @@ import { IdentityApi, StorageApi } from '@backstage/core-plugin-api'; import { Visit } from './VisitsApi'; import { VisitsApiFactory } from './VisitsApiFactory'; +/** @public */ +export type CoreStorageVisitsApiOptions = { + storageApi: StorageApi; + randomUUID?: Window['crypto']['randomUUID']; + limit?: number; + identityApi: IdentityApi; +}; + /** * @public * This is an implementation of VisitsApi that relies on a StorageApi @@ -26,17 +34,16 @@ export class CoreStorageVisitsApi extends VisitsApiFactory { private readonly storageKeyPrefix = '@backstage/plugin-home:visits'; private readonly identityApi: IdentityApi; - constructor({ + static create(options: CoreStorageVisitsApiOptions) { + return new CoreStorageVisitsApi(options); + } + + private constructor({ storageApi, identityApi, randomUUID = window?.crypto?.randomUUID, limit = 100, - }: { - storageApi: StorageApi; - randomUUID?: Window['crypto']['randomUUID']; - limit?: number; - identityApi: IdentityApi; - }) { + }: CoreStorageVisitsApiOptions) { super({ randomUUID, limit }); this.storageApi = storageApi; this.identityApi = identityApi; diff --git a/plugins/home/src/api/LocalStorageVisitsApi.test.ts b/plugins/home/src/api/LocalStorageVisitsApi.test.ts index 4e2e3f9c7a..c17fa3cfb8 100644 --- a/plugins/home/src/api/LocalStorageVisitsApi.test.ts +++ b/plugins/home/src/api/LocalStorageVisitsApi.test.ts @@ -17,7 +17,7 @@ import { BackstageUserIdentity, IdentityApi } from '@backstage/core-plugin-api'; import { LocalStorageVisitsApi } from './LocalStorageVisitsApi'; -describe('new LocalStorageVisitsApi()', () => { +describe('LocalStorageVisitsApi.create()', () => { const mockRandomUUID = () => '068f3129-7440-4e0e-8fd4-xxxxxxxxxxxx'.replace( /x/g, @@ -42,12 +42,12 @@ describe('new LocalStorageVisitsApi()', () => { }); it('instantiates with only identitiyApi', () => { - const api = new LocalStorageVisitsApi({ identityApi: mockIdentityApi }); + const api = LocalStorageVisitsApi.create({ identityApi: mockIdentityApi }); expect(api).toBeTruthy(); }); it('saves a visit', async () => { - const api = new LocalStorageVisitsApi({ identityApi: mockIdentityApi }); + const api = LocalStorageVisitsApi.create({ identityApi: mockIdentityApi }); const visit = { pathname: '/catalog/default/component/playback-order', entityRef: 'component:default/playback-order', @@ -61,7 +61,7 @@ describe('new LocalStorageVisitsApi()', () => { }); it('retrieves visits', async () => { - const api = new LocalStorageVisitsApi({ identityApi: mockIdentityApi }); + const api = LocalStorageVisitsApi.create({ identityApi: mockIdentityApi }); const visit = { pathname: '/catalog/default/component/playback-order', entityRef: 'component:default/playback-order', diff --git a/plugins/home/src/api/LocalStorageVisitsApi.ts b/plugins/home/src/api/LocalStorageVisitsApi.ts index 21a8e5cb6f..31cd30dc21 100644 --- a/plugins/home/src/api/LocalStorageVisitsApi.ts +++ b/plugins/home/src/api/LocalStorageVisitsApi.ts @@ -17,6 +17,14 @@ import { IdentityApi } from '@backstage/core-plugin-api'; import { Visit } from './VisitsApi'; import { VisitsApiFactory } from './VisitsApiFactory'; +/** @public */ +export type LocalStorageVisitsApiOptions = { + localStorage?: Window['localStorage']; + randomUUID?: Window['crypto']['randomUUID']; + limit?: number; + identityApi: IdentityApi; +}; + /** * @public * This is a reference implementation of VisitsApi using window.localStorage. @@ -26,17 +34,16 @@ export class LocalStorageVisitsApi extends VisitsApiFactory { private readonly storageKeyPrefix = '@backstage/plugin-home:visits'; private readonly identityApi: IdentityApi; - constructor({ + static create(options: LocalStorageVisitsApiOptions) { + return new LocalStorageVisitsApi(options); + } + + private constructor({ localStorage = window?.localStorage, randomUUID = window?.crypto?.randomUUID, limit = 100, identityApi, - }: { - localStorage?: Window['localStorage']; - randomUUID?: Window['crypto']['randomUUID']; - limit?: number; - identityApi: IdentityApi; - }) { + }: LocalStorageVisitsApiOptions) { super({ randomUUID, limit }); this.localStorage = localStorage; this.identityApi = identityApi; diff --git a/plugins/home/src/api/VisitsApiFactory.ts b/plugins/home/src/api/VisitsApiFactory.ts index ba3fe1fe3e..1b04c775a0 100644 --- a/plugins/home/src/api/VisitsApiFactory.ts +++ b/plugins/home/src/api/VisitsApiFactory.ts @@ -17,6 +17,14 @@ import { Visit, VisitsApi, VisitsApiQueryParams, VisitsApiSaveParams } from './V type ArrayElement = A extends readonly (infer T)[] ? T : never; +/** @public */ +export type VisitsApiFactoryOptions = { + randomUUID: Window['crypto']['randomUUID']; + limit: number; + retrieveAll?: () => Promise>; + persistAll?: (visits: Array) => Promise; +}; + /** * @public * This helps the creation of VisitApi implementations. Important to note @@ -30,17 +38,12 @@ export class VisitsApiFactory implements VisitsApi { protected retrieveAll: () => Promise>; protected persistAll: (visits: Array) => Promise; - constructor({ + protected constructor({ randomUUID = window?.crypto?.randomUUID, limit = 100, retrieveAll, persistAll, - }: { - randomUUID: Window['crypto']['randomUUID']; - limit: number; - retrieveAll?: () => Promise>; - persistAll?: (visits: Array) => Promise; - }) { + }: VisitsApiFactoryOptions) { this.randomUUID = randomUUID; this.limit = Math.abs(limit); this.retrieveAll = retrieveAll ?? (async () => []); @@ -59,6 +62,7 @@ export class VisitsApiFactory implements VisitsApi { } }); + // reversing order to guarantee filterBy priority (queryParams?.filterBy ?? []).reverse().forEach(filter => { visits = visits.filter(visit => { const field = visit[filter.field] as number | string; From c30e715a2bfe3326c6be72577963271d56848f04 Mon Sep 17 00:00:00 2001 From: Renan Mendes Carvalho Date: Thu, 31 Aug 2023 10:17:35 +0200 Subject: [PATCH 14/28] feature(home-plugin): Add default implementation to plugin.ts Signed-off-by: Renan Mendes Carvalho --- plugins/home/src/plugin.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/plugins/home/src/plugin.ts b/plugins/home/src/plugin.ts index c357562bec..3bbfd9d544 100644 --- a/plugins/home/src/plugin.ts +++ b/plugins/home/src/plugin.ts @@ -15,17 +15,32 @@ */ import { + createApiFactory, createComponentExtension, createPlugin, createRoutableExtension, + identityApiRef, + storageApiRef, } from '@backstage/core-plugin-api'; import { createCardExtension } from '@backstage/plugin-home-react'; import { ToolkitContentProps, VisitedByTypeProps } from './homePageComponents'; import { rootRouteRef } from './routes'; +import { CoreStorageVisitsApi, visitsApiRef } from './api'; /** @public */ export const homePlugin = createPlugin({ id: 'home', + apis: [ + createApiFactory({ + api: visitsApiRef, + deps: { + storageApi: storageApiRef, + identityApi: identityApiRef, + }, + factory: ({ storageApi, identityApi }) => + CoreStorageVisitsApi.create({ storageApi, identityApi }), + }), + ], routes: { root: rootRouteRef, }, From 6c93a6799e1befc7221b54a2dff1a0dc58d6c903 Mon Sep 17 00:00:00 2001 From: Renan Mendes Carvalho Date: Fri, 8 Sep 2023 13:31:12 +0200 Subject: [PATCH 15/28] refactor(plugins/home): Simplify VisitsApi method names Signed-off-by: Renan Mendes Carvalho --- plugins/home/api-report.md | 8 +-- .../home/src/api/CoreStorageVisitsApi.test.ts | 6 +-- .../src/api/LocalStorageVisitsApi.test.ts | 6 +-- plugins/home/src/api/VisitsApi.ts | 4 +- plugins/home/src/api/VisitsApiFactory.test.ts | 50 +++++++++---------- plugins/home/src/api/VisitsApiFactory.ts | 13 +++-- .../src/components/VisitListener.test.tsx | 14 +++--- plugins/home/src/components/VisitListener.tsx | 2 +- .../VisitedByType/Content.test.tsx | 4 +- .../VisitedByType/Content.tsx | 4 +- .../HomePageVisitedByType.stories.tsx | 4 +- 11 files changed, 58 insertions(+), 57 deletions(-) diff --git a/plugins/home/api-report.md b/plugins/home/api-report.md index 2dc1aca8e2..a71916d8b3 100644 --- a/plugins/home/api-report.md +++ b/plugins/home/api-report.md @@ -262,8 +262,8 @@ export const VisitListener: ({ // @public export interface VisitsApi { - listVisits(queryParams?: VisitsApiQueryParams): Promise; - saveVisit(saveParams: VisitsApiSaveParams): Promise; + list(queryParams?: VisitsApiQueryParams): Promise; + save(saveParams: VisitsApiSaveParams): Promise; } // @public @@ -277,7 +277,7 @@ export class VisitsApiFactory implements VisitsApi { // (undocumented) protected readonly limit: number; // (undocumented) - listVisits(queryParams?: VisitsApiQueryParams): Promise; + list(queryParams?: VisitsApiQueryParams): Promise; // (undocumented) protected persistAll: (visits: Array) => Promise; // (undocumented) @@ -285,7 +285,7 @@ export class VisitsApiFactory implements VisitsApi { // (undocumented) protected retrieveAll: () => Promise>; // (undocumented) - saveVisit(saveParams: VisitsApiSaveParams): Promise; + save(saveParams: VisitsApiSaveParams): Promise; } // @public (undocumented) diff --git a/plugins/home/src/api/CoreStorageVisitsApi.test.ts b/plugins/home/src/api/CoreStorageVisitsApi.test.ts index ab5d66c495..e1f39d8464 100644 --- a/plugins/home/src/api/CoreStorageVisitsApi.test.ts +++ b/plugins/home/src/api/CoreStorageVisitsApi.test.ts @@ -59,7 +59,7 @@ describe('CoreStorageVisitsApi.create({ storageApi: MockStorageApi.create() })', entityRef: 'component:default/playback-order', name: 'Playback Order', }; - const returnedVisit = await api.saveVisit({ visit }); + const returnedVisit = await api.save({ visit }); expect(returnedVisit).toEqual(expect.objectContaining(visit)); expect(returnedVisit.id).toBeTruthy(); expect(returnedVisit.timestamp).toBeTruthy(); @@ -76,8 +76,8 @@ describe('CoreStorageVisitsApi.create({ storageApi: MockStorageApi.create() })', entityRef: 'component:default/playback-order', name: 'Playback Order', }; - const returnedVisit = await api.saveVisit({ visit }); - const visits = await api.listVisits(); + const returnedVisit = await api.save({ visit }); + const visits = await api.list(); expect(visits).toHaveLength(1); expect(visits).toEqual([expect.objectContaining(visit)]); expect(visits).toEqual([returnedVisit]); diff --git a/plugins/home/src/api/LocalStorageVisitsApi.test.ts b/plugins/home/src/api/LocalStorageVisitsApi.test.ts index c17fa3cfb8..eb19859055 100644 --- a/plugins/home/src/api/LocalStorageVisitsApi.test.ts +++ b/plugins/home/src/api/LocalStorageVisitsApi.test.ts @@ -53,7 +53,7 @@ describe('LocalStorageVisitsApi.create()', () => { entityRef: 'component:default/playback-order', name: 'Playback Order', }; - const returnedVisit = await api.saveVisit({ visit }); + const returnedVisit = await api.save({ visit }); expect(returnedVisit).toEqual(expect.objectContaining(visit)); expect(returnedVisit.id).toBeTruthy(); expect(returnedVisit.timestamp).toBeTruthy(); @@ -67,8 +67,8 @@ describe('LocalStorageVisitsApi.create()', () => { entityRef: 'component:default/playback-order', name: 'Playback Order', }; - const returnedVisit = await api.saveVisit({ visit }); - const visits = await api.listVisits(); + const returnedVisit = await api.save({ visit }); + const visits = await api.list(); expect(visits).toHaveLength(1); expect(visits).toEqual([expect.objectContaining(visit)]); expect(visits).toEqual([returnedVisit]); diff --git a/plugins/home/src/api/VisitsApi.ts b/plugins/home/src/api/VisitsApi.ts index ab53ea7f10..ab43d1c7a7 100644 --- a/plugins/home/src/api/VisitsApi.ts +++ b/plugins/home/src/api/VisitsApi.ts @@ -106,12 +106,12 @@ export interface VisitsApi { * Persist a new visit. * @param pageVisit - a new visit data */ - saveVisit(saveParams: VisitsApiSaveParams): Promise; + save(saveParams: VisitsApiSaveParams): Promise; /** * Get user visits. * @param queryParams - optional search query params. */ - listVisits(queryParams?: VisitsApiQueryParams): Promise; + list(queryParams?: VisitsApiQueryParams): Promise; } /** @public */ diff --git a/plugins/home/src/api/VisitsApiFactory.test.ts b/plugins/home/src/api/VisitsApiFactory.test.ts index 3281c320b7..8c19a8e6a5 100644 --- a/plugins/home/src/api/VisitsApiFactory.test.ts +++ b/plugins/home/src/api/VisitsApiFactory.test.ts @@ -66,7 +66,7 @@ describe('new MemoryVisitsApi()', () => { expect(api).toBeTruthy(); }); - describe('.saveVisit()', () => { + describe('.save()', () => { it('saves a visit', async () => { const api = new MemoryVisitsApi(); const visit = { @@ -74,7 +74,7 @@ describe('new MemoryVisitsApi()', () => { entityRef: 'component:default/playback-order', name: 'Playback Order', }; - const returnedVisit = await api.saveVisit({ visit }); + const returnedVisit = await api.save({ visit }); expect(returnedVisit).toEqual(expect.objectContaining(visit)); expect(returnedVisit.id).toBeTruthy(); expect(returnedVisit.timestamp).toBeTruthy(); @@ -90,22 +90,22 @@ describe('new MemoryVisitsApi()', () => { name: 'Playback Order', }; jest.setSystemTime(baseDate); - await api.saveVisit({ visit: visit1 }); + await api.save({ visit: visit1 }); const visit2 = { pathname: '/catalog/default/component/playback-order-2', entityRef: 'component:default/playback-order', name: 'Playback Order', }; jest.setSystemTime(baseDate + 360_000); - await api.saveVisit({ visit: visit2 }); + await api.save({ visit: visit2 }); const visit3 = { pathname: '/catalog/default/component/playback-order-3', entityRef: 'component:default/playback-order', name: 'Playback Order', }; jest.setSystemTime(baseDate + 360_000 * 2); - await api.saveVisit({ visit: visit3 }); - const visits = await api.listVisits(); + await api.save({ visit: visit3 }); + const visits = await api.list(); expect(visits).toHaveLength(2); expect(visits).toContainEqual(expect.objectContaining(visit2)); expect(visits).toContainEqual(expect.objectContaining(visit3)); @@ -118,9 +118,9 @@ describe('new MemoryVisitsApi()', () => { entityRef: 'component:default/playback-order', name: 'Playback Order', }; - const visit1 = await api.saveVisit({ visit }); - const visit2 = await api.saveVisit({ visit }); - const visits = await api.listVisits(); + const visit1 = await api.save({ visit }); + const visit2 = await api.save({ visit }); + const visits = await api.list(); expect(visits).toHaveLength(1); expect(visits).toContainEqual(expect.objectContaining(visit)); // keeps the original id created on the first visit @@ -132,7 +132,7 @@ describe('new MemoryVisitsApi()', () => { }); }); - describe('.listVisits()', () => { + describe('.list()', () => { let api: MemoryVisitsApi; let visitsToSave: Array>; let baseDate: number; @@ -161,14 +161,14 @@ describe('new MemoryVisitsApi()', () => { (acc, visit, index) => acc.then(() => { jest.setSystemTime(baseDate + 360_000 * index); - return api.saveVisit({ visit }); + return api.save({ visit }); }), Promise.resolve({}), ); }); it('retrieves visits', async () => { - const visits = await api.listVisits(); + const visits = await api.list(); expect(visits).toHaveLength(3); expect(visits).toEqual([ expect.objectContaining(visitsToSave[2]), @@ -178,7 +178,7 @@ describe('new MemoryVisitsApi()', () => { }); it('orders by timestamp asc', async () => { - const visits = await api.listVisits({ + const visits = await api.list({ orderBy: [{ field: 'timestamp', direction: 'asc' }], }); expect(visits).toEqual([ @@ -189,7 +189,7 @@ describe('new MemoryVisitsApi()', () => { }); it('orders by timestamp desc', async () => { - const visits = await api.listVisits({ + const visits = await api.list({ orderBy: [{ field: 'timestamp', direction: 'desc' }], }); expect(visits).toEqual([ @@ -200,7 +200,7 @@ describe('new MemoryVisitsApi()', () => { }); it('orders by entityRef asc', async () => { - const visits = await api.listVisits({ + const visits = await api.list({ orderBy: [{ field: 'entityRef', direction: 'asc' }], }); expect(visits).toEqual([ @@ -211,7 +211,7 @@ describe('new MemoryVisitsApi()', () => { }); it('orders by entityRef desc', async () => { - const visits = await api.listVisits({ + const visits = await api.list({ orderBy: [{ field: 'entityRef', direction: 'desc' }], }); expect(visits).toEqual([ @@ -222,7 +222,7 @@ describe('new MemoryVisitsApi()', () => { }); it('orders by name asc then by entityRef asc', async () => { - const visits = await api.listVisits({ + const visits = await api.list({ orderBy: [ { field: 'name', direction: 'asc' }, { field: 'entityRef', direction: 'asc' }, @@ -236,7 +236,7 @@ describe('new MemoryVisitsApi()', () => { }); it('orders by name desc then by entityRef asc', async () => { - const visits = await api.listVisits({ + const visits = await api.list({ orderBy: [ { field: 'name', direction: 'desc' }, { field: 'entityRef', direction: 'asc' }, @@ -250,7 +250,7 @@ describe('new MemoryVisitsApi()', () => { }); it('filters by timestamp with >', async () => { - const visits = await api.listVisits({ + const visits = await api.list({ filterBy: [{ field: 'timestamp', operator: '>', value: baseDate }], }); expect(visits).toHaveLength(2); @@ -261,7 +261,7 @@ describe('new MemoryVisitsApi()', () => { }); it('filters by timestamp with >=', async () => { - const visits = await api.listVisits({ + const visits = await api.list({ filterBy: [ { field: 'timestamp', operator: '>=', value: baseDate + 360_000 * 2 }, ], @@ -271,7 +271,7 @@ describe('new MemoryVisitsApi()', () => { }); it('filters by timestamp with <', async () => { - const visits = await api.listVisits({ + const visits = await api.list({ filterBy: [{ field: 'timestamp', operator: '<', value: baseDate + 1 }], }); expect(visits).toHaveLength(1); @@ -279,7 +279,7 @@ describe('new MemoryVisitsApi()', () => { }); it('filters by timestamp with <=', async () => { - const visits = await api.listVisits({ + const visits = await api.list({ filterBy: [ { field: 'timestamp', operator: '<=', value: baseDate + 360_000 }, ], @@ -292,7 +292,7 @@ describe('new MemoryVisitsApi()', () => { }); it('filters by timestamp with ==', async () => { - const visits = await api.listVisits({ + const visits = await api.list({ filterBy: [ { field: 'timestamp', operator: '==', value: baseDate + 360_000 }, ], @@ -302,7 +302,7 @@ describe('new MemoryVisitsApi()', () => { }); it('filters by entityRef with contains', async () => { - const visits = await api.listVisits({ + const visits = await api.list({ filterBy: [ { field: 'entityRef', operator: 'contains', value: 'order-2' }, ], @@ -312,7 +312,7 @@ describe('new MemoryVisitsApi()', () => { }); it('filters by timestamp with <= then by name with contains', async () => { - const visits = await api.listVisits({ + const visits = await api.list({ filterBy: [ { field: 'timestamp', operator: '<=', value: baseDate + 360_000 }, { field: 'name', operator: 'contains', value: 'Odd' }, diff --git a/plugins/home/src/api/VisitsApiFactory.ts b/plugins/home/src/api/VisitsApiFactory.ts index 1b04c775a0..d54ed0c984 100644 --- a/plugins/home/src/api/VisitsApiFactory.ts +++ b/plugins/home/src/api/VisitsApiFactory.ts @@ -13,7 +13,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { Visit, VisitsApi, VisitsApiQueryParams, VisitsApiSaveParams } from './VisitsApi'; +import { + Visit, + VisitsApi, + VisitsApiQueryParams, + VisitsApiSaveParams, +} from './VisitsApi'; type ArrayElement = A extends readonly (infer T)[] ? T : never; @@ -50,7 +55,7 @@ export class VisitsApiFactory implements VisitsApi { this.persistAll = persistAll ?? (async () => {}); } - async listVisits(queryParams?: VisitsApiQueryParams): Promise { + async list(queryParams?: VisitsApiQueryParams): Promise { let visits = await this.retrieveAll(); // reversing order to guarantee orderBy priority @@ -80,9 +85,7 @@ export class VisitsApiFactory implements VisitsApi { return visits; } - async saveVisit( - saveParams: VisitsApiSaveParams, - ): Promise { + async save(saveParams: VisitsApiSaveParams): Promise { const visits = await this.retrieveAll(); const visit: Visit = { diff --git a/plugins/home/src/components/VisitListener.test.tsx b/plugins/home/src/components/VisitListener.test.tsx index 81587b4067..7a0ce57de5 100644 --- a/plugins/home/src/components/VisitListener.test.tsx +++ b/plugins/home/src/components/VisitListener.test.tsx @@ -45,8 +45,8 @@ const visits: Array = [ ]; const mockVisitsApi = { - saveVisit: jest.fn(async () => visits[0]), - listVisits: jest.fn(async () => visits), + save: jest.fn(async () => visits[0]), + list: jest.fn(async () => visits), }; describe('', () => { @@ -63,10 +63,8 @@ describe('', () => { { routeEntries: [pathname] }, ); - await waitFor(() => - expect(mockVisitsApi.saveVisit).toHaveBeenCalledTimes(1), - ); - expect(mockVisitsApi.saveVisit).toHaveBeenCalledWith({ + await waitFor(() => expect(mockVisitsApi.save).toHaveBeenCalledTimes(1)); + expect(mockVisitsApi.save).toHaveBeenCalledWith({ visit: { pathname, entityRef: 'component:default/playback-order', @@ -102,7 +100,7 @@ describe('', () => { ); await waitFor(() => - expect(mockVisitsApi.saveVisit).toHaveBeenCalledWith({ + expect(mockVisitsApi.save).toHaveBeenCalledWith({ visit: { pathname, entityRef: 'component:default/playback-order', @@ -127,7 +125,7 @@ describe('', () => { ); await waitFor(() => - expect(mockVisitsApi.saveVisit).toHaveBeenCalledWith({ + expect(mockVisitsApi.save).toHaveBeenCalledWith({ visit: { pathname, entityRef: pathname, diff --git a/plugins/home/src/components/VisitListener.tsx b/plugins/home/src/components/VisitListener.tsx index c0cf34bdc9..4a7900a63a 100644 --- a/plugins/home/src/components/VisitListener.tsx +++ b/plugins/home/src/components/VisitListener.tsx @@ -81,7 +81,7 @@ export const VisitListener = ({ // Wait for the browser to finish with paint with the assumption react // has finished with dom reconciliation. const requestId = requestAnimationFrame(() => { - visitsApi.saveVisit({ + visitsApi.save({ visit: { name: visitNameImpl({ pathname }), pathname, diff --git a/plugins/home/src/homePageComponents/VisitedByType/Content.test.tsx b/plugins/home/src/homePageComponents/VisitedByType/Content.test.tsx index a9ff2cdd7c..4442c319e6 100644 --- a/plugins/home/src/homePageComponents/VisitedByType/Content.test.tsx +++ b/plugins/home/src/homePageComponents/VisitedByType/Content.test.tsx @@ -32,8 +32,8 @@ const visits = [ ]; const mockVisitsApi = { - saveVisit: async () => visits[0], - listVisits: async () => visits, + save: async () => visits[0], + list: async () => visits, }; describe('', () => { diff --git a/plugins/home/src/homePageComponents/VisitedByType/Content.tsx b/plugins/home/src/homePageComponents/VisitedByType/Content.tsx index 8f492a2099..4e847b717f 100644 --- a/plugins/home/src/homePageComponents/VisitedByType/Content.tsx +++ b/plugins/home/src/homePageComponents/VisitedByType/Content.tsx @@ -65,7 +65,7 @@ export const Content = ({ const { loading: reqLoading } = useAsync(async () => { if (!visits && !loading && kind === 'recent') { return await visitsApi - .listVisits({ + .list({ limit: numVisitsTotal ?? 8, orderBy: [{ field: 'timestamp', direction: 'desc' }], }) @@ -73,7 +73,7 @@ export const Content = ({ } if (!visits && !loading && kind === 'top') { return await visitsApi - .listVisits({ + .list({ limit: numVisitsTotal ?? 8, orderBy: [{ field: 'hits', direction: 'desc' }], }) diff --git a/plugins/home/src/homePageComponents/VisitedByType/HomePageVisitedByType.stories.tsx b/plugins/home/src/homePageComponents/VisitedByType/HomePageVisitedByType.stories.tsx index 0dd9c9e873..19861bf58e 100644 --- a/plugins/home/src/homePageComponents/VisitedByType/HomePageVisitedByType.stories.tsx +++ b/plugins/home/src/homePageComponents/VisitedByType/HomePageVisitedByType.stories.tsx @@ -87,8 +87,8 @@ const visits: Array = [ ]; const mockVisitsApi = { - saveVisit: async () => visits[0], - listUserVisits: async () => visits, + save: async () => visits[0], + list: async () => visits, }; export default { From 64caa4bf2068ec5a8fe822aa67db4f74c079d50d Mon Sep 17 00:00:00 2001 From: Renan Mendes Carvalho Date: Mon, 18 Sep 2023 10:37:24 +0200 Subject: [PATCH 16/28] refactoring(plugins/home): Remove unnecessary injection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This patch removes the injection of localStorage and randomUUID. Signed-off-by: Renan Mendes Carvalho Co-authored-by: Fredrik Adelöw --- plugins/home/api-report.md | 7 +------ plugins/home/src/api/CoreStorageVisitsApi.ts | 4 +--- plugins/home/src/api/LocalStorageVisitsApi.ts | 9 ++------- plugins/home/src/api/VisitsApiFactory.test.ts | 4 +--- plugins/home/src/api/VisitsApiFactory.ts | 7 ++----- 5 files changed, 7 insertions(+), 24 deletions(-) diff --git a/plugins/home/api-report.md b/plugins/home/api-report.md index a71916d8b3..38ae82aa7c 100644 --- a/plugins/home/api-report.md +++ b/plugins/home/api-report.md @@ -85,7 +85,6 @@ export class CoreStorageVisitsApi extends VisitsApiFactory { // @public (undocumented) export type CoreStorageVisitsApiOptions = { storageApi: StorageApi; - randomUUID?: Window['crypto']['randomUUID']; limit?: number; identityApi: IdentityApi; }; @@ -185,8 +184,6 @@ export class LocalStorageVisitsApi extends VisitsApiFactory { // @public (undocumented) export type LocalStorageVisitsApiOptions = { - localStorage?: Window['localStorage']; - randomUUID?: Window['crypto']['randomUUID']; limit?: number; identityApi: IdentityApi; }; @@ -269,7 +266,6 @@ export interface VisitsApi { // @public export class VisitsApiFactory implements VisitsApi { protected constructor({ - randomUUID, limit, retrieveAll, persistAll, @@ -281,7 +277,7 @@ export class VisitsApiFactory implements VisitsApi { // (undocumented) protected persistAll: (visits: Array) => Promise; // (undocumented) - protected readonly randomUUID: Window['crypto']['randomUUID']; + protected readonly randomUUID: () => `${string}-${string}-${string}-${string}-${string}`; // (undocumented) protected retrieveAll: () => Promise>; // (undocumented) @@ -290,7 +286,6 @@ export class VisitsApiFactory implements VisitsApi { // @public (undocumented) export type VisitsApiFactoryOptions = { - randomUUID: Window['crypto']['randomUUID']; limit: number; retrieveAll?: () => Promise>; persistAll?: (visits: Array) => Promise; diff --git a/plugins/home/src/api/CoreStorageVisitsApi.ts b/plugins/home/src/api/CoreStorageVisitsApi.ts index c4b70eff88..e392c2f30c 100644 --- a/plugins/home/src/api/CoreStorageVisitsApi.ts +++ b/plugins/home/src/api/CoreStorageVisitsApi.ts @@ -20,7 +20,6 @@ import { VisitsApiFactory } from './VisitsApiFactory'; /** @public */ export type CoreStorageVisitsApiOptions = { storageApi: StorageApi; - randomUUID?: Window['crypto']['randomUUID']; limit?: number; identityApi: IdentityApi; }; @@ -41,10 +40,9 @@ export class CoreStorageVisitsApi extends VisitsApiFactory { private constructor({ storageApi, identityApi, - randomUUID = window?.crypto?.randomUUID, limit = 100, }: CoreStorageVisitsApiOptions) { - super({ randomUUID, limit }); + super({ limit }); this.storageApi = storageApi; this.identityApi = identityApi; this.retrieveAll = async (): Promise> => { diff --git a/plugins/home/src/api/LocalStorageVisitsApi.ts b/plugins/home/src/api/LocalStorageVisitsApi.ts index 31cd30dc21..42f8495a5e 100644 --- a/plugins/home/src/api/LocalStorageVisitsApi.ts +++ b/plugins/home/src/api/LocalStorageVisitsApi.ts @@ -19,8 +19,6 @@ import { VisitsApiFactory } from './VisitsApiFactory'; /** @public */ export type LocalStorageVisitsApiOptions = { - localStorage?: Window['localStorage']; - randomUUID?: Window['crypto']['randomUUID']; limit?: number; identityApi: IdentityApi; }; @@ -30,7 +28,7 @@ export type LocalStorageVisitsApiOptions = { * This is a reference implementation of VisitsApi using window.localStorage. */ export class LocalStorageVisitsApi extends VisitsApiFactory { - private readonly localStorage: Window['localStorage']; + private readonly localStorage = window.localStorage; private readonly storageKeyPrefix = '@backstage/plugin-home:visits'; private readonly identityApi: IdentityApi; @@ -39,13 +37,10 @@ export class LocalStorageVisitsApi extends VisitsApiFactory { } private constructor({ - localStorage = window?.localStorage, - randomUUID = window?.crypto?.randomUUID, limit = 100, identityApi, }: LocalStorageVisitsApiOptions) { - super({ randomUUID, limit }); - this.localStorage = localStorage; + super({ limit }); this.identityApi = identityApi; this.retrieveAll = async (): Promise> => { let visits: Array; diff --git a/plugins/home/src/api/VisitsApiFactory.test.ts b/plugins/home/src/api/VisitsApiFactory.test.ts index 8c19a8e6a5..4e00b1432f 100644 --- a/plugins/home/src/api/VisitsApiFactory.test.ts +++ b/plugins/home/src/api/VisitsApiFactory.test.ts @@ -21,13 +21,11 @@ class MemoryVisitsApi extends VisitsApiFactory { private visits: Array = []; constructor({ - randomUUID = window?.crypto?.randomUUID, limit = 100, }: { - randomUUID?: Window['crypto']['randomUUID']; limit?: number; } = {}) { - super({ randomUUID, limit }); + super({ limit }); this.retrieveAll = async (): Promise> => { let visits: Array; try { diff --git a/plugins/home/src/api/VisitsApiFactory.ts b/plugins/home/src/api/VisitsApiFactory.ts index d54ed0c984..48952c0a1c 100644 --- a/plugins/home/src/api/VisitsApiFactory.ts +++ b/plugins/home/src/api/VisitsApiFactory.ts @@ -24,7 +24,6 @@ type ArrayElement = A extends readonly (infer T)[] ? T : never; /** @public */ export type VisitsApiFactoryOptions = { - randomUUID: Window['crypto']['randomUUID']; limit: number; retrieveAll?: () => Promise>; persistAll?: (visits: Array) => Promise; @@ -38,18 +37,16 @@ export type VisitsApiFactoryOptions = { * See LocalStorageVisitsApi for an usage example. */ export class VisitsApiFactory implements VisitsApi { - protected readonly randomUUID: Window['crypto']['randomUUID']; + protected readonly randomUUID = window.crypto.randomUUID; protected readonly limit: number; protected retrieveAll: () => Promise>; protected persistAll: (visits: Array) => Promise; protected constructor({ - randomUUID = window?.crypto?.randomUUID, limit = 100, retrieveAll, persistAll, }: VisitsApiFactoryOptions) { - this.randomUUID = randomUUID; this.limit = Math.abs(limit); this.retrieveAll = retrieveAll ?? (async () => []); this.persistAll = persistAll ?? (async () => {}); @@ -90,7 +87,7 @@ export class VisitsApiFactory implements VisitsApi { const visit: Visit = { ...saveParams.visit, - id: this.randomUUID(), + id: window.crypto.randomUUID(), hits: 1, timestamp: Date.now(), }; From bc93253806f8db0a7c584bce56e703dc34942b96 Mon Sep 17 00:00:00 2001 From: Renan Mendes Carvalho Date: Mon, 18 Sep 2023 10:56:14 +0200 Subject: [PATCH 17/28] refactor(plugins/home): Keep api-report concise. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This patch remove parameter destructuring to simplify api-report reads. Signed-off-by: Renan Mendes Carvalho Co-authored-by: Fredrik Adelöw --- plugins/home/api-report.md | 6 +----- plugins/home/src/api/CoreStorageVisitsApi.ts | 12 ++++-------- plugins/home/src/api/LocalStorageVisitsApi.ts | 9 +++------ plugins/home/src/api/VisitsApiFactory.ts | 12 ++++-------- 4 files changed, 12 insertions(+), 27 deletions(-) diff --git a/plugins/home/api-report.md b/plugins/home/api-report.md index 38ae82aa7c..9271d00492 100644 --- a/plugins/home/api-report.md +++ b/plugins/home/api-report.md @@ -265,11 +265,7 @@ export interface VisitsApi { // @public export class VisitsApiFactory implements VisitsApi { - protected constructor({ - limit, - retrieveAll, - persistAll, - }: VisitsApiFactoryOptions); + protected constructor(options: VisitsApiFactoryOptions); // (undocumented) protected readonly limit: number; // (undocumented) diff --git a/plugins/home/src/api/CoreStorageVisitsApi.ts b/plugins/home/src/api/CoreStorageVisitsApi.ts index e392c2f30c..18cdaca90f 100644 --- a/plugins/home/src/api/CoreStorageVisitsApi.ts +++ b/plugins/home/src/api/CoreStorageVisitsApi.ts @@ -37,14 +37,10 @@ export class CoreStorageVisitsApi extends VisitsApiFactory { return new CoreStorageVisitsApi(options); } - private constructor({ - storageApi, - identityApi, - limit = 100, - }: CoreStorageVisitsApiOptions) { - super({ limit }); - this.storageApi = storageApi; - this.identityApi = identityApi; + private constructor(options: CoreStorageVisitsApiOptions) { + super({ limit: options.limit ?? 100 }); + this.storageApi = options.storageApi; + this.identityApi = options.identityApi; this.retrieveAll = async (): Promise> => { let visits: Array; const { userEntityRef } = await this.identityApi.getBackstageIdentity(); diff --git a/plugins/home/src/api/LocalStorageVisitsApi.ts b/plugins/home/src/api/LocalStorageVisitsApi.ts index 42f8495a5e..44932b93f5 100644 --- a/plugins/home/src/api/LocalStorageVisitsApi.ts +++ b/plugins/home/src/api/LocalStorageVisitsApi.ts @@ -36,12 +36,9 @@ export class LocalStorageVisitsApi extends VisitsApiFactory { return new LocalStorageVisitsApi(options); } - private constructor({ - limit = 100, - identityApi, - }: LocalStorageVisitsApiOptions) { - super({ limit }); - this.identityApi = identityApi; + private constructor(options: LocalStorageVisitsApiOptions) { + super({ limit: options.limit ?? 100 }); + this.identityApi = options.identityApi; this.retrieveAll = async (): Promise> => { let visits: Array; const { userEntityRef } = await this.identityApi.getBackstageIdentity(); diff --git a/plugins/home/src/api/VisitsApiFactory.ts b/plugins/home/src/api/VisitsApiFactory.ts index 48952c0a1c..e83bcef723 100644 --- a/plugins/home/src/api/VisitsApiFactory.ts +++ b/plugins/home/src/api/VisitsApiFactory.ts @@ -42,14 +42,10 @@ export class VisitsApiFactory implements VisitsApi { protected retrieveAll: () => Promise>; protected persistAll: (visits: Array) => Promise; - protected constructor({ - limit = 100, - retrieveAll, - persistAll, - }: VisitsApiFactoryOptions) { - this.limit = Math.abs(limit); - this.retrieveAll = retrieveAll ?? (async () => []); - this.persistAll = persistAll ?? (async () => {}); + protected constructor(options: VisitsApiFactoryOptions) { + this.limit = Math.abs(options.limit ?? 100); + this.retrieveAll = options.retrieveAll ?? (async () => []); + this.persistAll = options.persistAll ?? (async () => {}); } async list(queryParams?: VisitsApiQueryParams): Promise { From a1a7885ca45353b804462c84dac757ef8b498445 Mon Sep 17 00:00:00 2001 From: Renan Mendes Carvalho Date: Tue, 19 Sep 2023 11:10:11 +0200 Subject: [PATCH 18/28] feature(plugins/home): Add support for the != operator This patch adds support for the != operator on the VisitsApi filterBy option. Signed-off-by: Renan Mendes Carvalho --- plugins/home/api-report.md | 2 +- plugins/home/src/api/VisitsApi.ts | 2 +- plugins/home/src/api/VisitsApiFactory.test.ts | 13 +++++++++++++ plugins/home/src/api/VisitsApiFactory.ts | 1 + 4 files changed, 16 insertions(+), 2 deletions(-) diff --git a/plugins/home/api-report.md b/plugins/home/api-report.md index 9271d00492..f0a38506ca 100644 --- a/plugins/home/api-report.md +++ b/plugins/home/api-report.md @@ -296,7 +296,7 @@ export type VisitsApiQueryParams = { }>; filterBy?: Array<{ field: keyof Visit; - operator: '<' | '<=' | '==' | '>' | '>=' | 'contains'; + operator: '<' | '<=' | '==' | '!=' | '>' | '>=' | 'contains'; value: string | number; }>; }; diff --git a/plugins/home/src/api/VisitsApi.ts b/plugins/home/src/api/VisitsApi.ts index ab43d1c7a7..ad750d81f2 100644 --- a/plugins/home/src/api/VisitsApi.ts +++ b/plugins/home/src/api/VisitsApi.ts @@ -84,7 +84,7 @@ export type VisitsApiQueryParams = { */ filterBy?: Array<{ field: keyof Visit; - operator: '<' | '<=' | '==' | '>' | '>=' | 'contains'; + operator: '<' | '<=' | '==' | '!=' | '>' | '>=' | 'contains'; value: string | number; }>; }; diff --git a/plugins/home/src/api/VisitsApiFactory.test.ts b/plugins/home/src/api/VisitsApiFactory.test.ts index 4e00b1432f..416481be53 100644 --- a/plugins/home/src/api/VisitsApiFactory.test.ts +++ b/plugins/home/src/api/VisitsApiFactory.test.ts @@ -299,6 +299,19 @@ describe('new MemoryVisitsApi()', () => { expect(visits).toEqual([expect.objectContaining(visitsToSave[1])]); }); + it('filters by timestamp with !=', async () => { + const visits = await api.list({ + filterBy: [ + { field: 'timestamp', operator: '!=', value: baseDate + 360_000 }, + ], + }); + expect(visits).toHaveLength(2); + expect(visits).toEqual([ + expect.objectContaining(visitsToSave[2]), + expect.objectContaining(visitsToSave[0]), + ]); + }); + it('filters by entityRef with contains', async () => { const visits = await api.list({ filterBy: [ diff --git a/plugins/home/src/api/VisitsApiFactory.ts b/plugins/home/src/api/VisitsApiFactory.ts index e83bcef723..9ba6a2fe79 100644 --- a/plugins/home/src/api/VisitsApiFactory.ts +++ b/plugins/home/src/api/VisitsApiFactory.ts @@ -69,6 +69,7 @@ export class VisitsApiFactory implements VisitsApi { if (filter.operator === '<') return field < filter.value; if (filter.operator === '<=') return field <= filter.value; if (filter.operator === '==') return field === filter.value; + if (filter.operator === '!=') return field !== filter.value; if (filter.operator === 'contains') return `${field}`.includes(`${filter.value}`); return false; From f997f771da3a9631e54dcb321c41bcf4516d9c5d Mon Sep 17 00:00:00 2001 From: Renan Mendes Carvalho Date: Tue, 19 Sep 2023 16:40:36 +0200 Subject: [PATCH 19/28] changeset(plugins/home): Adds new Top/Recently Visited components Signed-off-by: Renan Mendes Carvalho --- .changeset/happy-books-smoke.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/happy-books-smoke.md diff --git a/.changeset/happy-books-smoke.md b/.changeset/happy-books-smoke.md new file mode 100644 index 0000000000..b035f84cfd --- /dev/null +++ b/.changeset/happy-books-smoke.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-home': minor +--- + +Adds Top/Recently Visited components to homepage From 72f3ea7042fd9deb7e72ab36fdb03a7839248bdc Mon Sep 17 00:00:00 2001 From: Renan Mendes Carvalho Date: Wed, 20 Sep 2023 10:27:41 +0200 Subject: [PATCH 20/28] refactor(plugins/home): Remove any type Signed-off-by: Renan Mendes Carvalho --- .../home/src/homePageComponents/VisitedByType/Context.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/plugins/home/src/homePageComponents/VisitedByType/Context.tsx b/plugins/home/src/homePageComponents/VisitedByType/Context.tsx index 0d1c2100f2..cd316d8b75 100644 --- a/plugins/home/src/homePageComponents/VisitedByType/Context.tsx +++ b/plugins/home/src/homePageComponents/VisitedByType/Context.tsx @@ -60,14 +60,15 @@ export const defaultContextValue: ContextValue = { export const Context = createContext(defaultContextValue); const getFilteredSet = - ( + ( setContext: Dispatch>, contextKey: keyof ContextValueOnly, ) => - (e: SetStateAction) => + (e: SetStateAction) => setContext(state => ({ ...state, - [contextKey]: typeof e === 'function' ? e(state[contextKey]) : e, + [contextKey]: + typeof e === 'function' ? (e as Function)(state[contextKey]) : e, })); export const ContextProvider = ({ children }: { children: JSX.Element }) => { From baa8af646ca83c42d87e2e5fa34821735a5b15cc Mon Sep 17 00:00:00 2001 From: Renan Mendes Carvalho Date: Wed, 20 Sep 2023 14:03:20 +0200 Subject: [PATCH 21/28] refactor(plugins/home): Implementations depend only on StorageApi This patch removes the VisitsApiFactory, promotes CoreStorageVisitsApi to the main implementation and LocalStorageVisitsApi starts to rely on WebStorage. Signed-off-by: Renan Mendes Carvalho --- plugins/home/README.md | 7 +- plugins/home/api-report.md | 36 +- plugins/home/package.json | 2 +- .../home/src/api/CoreStorageVisitsApi.test.ts | 312 ++++++++++++++-- plugins/home/src/api/CoreStorageVisitsApi.ts | 128 +++++-- .../src/api/LocalStorageVisitsApi.test.ts | 17 +- plugins/home/src/api/LocalStorageVisitsApi.ts | 47 +-- plugins/home/src/api/VisitsApiFactory.test.ts | 336 ------------------ plugins/home/src/api/VisitsApiFactory.ts | 120 ------- plugins/home/src/api/index.ts | 1 - 10 files changed, 429 insertions(+), 577 deletions(-) delete mode 100644 plugins/home/src/api/VisitsApiFactory.test.ts delete mode 100644 plugins/home/src/api/VisitsApiFactory.ts diff --git a/plugins/home/README.md b/plugins/home/README.md index f6a223701b..af3b3a12f6 100644 --- a/plugins/home/README.md +++ b/plugins/home/README.md @@ -280,7 +280,7 @@ import { } from '@backstage/plugin-home'; // ... export const apis: AnyApiFactory[] = [ - // Implementation that relies on the integration with storageApi + // Implementation that relies on a provided storageApi createApiFactory({ api: visitsApiRef, deps: { @@ -291,13 +291,14 @@ export const apis: AnyApiFactory[] = [ CoreStorageVisitsApi.create({ storageApi, identityApi }), }), - // Or a local data implementation, relies on the browser's window.localStorage + // Or a local data implementation, relies on WebStorage implementation of storageApi createApiFactory({ api: visitsApiRef, deps: { identityApi: identityApiRef, + errorApi: errorApiRef }, - factory: ({ identityApi }) => LocalStoreVisitsApi.create({ identityApi }), + factory: ({ identityApi, errorApi }) => LocalStoreVisitsApi.create({ identityApi, errorApi }), }), // ... ``` diff --git a/plugins/home/api-report.md b/plugins/home/api-report.md index f0a38506ca..5da8f912a5 100644 --- a/plugins/home/api-report.md +++ b/plugins/home/api-report.md @@ -14,6 +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 { ErrorApi } from '@backstage/core-plugin-api'; import { IdentityApi } from '@backstage/core-plugin-api'; import { JSX as JSX_2 } from 'react'; import { default as React_2 } from 'react'; @@ -77,15 +78,17 @@ export const ComponentTabs: (props: { }) => JSX_2.Element; // @public -export class CoreStorageVisitsApi extends VisitsApiFactory { +export class CoreStorageVisitsApi implements VisitsApi { // (undocumented) static create(options: CoreStorageVisitsApiOptions): CoreStorageVisitsApi; + list(queryParams?: VisitsApiQueryParams): Promise; + save(saveParams: VisitsApiSaveParams): Promise; } // @public (undocumented) export type CoreStorageVisitsApiOptions = { - storageApi: StorageApi; limit?: number; + storageApi: StorageApi; identityApi: IdentityApi; }; @@ -177,15 +180,16 @@ export type LayoutConfiguration = { }; // @public -export class LocalStorageVisitsApi extends VisitsApiFactory { +export class LocalStorageVisitsApi { // (undocumented) - static create(options: LocalStorageVisitsApiOptions): LocalStorageVisitsApi; + static create(options: LocalStorageVisitsApiOptions): CoreStorageVisitsApi; } // @public (undocumented) export type LocalStorageVisitsApiOptions = { limit?: number; identityApi: IdentityApi; + errorApi: ErrorApi; }; // @public @deprecated (undocumented) @@ -263,30 +267,6 @@ export interface VisitsApi { save(saveParams: VisitsApiSaveParams): Promise; } -// @public -export class VisitsApiFactory implements VisitsApi { - protected constructor(options: VisitsApiFactoryOptions); - // (undocumented) - protected readonly limit: number; - // (undocumented) - list(queryParams?: VisitsApiQueryParams): Promise; - // (undocumented) - protected persistAll: (visits: Array) => Promise; - // (undocumented) - protected readonly randomUUID: () => `${string}-${string}-${string}-${string}-${string}`; - // (undocumented) - protected retrieveAll: () => Promise>; - // (undocumented) - save(saveParams: VisitsApiSaveParams): Promise; -} - -// @public (undocumented) -export type VisitsApiFactoryOptions = { - limit: number; - retrieveAll?: () => Promise>; - persistAll?: (visits: Array) => Promise; -}; - // @public export type VisitsApiQueryParams = { limit?: number; diff --git a/plugins/home/package.json b/plugins/home/package.json index 6a9c5306c1..80fb8496b1 100644 --- a/plugins/home/package.json +++ b/plugins/home/package.json @@ -36,6 +36,7 @@ "dependencies": { "@backstage/catalog-model": "workspace:^", "@backstage/config": "workspace:^", + "@backstage/core-app-api": "workspace:^", "@backstage/core-components": "workspace:^", "@backstage/core-plugin-api": "workspace:^", "@backstage/plugin-catalog-react": "workspace:^", @@ -64,7 +65,6 @@ }, "devDependencies": { "@backstage/cli": "workspace:^", - "@backstage/core-app-api": "workspace:^", "@backstage/dev-utils": "workspace:^", "@backstage/test-utils": "workspace:^", "@testing-library/dom": "^8.0.0", diff --git a/plugins/home/src/api/CoreStorageVisitsApi.test.ts b/plugins/home/src/api/CoreStorageVisitsApi.test.ts index e1f39d8464..9118b2546b 100644 --- a/plugins/home/src/api/CoreStorageVisitsApi.test.ts +++ b/plugins/home/src/api/CoreStorageVisitsApi.test.ts @@ -17,8 +17,9 @@ import { BackstageUserIdentity, IdentityApi } from '@backstage/core-plugin-api'; import { CoreStorageVisitsApi } from './CoreStorageVisitsApi'; import { MockStorageApi } from '@backstage/test-utils'; +import { Visit, VisitsApi } from './VisitsApi'; -describe('CoreStorageVisitsApi.create({ storageApi: MockStorageApi.create() })', () => { +describe('CoreStorageVisitsApi.create', () => { const mockRandomUUID = () => '068f3129-7440-4e0e-8fd4-xxxxxxxxxxxx'.replace( /x/g, @@ -35,9 +36,12 @@ describe('CoreStorageVisitsApi.create({ storageApi: MockStorageApi.create() })', beforeEach(() => { window.crypto.randomUUID = mockRandomUUID; + jest.useFakeTimers(); }); afterEach(() => { + jest.resetAllMocks(); + jest.useRealTimers(); window.localStorage.clear(); }); @@ -49,37 +53,287 @@ describe('CoreStorageVisitsApi.create({ storageApi: MockStorageApi.create() })', expect(api).toBeTruthy(); }); - it('saves a visit', async () => { - const api = CoreStorageVisitsApi.create({ - storageApi: MockStorageApi.create(), - identityApi: mockIdentityApi, + describe('.save()', () => { + it('saves a visit', async () => { + const api = CoreStorageVisitsApi.create({ + storageApi: MockStorageApi.create(), + identityApi: mockIdentityApi, + }); + const visit = { + pathname: '/catalog/default/component/playback-order', + entityRef: 'component:default/playback-order', + name: 'Playback Order', + }; + const returnedVisit = await api.save({ visit }); + expect(returnedVisit).toEqual(expect.objectContaining(visit)); + expect(returnedVisit.id).toBeTruthy(); + expect(returnedVisit.timestamp).toBeTruthy(); + expect(returnedVisit.hits).toBeTruthy(); + }); + + it('can control the number of stored entities', async () => { + const api = CoreStorageVisitsApi.create({ + storageApi: MockStorageApi.create(), + identityApi: mockIdentityApi, + limit: 2, + }); + const baseDate = Date.now(); + const visit1 = { + pathname: '/catalog/default/component/playback-order-1', + entityRef: 'component:default/playback-order', + name: 'Playback Order', + }; + jest.setSystemTime(baseDate); + await api.save({ visit: visit1 }); + const visit2 = { + pathname: '/catalog/default/component/playback-order-2', + entityRef: 'component:default/playback-order', + name: 'Playback Order', + }; + jest.setSystemTime(baseDate + 360_000); + await api.save({ visit: visit2 }); + const visit3 = { + pathname: '/catalog/default/component/playback-order-3', + entityRef: 'component:default/playback-order', + name: 'Playback Order', + }; + jest.setSystemTime(baseDate + 360_000 * 2); + await api.save({ visit: visit3 }); + const visits = await api.list(); + expect(visits).toHaveLength(2); + expect(visits).toContainEqual(expect.objectContaining(visit2)); + expect(visits).toContainEqual(expect.objectContaining(visit3)); + }); + + it('correctly bumps the hits from a previous visit', async () => { + const api = CoreStorageVisitsApi.create({ + storageApi: MockStorageApi.create(), + identityApi: mockIdentityApi, + }); + const visit = { + pathname: '/catalog/default/component/playback-order', + entityRef: 'component:default/playback-order', + name: 'Playback Order', + }; + const visit1 = await api.save({ visit }); + const visit2 = await api.save({ visit }); + const visits = await api.list(); + expect(visits).toHaveLength(1); + expect(visits).toContainEqual(expect.objectContaining(visit)); + // keeps the original id created on the first visit + expect(visits).toContainEqual(expect.objectContaining({ id: visit1.id })); + // updates timestamp and hits + expect(visits).toContainEqual( + expect.objectContaining({ timestamp: visit2.timestamp, hits: 2 }), + ); }); - const visit = { - pathname: '/catalog/default/component/playback-order', - entityRef: 'component:default/playback-order', - name: 'Playback Order', - }; - const returnedVisit = await api.save({ visit }); - expect(returnedVisit).toEqual(expect.objectContaining(visit)); - expect(returnedVisit.id).toBeTruthy(); - expect(returnedVisit.timestamp).toBeTruthy(); - expect(returnedVisit.hits).toBeTruthy(); }); - it('retrieves visits', async () => { - const api = CoreStorageVisitsApi.create({ - storageApi: MockStorageApi.create(), - identityApi: mockIdentityApi, + describe('.list()', () => { + let api: VisitsApi; + let visitsToSave: Array>; + let baseDate: number; + + beforeEach(() => { + api = CoreStorageVisitsApi.create({ + storageApi: MockStorageApi.create(), + identityApi: mockIdentityApi, + }); + visitsToSave = [ + { + pathname: '/catalog/default/component/playback-order-1', + entityRef: 'component:default/playback-order-1', + name: 'Playback Order Odd', + }, + { + pathname: '/catalog/default/component/playback-order-2', + entityRef: 'component:default/playback-order-2', + name: 'Playback Order Even', + }, + { + pathname: '/catalog/default/component/playback-order-3', + entityRef: 'component:default/playback-order-3', + name: 'Playback Order Odd', + }, + ]; + baseDate = Date.now(); + // Chaining items to ensure the right setSystemTime + return visitsToSave.reduce( + (acc, visit, index) => + acc.then(() => { + jest.setSystemTime(baseDate + 360_000 * index); + return api.save({ visit }); + }), + Promise.resolve({}), + ); + }); + + it('retrieves visits', async () => { + const visits = await api.list(); + expect(visits).toHaveLength(3); + expect(visits).toEqual([ + expect.objectContaining(visitsToSave[2]), + expect.objectContaining(visitsToSave[1]), + expect.objectContaining(visitsToSave[0]), + ]); + }); + + it('orders by timestamp asc', async () => { + const visits = await api.list({ + orderBy: [{ field: 'timestamp', direction: 'asc' }], + }); + expect(visits).toEqual([ + expect.objectContaining(visitsToSave[0]), + expect.objectContaining(visitsToSave[1]), + expect.objectContaining(visitsToSave[2]), + ]); + }); + + it('orders by timestamp desc', async () => { + const visits = await api.list({ + orderBy: [{ field: 'timestamp', direction: 'desc' }], + }); + expect(visits).toEqual([ + expect.objectContaining(visitsToSave[2]), + expect.objectContaining(visitsToSave[1]), + expect.objectContaining(visitsToSave[0]), + ]); + }); + + it('orders by entityRef asc', async () => { + const visits = await api.list({ + orderBy: [{ field: 'entityRef', direction: 'asc' }], + }); + expect(visits).toEqual([ + expect.objectContaining(visitsToSave[0]), + expect.objectContaining(visitsToSave[1]), + expect.objectContaining(visitsToSave[2]), + ]); + }); + + it('orders by entityRef desc', async () => { + const visits = await api.list({ + orderBy: [{ field: 'entityRef', direction: 'desc' }], + }); + expect(visits).toEqual([ + expect.objectContaining(visitsToSave[2]), + expect.objectContaining(visitsToSave[1]), + expect.objectContaining(visitsToSave[0]), + ]); + }); + + it('orders by name asc then by entityRef asc', async () => { + const visits = await api.list({ + orderBy: [ + { field: 'name', direction: 'asc' }, + { field: 'entityRef', direction: 'asc' }, + ], + }); + expect(visits).toEqual([ + expect.objectContaining(visitsToSave[1]), // Playback Order Even, playback-order-2 + expect.objectContaining(visitsToSave[0]), // Playback Order Odd, playback-order-1 + expect.objectContaining(visitsToSave[2]), // Playback Order Odd, playback-order-3 + ]); + }); + + it('orders by name desc then by entityRef asc', async () => { + const visits = await api.list({ + orderBy: [ + { field: 'name', direction: 'desc' }, + { field: 'entityRef', direction: 'asc' }, + ], + }); + expect(visits).toEqual([ + expect.objectContaining(visitsToSave[0]), // Playback Order Odd, playback-order-1 + expect.objectContaining(visitsToSave[2]), // Playback Order Odd, playback-order-3 + expect.objectContaining(visitsToSave[1]), // Playback Order Even, playback-order-2 + ]); + }); + + it('filters by timestamp with >', async () => { + const visits = await api.list({ + filterBy: [{ field: 'timestamp', operator: '>', value: baseDate }], + }); + expect(visits).toHaveLength(2); + expect(visits).toEqual([ + expect.objectContaining(visitsToSave[2]), + expect.objectContaining(visitsToSave[1]), + ]); + }); + + it('filters by timestamp with >=', async () => { + const visits = await api.list({ + filterBy: [ + { field: 'timestamp', operator: '>=', value: baseDate + 360_000 * 2 }, + ], + }); + expect(visits).toHaveLength(1); + expect(visits).toEqual([expect.objectContaining(visitsToSave[2])]); + }); + + it('filters by timestamp with <', async () => { + const visits = await api.list({ + filterBy: [{ field: 'timestamp', operator: '<', value: baseDate + 1 }], + }); + expect(visits).toHaveLength(1); + expect(visits).toEqual([expect.objectContaining(visitsToSave[0])]); + }); + + it('filters by timestamp with <=', async () => { + const visits = await api.list({ + filterBy: [ + { field: 'timestamp', operator: '<=', value: baseDate + 360_000 }, + ], + }); + expect(visits).toHaveLength(2); + expect(visits).toEqual([ + expect.objectContaining(visitsToSave[1]), + expect.objectContaining(visitsToSave[0]), + ]); + }); + + it('filters by timestamp with ==', async () => { + const visits = await api.list({ + filterBy: [ + { field: 'timestamp', operator: '==', value: baseDate + 360_000 }, + ], + }); + expect(visits).toHaveLength(1); + expect(visits).toEqual([expect.objectContaining(visitsToSave[1])]); + }); + + it('filters by timestamp with !=', async () => { + const visits = await api.list({ + filterBy: [ + { field: 'timestamp', operator: '!=', value: baseDate + 360_000 }, + ], + }); + expect(visits).toHaveLength(2); + expect(visits).toEqual([ + expect.objectContaining(visitsToSave[2]), + expect.objectContaining(visitsToSave[0]), + ]); + }); + + it('filters by entityRef with contains', async () => { + const visits = await api.list({ + filterBy: [ + { field: 'entityRef', operator: 'contains', value: 'order-2' }, + ], + }); + expect(visits).toHaveLength(1); + expect(visits).toEqual([expect.objectContaining(visitsToSave[1])]); + }); + + it('filters by timestamp with <= then by name with contains', async () => { + const visits = await api.list({ + filterBy: [ + { field: 'timestamp', operator: '<=', value: baseDate + 360_000 }, + { field: 'name', operator: 'contains', value: 'Odd' }, + ], + }); + expect(visits).toHaveLength(1); + expect(visits).toEqual([expect.objectContaining(visitsToSave[0])]); }); - const visit = { - pathname: '/catalog/default/component/playback-order', - entityRef: 'component:default/playback-order', - name: 'Playback Order', - }; - const returnedVisit = await api.save({ visit }); - const visits = await api.list(); - expect(visits).toHaveLength(1); - expect(visits).toEqual([expect.objectContaining(visit)]); - expect(visits).toEqual([returnedVisit]); }); }); diff --git a/plugins/home/src/api/CoreStorageVisitsApi.ts b/plugins/home/src/api/CoreStorageVisitsApi.ts index 18cdaca90f..2707a50b87 100644 --- a/plugins/home/src/api/CoreStorageVisitsApi.ts +++ b/plugins/home/src/api/CoreStorageVisitsApi.ts @@ -14,21 +14,31 @@ * limitations under the License. */ import { IdentityApi, StorageApi } from '@backstage/core-plugin-api'; -import { Visit } from './VisitsApi'; -import { VisitsApiFactory } from './VisitsApiFactory'; +import { + Visit, + VisitsApi, + VisitsApiQueryParams, + VisitsApiSaveParams, +} from './VisitsApi'; /** @public */ export type CoreStorageVisitsApiOptions = { - storageApi: StorageApi; limit?: number; + storageApi: StorageApi; identityApi: IdentityApi; }; +type ArrayElement = A extends readonly (infer T)[] ? T : never; + /** * @public - * This is an implementation of VisitsApi that relies on a StorageApi + * This is an implementation of VisitsApi that relies on a StorageApi. + * Beware that filtering and ordering are done in memory therefore it is + * prudent to keep limit to a reasonable size. */ -export class CoreStorageVisitsApi extends VisitsApiFactory { +export class CoreStorageVisitsApi implements VisitsApi { + private readonly randomUUID = window.crypto.randomUUID; + private readonly limit: number; private readonly storageApi: StorageApi; private readonly storageKeyPrefix = '@backstage/plugin-home:visits'; private readonly identityApi: IdentityApi; @@ -38,26 +48,104 @@ export class CoreStorageVisitsApi extends VisitsApiFactory { } private constructor(options: CoreStorageVisitsApiOptions) { - super({ limit: options.limit ?? 100 }); + this.limit = Math.abs(options.limit ?? 100); this.storageApi = options.storageApi; this.identityApi = options.identityApi; - this.retrieveAll = async (): Promise> => { - let visits: Array; - const { userEntityRef } = await this.identityApi.getBackstageIdentity(); - const storageKey = `${this.storageKeyPrefix}:${userEntityRef}`; + } - try { - visits = this.storageApi.snapshot>(storageKey).value ?? []; - } catch { - visits = []; + /** + * Returns a list of visits through the visitsApi + */ + async list(queryParams?: VisitsApiQueryParams): Promise { + let visits = [...(await this.retrieveAll())]; + + // reversing order to guarantee orderBy priority + (queryParams?.orderBy ?? []).reverse().forEach(order => { + if (order.direction === 'asc') { + visits.sort((a, b) => this.compare(order, a, b)); + } else { + visits.sort((a, b) => this.compare(order, b, a)); } - return visits; - }; - this.persistAll = async (visits: Array) => { - const { userEntityRef } = await this.identityApi.getBackstageIdentity(); - const storageKey = `${this.storageKeyPrefix}:${userEntityRef}`; + }); - return this.storageApi.set>(storageKey, visits); + // reversing order to guarantee filterBy priority + (queryParams?.filterBy ?? []).reverse().forEach(filter => { + visits = visits.filter(visit => { + const field = visit[filter.field] as number | string; + if (filter.operator === '>') return field > filter.value; + if (filter.operator === '>=') return field >= filter.value; + if (filter.operator === '<') return field < filter.value; + if (filter.operator === '<=') return field <= filter.value; + if (filter.operator === '==') return field === filter.value; + if (filter.operator === '!=') return field !== filter.value; + if (filter.operator === 'contains') + return `${field}`.includes(`${filter.value}`); + return false; + }); + }); + + return visits; + } + + /** + * Saves a visit through the visitsApi + */ + async save(saveParams: VisitsApiSaveParams): Promise { + const visits: Visit[] = [...(await this.retrieveAll())]; + + const visit: Visit = { + ...saveParams.visit, + id: this.randomUUID(), + hits: 1, + timestamp: Date.now(), }; + + // Updates entry if pathname is already registered + const visitIndex = visits.findIndex(e => e.pathname === visit.pathname); + if (visitIndex >= 0) { + visit.id = visits[visitIndex].id; + visit.hits = visits[visitIndex].hits + 1; + visits[visitIndex] = visit; + } else { + visits.push(visit); + } + + // Sort by time, most recent first + visits.sort((a, b) => b.timestamp - a.timestamp); + // Keep the most recent items up to limit + await this.persistAll(visits.splice(0, this.limit)); + return visit; + } + + private async persistAll(visits: Array) { + const { userEntityRef } = await this.identityApi.getBackstageIdentity(); + const storageKey = `${this.storageKeyPrefix}:${userEntityRef}`; + + return this.storageApi.set>(storageKey, visits); + } + + private async retrieveAll(): Promise> { + const { userEntityRef } = await this.identityApi.getBackstageIdentity(); + const storageKey = `${this.storageKeyPrefix}:${userEntityRef}`; + let visits: Array; + + try { + visits = this.storageApi.snapshot>(storageKey).value ?? []; + } catch { + visits = []; + } + return visits; + } + + // This assumes Visit fields are either numbers or strings + private compare( + order: ArrayElement, + a: Visit, + b: Visit, + ): number { + const isNumber = typeof a[order.field] === 'number'; + return isNumber + ? (a[order.field] as number) - (b[order.field] as number) + : `${a[order.field]}`.localeCompare(`${b[order.field]}`); } } diff --git a/plugins/home/src/api/LocalStorageVisitsApi.test.ts b/plugins/home/src/api/LocalStorageVisitsApi.test.ts index eb19859055..b646af209e 100644 --- a/plugins/home/src/api/LocalStorageVisitsApi.test.ts +++ b/plugins/home/src/api/LocalStorageVisitsApi.test.ts @@ -32,6 +32,8 @@ describe('LocalStorageVisitsApi.create()', () => { getCredentials: jest.fn(), }; + const mockErrorApi = { post: jest.fn(), error$: jest.fn() }; + beforeEach(() => { window.crypto.randomUUID = mockRandomUUID; }); @@ -42,12 +44,18 @@ describe('LocalStorageVisitsApi.create()', () => { }); it('instantiates with only identitiyApi', () => { - const api = LocalStorageVisitsApi.create({ identityApi: mockIdentityApi }); + const api = LocalStorageVisitsApi.create({ + identityApi: mockIdentityApi, + errorApi: mockErrorApi, + }); expect(api).toBeTruthy(); }); it('saves a visit', async () => { - const api = LocalStorageVisitsApi.create({ identityApi: mockIdentityApi }); + const api = LocalStorageVisitsApi.create({ + identityApi: mockIdentityApi, + errorApi: mockErrorApi, + }); const visit = { pathname: '/catalog/default/component/playback-order', entityRef: 'component:default/playback-order', @@ -61,7 +69,10 @@ describe('LocalStorageVisitsApi.create()', () => { }); it('retrieves visits', async () => { - const api = LocalStorageVisitsApi.create({ identityApi: mockIdentityApi }); + const api = LocalStorageVisitsApi.create({ + identityApi: mockIdentityApi, + errorApi: mockErrorApi, + }); const visit = { pathname: '/catalog/default/component/playback-order', entityRef: 'component:default/playback-order', diff --git a/plugins/home/src/api/LocalStorageVisitsApi.ts b/plugins/home/src/api/LocalStorageVisitsApi.ts index 44932b93f5..01d57b93d9 100644 --- a/plugins/home/src/api/LocalStorageVisitsApi.ts +++ b/plugins/home/src/api/LocalStorageVisitsApi.ts @@ -13,52 +13,27 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { IdentityApi } from '@backstage/core-plugin-api'; -import { Visit } from './VisitsApi'; -import { VisitsApiFactory } from './VisitsApiFactory'; +import { ErrorApi, IdentityApi } from '@backstage/core-plugin-api'; +import { CoreStorageVisitsApi } from './CoreStorageVisitsApi'; +import { WebStorage } from '@backstage/core-app-api'; /** @public */ export type LocalStorageVisitsApiOptions = { limit?: number; identityApi: IdentityApi; + errorApi: ErrorApi; }; /** * @public - * This is a reference implementation of VisitsApi using window.localStorage. + * This is a reference implementation of VisitsApi using WebStorage. */ -export class LocalStorageVisitsApi extends VisitsApiFactory { - private readonly localStorage = window.localStorage; - private readonly storageKeyPrefix = '@backstage/plugin-home:visits'; - private readonly identityApi: IdentityApi; - +export class LocalStorageVisitsApi { static create(options: LocalStorageVisitsApiOptions) { - return new LocalStorageVisitsApi(options); - } - - private constructor(options: LocalStorageVisitsApiOptions) { - super({ limit: options.limit ?? 100 }); - this.identityApi = options.identityApi; - this.retrieveAll = async (): Promise> => { - let visits: Array; - const { userEntityRef } = await this.identityApi.getBackstageIdentity(); - const storageKey = `${this.storageKeyPrefix}:${userEntityRef}`; - - try { - visits = JSON.parse(this.localStorage.getItem(storageKey) ?? '[]'); - } catch { - visits = []; - } - return visits; - }; - this.persistAll = async (visits: Array) => { - const { userEntityRef } = await this.identityApi.getBackstageIdentity(); - const storageKey = `${this.storageKeyPrefix}:${userEntityRef}`; - - return this.localStorage.setItem( - storageKey, - JSON.stringify(visits.splice(0, this.limit)), - ); - }; + return CoreStorageVisitsApi.create({ + limit: options.limit, + identityApi: options.identityApi, + storageApi: WebStorage.create({ errorApi: options.errorApi }), + }); } } diff --git a/plugins/home/src/api/VisitsApiFactory.test.ts b/plugins/home/src/api/VisitsApiFactory.test.ts deleted file mode 100644 index 416481be53..0000000000 --- a/plugins/home/src/api/VisitsApiFactory.test.ts +++ /dev/null @@ -1,336 +0,0 @@ -/* - * 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 { Visit } from './VisitsApi'; -import { VisitsApiFactory } from './VisitsApiFactory'; - -class MemoryVisitsApi extends VisitsApiFactory { - private visits: Array = []; - - constructor({ - limit = 100, - }: { - limit?: number; - } = {}) { - super({ limit }); - this.retrieveAll = async (): Promise> => { - let visits: Array; - try { - visits = this.visits; - } catch { - visits = []; - } - return visits; - }; - this.persistAll = async (visits: Array) => { - this.visits = visits; - }; - } -} - -describe('new MemoryVisitsApi()', () => { - const mockRandomUUID = () => - '068f3129-7440-4e0e-8fd4-xxxxxxxxxxxx'.replace( - /x/g, - () => Math.floor(Math.random() * 16).toString(16), // 0x0 to 0xf - ) as `${string}-${string}-${string}-${string}-${string}`; - - beforeEach(() => { - jest.useFakeTimers(); - window.crypto.randomUUID = mockRandomUUID; - }); - - afterEach(() => { - jest.resetAllMocks(); - jest.useRealTimers(); - window.localStorage.clear(); - }); - - it('instantiates with no configuration', () => { - const api = new MemoryVisitsApi(); - expect(api).toBeTruthy(); - }); - - describe('.save()', () => { - it('saves a visit', async () => { - const api = new MemoryVisitsApi(); - const visit = { - pathname: '/catalog/default/component/playback-order', - entityRef: 'component:default/playback-order', - name: 'Playback Order', - }; - const returnedVisit = await api.save({ visit }); - expect(returnedVisit).toEqual(expect.objectContaining(visit)); - expect(returnedVisit.id).toBeTruthy(); - expect(returnedVisit.timestamp).toBeTruthy(); - expect(returnedVisit.hits).toBeTruthy(); - }); - - it('can control the number of stored entities', async () => { - const api = new MemoryVisitsApi({ limit: 2 }); - const baseDate = Date.now(); - const visit1 = { - pathname: '/catalog/default/component/playback-order-1', - entityRef: 'component:default/playback-order', - name: 'Playback Order', - }; - jest.setSystemTime(baseDate); - await api.save({ visit: visit1 }); - const visit2 = { - pathname: '/catalog/default/component/playback-order-2', - entityRef: 'component:default/playback-order', - name: 'Playback Order', - }; - jest.setSystemTime(baseDate + 360_000); - await api.save({ visit: visit2 }); - const visit3 = { - pathname: '/catalog/default/component/playback-order-3', - entityRef: 'component:default/playback-order', - name: 'Playback Order', - }; - jest.setSystemTime(baseDate + 360_000 * 2); - await api.save({ visit: visit3 }); - const visits = await api.list(); - expect(visits).toHaveLength(2); - expect(visits).toContainEqual(expect.objectContaining(visit2)); - expect(visits).toContainEqual(expect.objectContaining(visit3)); - }); - - it('correctly bumps the hits from a previous visit', async () => { - const api = new MemoryVisitsApi(); - const visit = { - pathname: '/catalog/default/component/playback-order', - entityRef: 'component:default/playback-order', - name: 'Playback Order', - }; - const visit1 = await api.save({ visit }); - const visit2 = await api.save({ visit }); - const visits = await api.list(); - expect(visits).toHaveLength(1); - expect(visits).toContainEqual(expect.objectContaining(visit)); - // keeps the original id created on the first visit - expect(visits).toContainEqual(expect.objectContaining({ id: visit1.id })); - // updates timestamp and hits - expect(visits).toContainEqual( - expect.objectContaining({ timestamp: visit2.timestamp, hits: 2 }), - ); - }); - }); - - describe('.list()', () => { - let api: MemoryVisitsApi; - let visitsToSave: Array>; - let baseDate: number; - beforeEach(() => { - api = new MemoryVisitsApi(); - visitsToSave = [ - { - pathname: '/catalog/default/component/playback-order-1', - entityRef: 'component:default/playback-order-1', - name: 'Playback Order Odd', - }, - { - pathname: '/catalog/default/component/playback-order-2', - entityRef: 'component:default/playback-order-2', - name: 'Playback Order Even', - }, - { - pathname: '/catalog/default/component/playback-order-3', - entityRef: 'component:default/playback-order-3', - name: 'Playback Order Odd', - }, - ]; - baseDate = Date.now(); - // Chaining items to ensure the right setSystemTime - return visitsToSave.reduce( - (acc, visit, index) => - acc.then(() => { - jest.setSystemTime(baseDate + 360_000 * index); - return api.save({ visit }); - }), - Promise.resolve({}), - ); - }); - - it('retrieves visits', async () => { - const visits = await api.list(); - expect(visits).toHaveLength(3); - expect(visits).toEqual([ - expect.objectContaining(visitsToSave[2]), - expect.objectContaining(visitsToSave[1]), - expect.objectContaining(visitsToSave[0]), - ]); - }); - - it('orders by timestamp asc', async () => { - const visits = await api.list({ - orderBy: [{ field: 'timestamp', direction: 'asc' }], - }); - expect(visits).toEqual([ - expect.objectContaining(visitsToSave[0]), - expect.objectContaining(visitsToSave[1]), - expect.objectContaining(visitsToSave[2]), - ]); - }); - - it('orders by timestamp desc', async () => { - const visits = await api.list({ - orderBy: [{ field: 'timestamp', direction: 'desc' }], - }); - expect(visits).toEqual([ - expect.objectContaining(visitsToSave[2]), - expect.objectContaining(visitsToSave[1]), - expect.objectContaining(visitsToSave[0]), - ]); - }); - - it('orders by entityRef asc', async () => { - const visits = await api.list({ - orderBy: [{ field: 'entityRef', direction: 'asc' }], - }); - expect(visits).toEqual([ - expect.objectContaining(visitsToSave[0]), - expect.objectContaining(visitsToSave[1]), - expect.objectContaining(visitsToSave[2]), - ]); - }); - - it('orders by entityRef desc', async () => { - const visits = await api.list({ - orderBy: [{ field: 'entityRef', direction: 'desc' }], - }); - expect(visits).toEqual([ - expect.objectContaining(visitsToSave[2]), - expect.objectContaining(visitsToSave[1]), - expect.objectContaining(visitsToSave[0]), - ]); - }); - - it('orders by name asc then by entityRef asc', async () => { - const visits = await api.list({ - orderBy: [ - { field: 'name', direction: 'asc' }, - { field: 'entityRef', direction: 'asc' }, - ], - }); - expect(visits).toEqual([ - expect.objectContaining(visitsToSave[1]), // Playback Order Even, playback-order-2 - expect.objectContaining(visitsToSave[0]), // Playback Order Odd, playback-order-1 - expect.objectContaining(visitsToSave[2]), // Playback Order Odd, playback-order-3 - ]); - }); - - it('orders by name desc then by entityRef asc', async () => { - const visits = await api.list({ - orderBy: [ - { field: 'name', direction: 'desc' }, - { field: 'entityRef', direction: 'asc' }, - ], - }); - expect(visits).toEqual([ - expect.objectContaining(visitsToSave[0]), // Playback Order Odd, playback-order-1 - expect.objectContaining(visitsToSave[2]), // Playback Order Odd, playback-order-3 - expect.objectContaining(visitsToSave[1]), // Playback Order Even, playback-order-2 - ]); - }); - - it('filters by timestamp with >', async () => { - const visits = await api.list({ - filterBy: [{ field: 'timestamp', operator: '>', value: baseDate }], - }); - expect(visits).toHaveLength(2); - expect(visits).toEqual([ - expect.objectContaining(visitsToSave[2]), - expect.objectContaining(visitsToSave[1]), - ]); - }); - - it('filters by timestamp with >=', async () => { - const visits = await api.list({ - filterBy: [ - { field: 'timestamp', operator: '>=', value: baseDate + 360_000 * 2 }, - ], - }); - expect(visits).toHaveLength(1); - expect(visits).toEqual([expect.objectContaining(visitsToSave[2])]); - }); - - it('filters by timestamp with <', async () => { - const visits = await api.list({ - filterBy: [{ field: 'timestamp', operator: '<', value: baseDate + 1 }], - }); - expect(visits).toHaveLength(1); - expect(visits).toEqual([expect.objectContaining(visitsToSave[0])]); - }); - - it('filters by timestamp with <=', async () => { - const visits = await api.list({ - filterBy: [ - { field: 'timestamp', operator: '<=', value: baseDate + 360_000 }, - ], - }); - expect(visits).toHaveLength(2); - expect(visits).toEqual([ - expect.objectContaining(visitsToSave[1]), - expect.objectContaining(visitsToSave[0]), - ]); - }); - - it('filters by timestamp with ==', async () => { - const visits = await api.list({ - filterBy: [ - { field: 'timestamp', operator: '==', value: baseDate + 360_000 }, - ], - }); - expect(visits).toHaveLength(1); - expect(visits).toEqual([expect.objectContaining(visitsToSave[1])]); - }); - - it('filters by timestamp with !=', async () => { - const visits = await api.list({ - filterBy: [ - { field: 'timestamp', operator: '!=', value: baseDate + 360_000 }, - ], - }); - expect(visits).toHaveLength(2); - expect(visits).toEqual([ - expect.objectContaining(visitsToSave[2]), - expect.objectContaining(visitsToSave[0]), - ]); - }); - - it('filters by entityRef with contains', async () => { - const visits = await api.list({ - filterBy: [ - { field: 'entityRef', operator: 'contains', value: 'order-2' }, - ], - }); - expect(visits).toHaveLength(1); - expect(visits).toEqual([expect.objectContaining(visitsToSave[1])]); - }); - - it('filters by timestamp with <= then by name with contains', async () => { - const visits = await api.list({ - filterBy: [ - { field: 'timestamp', operator: '<=', value: baseDate + 360_000 }, - { field: 'name', operator: 'contains', value: 'Odd' }, - ], - }); - expect(visits).toHaveLength(1); - expect(visits).toEqual([expect.objectContaining(visitsToSave[0])]); - }); - }); -}); diff --git a/plugins/home/src/api/VisitsApiFactory.ts b/plugins/home/src/api/VisitsApiFactory.ts deleted file mode 100644 index 9ba6a2fe79..0000000000 --- a/plugins/home/src/api/VisitsApiFactory.ts +++ /dev/null @@ -1,120 +0,0 @@ -/* - * 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 { - Visit, - VisitsApi, - VisitsApiQueryParams, - VisitsApiSaveParams, -} from './VisitsApi'; - -type ArrayElement = A extends readonly (infer T)[] ? T : never; - -/** @public */ -export type VisitsApiFactoryOptions = { - limit: number; - retrieveAll?: () => Promise>; - persistAll?: (visits: Array) => Promise; -}; - -/** - * @public - * This helps the creation of VisitApi implementations. Important to note - * that it implements features like orderBy and filterBy on memory, therefore - * is intended to handle few visits. The default is 100. - * See LocalStorageVisitsApi for an usage example. - */ -export class VisitsApiFactory implements VisitsApi { - protected readonly randomUUID = window.crypto.randomUUID; - protected readonly limit: number; - protected retrieveAll: () => Promise>; - protected persistAll: (visits: Array) => Promise; - - protected constructor(options: VisitsApiFactoryOptions) { - this.limit = Math.abs(options.limit ?? 100); - this.retrieveAll = options.retrieveAll ?? (async () => []); - this.persistAll = options.persistAll ?? (async () => {}); - } - - async list(queryParams?: VisitsApiQueryParams): Promise { - let visits = await this.retrieveAll(); - - // reversing order to guarantee orderBy priority - (queryParams?.orderBy ?? []).reverse().forEach(order => { - if (order.direction === 'asc') { - visits.sort((a, b) => this.compare(order, a, b)); - } else { - visits.sort((a, b) => this.compare(order, b, a)); - } - }); - - // reversing order to guarantee filterBy priority - (queryParams?.filterBy ?? []).reverse().forEach(filter => { - visits = visits.filter(visit => { - const field = visit[filter.field] as number | string; - if (filter.operator === '>') return field > filter.value; - if (filter.operator === '>=') return field >= filter.value; - if (filter.operator === '<') return field < filter.value; - if (filter.operator === '<=') return field <= filter.value; - if (filter.operator === '==') return field === filter.value; - if (filter.operator === '!=') return field !== filter.value; - if (filter.operator === 'contains') - return `${field}`.includes(`${filter.value}`); - return false; - }); - }); - - return visits; - } - - async save(saveParams: VisitsApiSaveParams): Promise { - const visits = await this.retrieveAll(); - - const visit: Visit = { - ...saveParams.visit, - id: window.crypto.randomUUID(), - hits: 1, - timestamp: Date.now(), - }; - - // Updates entry if pathname is already registered - const visitIndex = visits.findIndex(e => e.pathname === visit.pathname); - if (visitIndex >= 0) { - visit.id = visits[visitIndex].id; - visit.hits = visits[visitIndex].hits + 1; - visits[visitIndex] = visit; - } else { - visits.push(visit); - } - - // Sort by time, most recent first - visits.sort((a, b) => b.timestamp - a.timestamp); - // Keep the most recent items up to limit - await this.persistAll(visits.splice(0, this.limit)); - return visit; - } - - // This assumes Visit fields are either numbers or strings - private compare( - order: ArrayElement, - a: Visit, - b: Visit, - ): number { - const isNumber = typeof a[order.field] === 'number'; - return isNumber - ? (a[order.field] as number) - (b[order.field] as number) - : `${a[order.field]}`.localeCompare(`${b[order.field]}`); - } -} diff --git a/plugins/home/src/api/index.ts b/plugins/home/src/api/index.ts index cb283aa2cf..50b48e3204 100644 --- a/plugins/home/src/api/index.ts +++ b/plugins/home/src/api/index.ts @@ -17,4 +17,3 @@ export * from './CoreStorageVisitsApi'; export * from './LocalStorageVisitsApi'; export * from './VisitsApi'; -export * from './VisitsApiFactory'; From 5514040b44b1a2c623668e5b42e87e2739169475 Mon Sep 17 00:00:00 2001 From: Renan Mendes Carvalho Date: Wed, 20 Sep 2023 14:16:48 +0200 Subject: [PATCH 22/28] rename(plugins/home): VisitsStorageApi and VisitsWebStorageApi Signed-off-by: Renan Mendes Carvalho Co-authored-by: Avantika Iyer --- plugins/home/README.md | 14 ++--- plugins/home/api-report.md | 56 +++++++++---------- ...tsApi.test.ts => VisitsStorageApi.test.ts} | 14 ++--- ...torageVisitsApi.ts => VisitsStorageApi.ts} | 13 ++--- ...pi.test.ts => VisitsWebStorageApi.test.ts} | 10 ++-- ...ageVisitsApi.ts => VisitsWebStorageApi.ts} | 10 ++-- plugins/home/src/api/index.ts | 4 +- plugins/home/src/plugin.ts | 4 +- 8 files changed, 62 insertions(+), 63 deletions(-) rename plugins/home/src/api/{CoreStorageVisitsApi.test.ts => VisitsStorageApi.test.ts} (97%) rename plugins/home/src/api/{CoreStorageVisitsApi.ts => VisitsStorageApi.ts} (92%) rename plugins/home/src/api/{LocalStorageVisitsApi.test.ts => VisitsWebStorageApi.test.ts} (91%) rename plugins/home/src/api/{LocalStorageVisitsApi.ts => VisitsWebStorageApi.ts} (81%) diff --git a/plugins/home/README.md b/plugins/home/README.md index af3b3a12f6..d70667e886 100644 --- a/plugins/home/README.md +++ b/plugins/home/README.md @@ -274,8 +274,8 @@ documentation for more information. Bellow you can see an example for two option // packages/app/src/apis.ts // ... import { - CoreStorageVisitsApi, - LocalStoreVisitsApi, + VisitsStorageApi, + VisitsWebStorageApi, visitsApiRef, } from '@backstage/plugin-home'; // ... @@ -288,17 +288,17 @@ export const apis: AnyApiFactory[] = [ identityApi: identityApiRef, }, factory: ({ storageApi, identityApi }) => - CoreStorageVisitsApi.create({ storageApi, identityApi }), + VisitsStorageApi.create({ storageApi, identityApi }), }), - // Or a local data implementation, relies on WebStorage implementation of storageApi + // Or a localStorage data implementation, relies on WebStorage implementation of storageApi createApiFactory({ api: visitsApiRef, deps: { identityApi: identityApiRef, errorApi: errorApiRef }, - factory: ({ identityApi, errorApi }) => LocalStoreVisitsApi.create({ identityApi, errorApi }), + factory: ({ identityApi, errorApi }) => VisitsWebStorageApi.create({ identityApi, errorApi }), }), // ... ``` @@ -308,14 +308,14 @@ See the example usage: ```ts // packages/app/src/App.tsx -import { VisitsListener } from '@backstage/plugin-home'; +import { VisitListener } from '@backstage/plugin-home'; // ... export default app.createRoot( <> - + {routes} , diff --git a/plugins/home/api-report.md b/plugins/home/api-report.md index 5da8f912a5..e3f789c2af 100644 --- a/plugins/home/api-report.md +++ b/plugins/home/api-report.md @@ -77,21 +77,6 @@ export const ComponentTabs: (props: { }[]; }) => JSX_2.Element; -// @public -export class CoreStorageVisitsApi implements VisitsApi { - // (undocumented) - static create(options: CoreStorageVisitsApiOptions): CoreStorageVisitsApi; - list(queryParams?: VisitsApiQueryParams): Promise; - save(saveParams: VisitsApiSaveParams): Promise; -} - -// @public (undocumented) -export type CoreStorageVisitsApiOptions = { - limit?: number; - storageApi: StorageApi; - identityApi: IdentityApi; -}; - // @public @deprecated (undocumented) export const createCardExtension: typeof createCardExtension_2; @@ -179,19 +164,6 @@ export type LayoutConfiguration = { resizable?: boolean; }; -// @public -export class LocalStorageVisitsApi { - // (undocumented) - static create(options: LocalStorageVisitsApiOptions): CoreStorageVisitsApi; -} - -// @public (undocumented) -export type LocalStorageVisitsApiOptions = { - limit?: number; - identityApi: IdentityApi; - errorApi: ErrorApi; -}; - // @public @deprecated (undocumented) export type RendererProps = RendererProps_2; @@ -289,6 +261,34 @@ export type VisitsApiSaveParams = { visit: Omit; }; +// @public +export class VisitsStorageApi implements VisitsApi { + // (undocumented) + static create(options: VisitsStorageApiOptions): VisitsStorageApi; + list(queryParams?: VisitsApiQueryParams): Promise; + save(saveParams: VisitsApiSaveParams): Promise; +} + +// @public (undocumented) +export type VisitsStorageApiOptions = { + limit?: number; + storageApi: StorageApi; + identityApi: IdentityApi; +}; + +// @public +export class VisitsWebStorageApi { + // (undocumented) + static create(options: VisitsWebStorageApiOptions): VisitsStorageApi; +} + +// @public (undocumented) +export type VisitsWebStorageApiOptions = { + limit?: number; + identityApi: IdentityApi; + errorApi: ErrorApi; +}; + // @public export const WelcomeTitle: ({ language, diff --git a/plugins/home/src/api/CoreStorageVisitsApi.test.ts b/plugins/home/src/api/VisitsStorageApi.test.ts similarity index 97% rename from plugins/home/src/api/CoreStorageVisitsApi.test.ts rename to plugins/home/src/api/VisitsStorageApi.test.ts index 9118b2546b..6faeb62350 100644 --- a/plugins/home/src/api/CoreStorageVisitsApi.test.ts +++ b/plugins/home/src/api/VisitsStorageApi.test.ts @@ -15,11 +15,11 @@ */ import { BackstageUserIdentity, IdentityApi } from '@backstage/core-plugin-api'; -import { CoreStorageVisitsApi } from './CoreStorageVisitsApi'; +import { VisitsStorageApi } from './VisitsStorageApi'; import { MockStorageApi } from '@backstage/test-utils'; import { Visit, VisitsApi } from './VisitsApi'; -describe('CoreStorageVisitsApi.create', () => { +describe('VisitsStorageApi.create', () => { const mockRandomUUID = () => '068f3129-7440-4e0e-8fd4-xxxxxxxxxxxx'.replace( /x/g, @@ -46,7 +46,7 @@ describe('CoreStorageVisitsApi.create', () => { }); it('instantiates', () => { - const api = CoreStorageVisitsApi.create({ + const api = VisitsStorageApi.create({ storageApi: MockStorageApi.create(), identityApi: mockIdentityApi, }); @@ -55,7 +55,7 @@ describe('CoreStorageVisitsApi.create', () => { describe('.save()', () => { it('saves a visit', async () => { - const api = CoreStorageVisitsApi.create({ + const api = VisitsStorageApi.create({ storageApi: MockStorageApi.create(), identityApi: mockIdentityApi, }); @@ -72,7 +72,7 @@ describe('CoreStorageVisitsApi.create', () => { }); it('can control the number of stored entities', async () => { - const api = CoreStorageVisitsApi.create({ + const api = VisitsStorageApi.create({ storageApi: MockStorageApi.create(), identityApi: mockIdentityApi, limit: 2, @@ -106,7 +106,7 @@ describe('CoreStorageVisitsApi.create', () => { }); it('correctly bumps the hits from a previous visit', async () => { - const api = CoreStorageVisitsApi.create({ + const api = VisitsStorageApi.create({ storageApi: MockStorageApi.create(), identityApi: mockIdentityApi, }); @@ -135,7 +135,7 @@ describe('CoreStorageVisitsApi.create', () => { let baseDate: number; beforeEach(() => { - api = CoreStorageVisitsApi.create({ + api = VisitsStorageApi.create({ storageApi: MockStorageApi.create(), identityApi: mockIdentityApi, }); diff --git a/plugins/home/src/api/CoreStorageVisitsApi.ts b/plugins/home/src/api/VisitsStorageApi.ts similarity index 92% rename from plugins/home/src/api/CoreStorageVisitsApi.ts rename to plugins/home/src/api/VisitsStorageApi.ts index 2707a50b87..daf24d716b 100644 --- a/plugins/home/src/api/CoreStorageVisitsApi.ts +++ b/plugins/home/src/api/VisitsStorageApi.ts @@ -22,7 +22,7 @@ import { } from './VisitsApi'; /** @public */ -export type CoreStorageVisitsApiOptions = { +export type VisitsStorageApiOptions = { limit?: number; storageApi: StorageApi; identityApi: IdentityApi; @@ -36,18 +36,17 @@ type ArrayElement = A extends readonly (infer T)[] ? T : never; * Beware that filtering and ordering are done in memory therefore it is * prudent to keep limit to a reasonable size. */ -export class CoreStorageVisitsApi implements VisitsApi { - private readonly randomUUID = window.crypto.randomUUID; +export class VisitsStorageApi implements VisitsApi { private readonly limit: number; private readonly storageApi: StorageApi; private readonly storageKeyPrefix = '@backstage/plugin-home:visits'; private readonly identityApi: IdentityApi; - static create(options: CoreStorageVisitsApiOptions) { - return new CoreStorageVisitsApi(options); + static create(options: VisitsStorageApiOptions) { + return new VisitsStorageApi(options); } - private constructor(options: CoreStorageVisitsApiOptions) { + private constructor(options: VisitsStorageApiOptions) { this.limit = Math.abs(options.limit ?? 100); this.storageApi = options.storageApi; this.identityApi = options.identityApi; @@ -95,7 +94,7 @@ export class CoreStorageVisitsApi implements VisitsApi { const visit: Visit = { ...saveParams.visit, - id: this.randomUUID(), + id: window.crypto.randomUUID(), hits: 1, timestamp: Date.now(), }; diff --git a/plugins/home/src/api/LocalStorageVisitsApi.test.ts b/plugins/home/src/api/VisitsWebStorageApi.test.ts similarity index 91% rename from plugins/home/src/api/LocalStorageVisitsApi.test.ts rename to plugins/home/src/api/VisitsWebStorageApi.test.ts index b646af209e..1cf1fa3129 100644 --- a/plugins/home/src/api/LocalStorageVisitsApi.test.ts +++ b/plugins/home/src/api/VisitsWebStorageApi.test.ts @@ -15,9 +15,9 @@ */ import { BackstageUserIdentity, IdentityApi } from '@backstage/core-plugin-api'; -import { LocalStorageVisitsApi } from './LocalStorageVisitsApi'; +import { VisitsWebStorageApi } from './VisitsWebStorageApi'; -describe('LocalStorageVisitsApi.create()', () => { +describe('VisitsWebStorageApi.create()', () => { const mockRandomUUID = () => '068f3129-7440-4e0e-8fd4-xxxxxxxxxxxx'.replace( /x/g, @@ -44,7 +44,7 @@ describe('LocalStorageVisitsApi.create()', () => { }); it('instantiates with only identitiyApi', () => { - const api = LocalStorageVisitsApi.create({ + const api = VisitsWebStorageApi.create({ identityApi: mockIdentityApi, errorApi: mockErrorApi, }); @@ -52,7 +52,7 @@ describe('LocalStorageVisitsApi.create()', () => { }); it('saves a visit', async () => { - const api = LocalStorageVisitsApi.create({ + const api = VisitsWebStorageApi.create({ identityApi: mockIdentityApi, errorApi: mockErrorApi, }); @@ -69,7 +69,7 @@ describe('LocalStorageVisitsApi.create()', () => { }); it('retrieves visits', async () => { - const api = LocalStorageVisitsApi.create({ + const api = VisitsWebStorageApi.create({ identityApi: mockIdentityApi, errorApi: mockErrorApi, }); diff --git a/plugins/home/src/api/LocalStorageVisitsApi.ts b/plugins/home/src/api/VisitsWebStorageApi.ts similarity index 81% rename from plugins/home/src/api/LocalStorageVisitsApi.ts rename to plugins/home/src/api/VisitsWebStorageApi.ts index 01d57b93d9..56b3ae53c5 100644 --- a/plugins/home/src/api/LocalStorageVisitsApi.ts +++ b/plugins/home/src/api/VisitsWebStorageApi.ts @@ -14,11 +14,11 @@ * limitations under the License. */ import { ErrorApi, IdentityApi } from '@backstage/core-plugin-api'; -import { CoreStorageVisitsApi } from './CoreStorageVisitsApi'; +import { VisitsStorageApi } from './VisitsStorageApi'; import { WebStorage } from '@backstage/core-app-api'; /** @public */ -export type LocalStorageVisitsApiOptions = { +export type VisitsWebStorageApiOptions = { limit?: number; identityApi: IdentityApi; errorApi: ErrorApi; @@ -28,9 +28,9 @@ export type LocalStorageVisitsApiOptions = { * @public * This is a reference implementation of VisitsApi using WebStorage. */ -export class LocalStorageVisitsApi { - static create(options: LocalStorageVisitsApiOptions) { - return CoreStorageVisitsApi.create({ +export class VisitsWebStorageApi { + static create(options: VisitsWebStorageApiOptions) { + return VisitsStorageApi.create({ limit: options.limit, identityApi: options.identityApi, storageApi: WebStorage.create({ errorApi: options.errorApi }), diff --git a/plugins/home/src/api/index.ts b/plugins/home/src/api/index.ts index 50b48e3204..944fa65330 100644 --- a/plugins/home/src/api/index.ts +++ b/plugins/home/src/api/index.ts @@ -14,6 +14,6 @@ * limitations under the License. */ -export * from './CoreStorageVisitsApi'; -export * from './LocalStorageVisitsApi'; +export * from './VisitsStorageApi'; +export * from './VisitsWebStorageApi'; export * from './VisitsApi'; diff --git a/plugins/home/src/plugin.ts b/plugins/home/src/plugin.ts index 3bbfd9d544..d4ed401c86 100644 --- a/plugins/home/src/plugin.ts +++ b/plugins/home/src/plugin.ts @@ -25,7 +25,7 @@ import { import { createCardExtension } from '@backstage/plugin-home-react'; import { ToolkitContentProps, VisitedByTypeProps } from './homePageComponents'; import { rootRouteRef } from './routes'; -import { CoreStorageVisitsApi, visitsApiRef } from './api'; +import { VisitsStorageApi, visitsApiRef } from './api'; /** @public */ export const homePlugin = createPlugin({ @@ -38,7 +38,7 @@ export const homePlugin = createPlugin({ identityApi: identityApiRef, }, factory: ({ storageApi, identityApi }) => - CoreStorageVisitsApi.create({ storageApi, identityApi }), + VisitsStorageApi.create({ storageApi, identityApi }), }), ], routes: { From f4cb351de2928bad337559520fa28aa6776c6592 Mon Sep 17 00:00:00 2001 From: Renan Mendes Carvalho Date: Thu, 21 Sep 2023 13:39:52 +0200 Subject: [PATCH 23/28] fix(plugins/home): Get name for catalog entities fallback to title Signed-off-by: Renan Mendes Carvalho --- plugins/home/src/components/VisitListener.tsx | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/plugins/home/src/components/VisitListener.tsx b/plugins/home/src/components/VisitListener.tsx index 4a7900a63a..4cddd307c5 100644 --- a/plugins/home/src/components/VisitListener.tsx +++ b/plugins/home/src/components/VisitListener.tsx @@ -57,7 +57,16 @@ const getToEntityRef = * for receiving a pathname and returning a string (name). The default * implementation ignores the pathname and uses the document.title . */ -export const getVisitName = (document: Document) => () => document.title; +export const getVisitName = + ({ rootPath = 'catalog', document = global.document } = {}) => + ({ pathname }: { pathname: string }) => { + const regex = new RegExp( + `^\/${rootPath}\/(?[^\/]+)\/(?[^\/]+)\/(?[^\/]+)`, + ); + const result = regex.exec(pathname); + if (result && result?.groups) return result.groups.name; + return document.title; + }; /** * @public @@ -76,7 +85,7 @@ export const VisitListener = ({ const visitsApi = useApi(visitsApiRef); const { pathname } = useLocation(); const toEntityRefImpl = toEntityRef ?? getToEntityRef(); - const visitNameImpl = visitName ?? getVisitName(document); + const visitNameImpl = visitName ?? getVisitName(); useEffect(() => { // Wait for the browser to finish with paint with the assumption react // has finished with dom reconciliation. From 7a8b0b4cf376842e6fc9cce25147d53b3fb9dc31 Mon Sep 17 00:00:00 2001 From: Renan Mendes Carvalho Date: Fri, 22 Sep 2023 09:49:38 +0200 Subject: [PATCH 24/28] refactoring(plugins/home): Separate TopVisited and RecentlyVisited This patch separates the two components to be easier to import them in the customizable homepage. Signed-off-by: Renan Mendes Carvalho --- plugins/home/README.md | 13 +++++---- plugins/home/api-report.md | 17 +++++++++--- .../HomePageVisitedByType.stories.tsx | 11 +++++++- .../VisitedByType/RecentlyVisited.tsx | 27 +++++++++++++++++++ .../VisitedByType/TopVisited.tsx | 27 +++++++++++++++++++ plugins/home/src/index.ts | 3 ++- plugins/home/src/plugin.ts | 22 +++++++++++---- 7 files changed, 105 insertions(+), 15 deletions(-) create mode 100644 plugins/home/src/homePageComponents/VisitedByType/RecentlyVisited.tsx create mode 100644 plugins/home/src/homePageComponents/VisitedByType/TopVisited.tsx diff --git a/plugins/home/README.md b/plugins/home/README.md index d70667e886..d41ae57162 100644 --- a/plugins/home/README.md +++ b/plugins/home/README.md @@ -242,24 +242,27 @@ const defaultConfig = [ ``` -## Page visit homepage component (HomePageVisitedByType) +## Page visit homepage component (HomePageTopVisited / HomePageRecentlyVisited) This component shows the homepage user a view for "Recently visited" or "Top visited". -Being provided by the `` component, see it in use on a homepage example below: +Being provided by the `` and `` component, see it in use on a homepage example below: ```tsx // packages/app/src/components/home/HomePage.tsx import React from 'react'; import Grid from '@material-ui/core/Grid'; -import { HomePageVisitedByType } from '@backstage/plugin-home'; +import { + HomePageTopVisited, + HomePageRecentlyVisited, +} from '@backstage/plugin-home'; export const homePage = ( - + - + ); diff --git a/plugins/home/api-report.md b/plugins/home/api-report.md index e3f789c2af..8b43f59811 100644 --- a/plugins/home/api-report.md +++ b/plugins/home/api-report.md @@ -102,7 +102,13 @@ export type CustomHomepageGridProps = { }; // @public -export const getVisitName: (document: Document) => () => string; +export const getVisitName: ({ + rootPath, + document, +}?: { + rootPath?: string | undefined; + document?: Document | undefined; +}) => ({ pathname }: { pathname: string }) => string; // @public export const HeaderWorldClock: (props: { @@ -129,6 +135,11 @@ export const HomePageRandomJoke: ( }>, ) => JSX_2.Element; +// @public +export const HomePageRecentlyVisited: ( + props: CardExtensionProps_2>, +) => JSX_2.Element; + // @public export const HomePageStarredEntities: ( props: CardExtensionProps_2, @@ -140,8 +151,8 @@ export const HomePageToolkit: ( ) => JSX_2.Element; // @public -export const HomePageVisitedByType: ( - props: CardExtensionProps_2, +export const HomePageTopVisited: ( + props: CardExtensionProps_2>, ) => JSX_2.Element; // @public (undocumented) diff --git a/plugins/home/src/homePageComponents/VisitedByType/HomePageVisitedByType.stories.tsx b/plugins/home/src/homePageComponents/VisitedByType/HomePageVisitedByType.stories.tsx index 19861bf58e..d20d5344ae 100644 --- a/plugins/home/src/homePageComponents/VisitedByType/HomePageVisitedByType.stories.tsx +++ b/plugins/home/src/homePageComponents/VisitedByType/HomePageVisitedByType.stories.tsx @@ -18,8 +18,10 @@ import React from 'react'; import { TestApiProvider, wrapInTestApp } from '@backstage/test-utils'; import { ComponentType, PropsWithChildren } from 'react'; import { Grid } from '@material-ui/core'; -import { HomePageVisitedByType } from '../../plugin'; +import { homePlugin } from '../../plugin'; import { Visit, visitsApiRef } from '../../api/VisitsApi'; +import { createCardExtension } from '@backstage/plugin-home-react'; +import { VisitedByTypeProps } from './Content'; const visits: Array = [ { @@ -86,6 +88,13 @@ const visits: Array = [ }, ]; +const HomePageVisitedByType = homePlugin.provide( + createCardExtension({ + name: 'HomePageTopVisited', + components: () => import('./'), + }), +); + const mockVisitsApi = { save: async () => visits[0], list: async () => visits, diff --git a/plugins/home/src/homePageComponents/VisitedByType/RecentlyVisited.tsx b/plugins/home/src/homePageComponents/VisitedByType/RecentlyVisited.tsx new file mode 100644 index 0000000000..a6896f4e91 --- /dev/null +++ b/plugins/home/src/homePageComponents/VisitedByType/RecentlyVisited.tsx @@ -0,0 +1,27 @@ +/* + * 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 { Actions } from './Actions'; +export { ContextProvider } from './Context'; +export type { VisitedByTypeProps, VisitedByTypeKind } from './Content'; +import React from 'react'; +import { Content, VisitedByTypeProps } from './Content'; + +const RecentlyVisitedContent = (props: Partial) => ( + +); + +export { RecentlyVisitedContent as Content }; diff --git a/plugins/home/src/homePageComponents/VisitedByType/TopVisited.tsx b/plugins/home/src/homePageComponents/VisitedByType/TopVisited.tsx new file mode 100644 index 0000000000..5a326abf51 --- /dev/null +++ b/plugins/home/src/homePageComponents/VisitedByType/TopVisited.tsx @@ -0,0 +1,27 @@ +/* + * 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 { Actions } from './Actions'; +export { ContextProvider } from './Context'; +export type { VisitedByTypeProps, VisitedByTypeKind } from './Content'; +import React from 'react'; +import { Content, VisitedByTypeProps } from './Content'; + +const TopVisitedContent = (props: Partial) => ( + +); + +export { TopVisitedContent as Content }; diff --git a/plugins/home/src/index.ts b/plugins/home/src/index.ts index c60883d749..a80f8bd93d 100644 --- a/plugins/home/src/index.ts +++ b/plugins/home/src/index.ts @@ -32,7 +32,8 @@ export { ComponentTab, WelcomeTitle, HeaderWorldClock, - HomePageVisitedByType, + HomePageTopVisited, + HomePageRecentlyVisited, } from './plugin'; export * from './components'; export * from './assets'; diff --git a/plugins/home/src/plugin.ts b/plugins/home/src/plugin.ts index d4ed401c86..ab8c46baa1 100644 --- a/plugins/home/src/plugin.ts +++ b/plugins/home/src/plugin.ts @@ -189,12 +189,24 @@ export const HeaderWorldClock = homePlugin.provide( ); /** - * Display recently/top visited pages for the homepage + * Display top visited pages for the homepage * @public */ -export const HomePageVisitedByType = homePlugin.provide( - createCardExtension({ - name: 'HomePageVisitedByType', - components: () => import('./homePageComponents/VisitedByType'), +export const HomePageTopVisited = homePlugin.provide( + createCardExtension>({ + name: 'HomePageTopVisited', + components: () => import('./homePageComponents/VisitedByType/TopVisited'), + }), +); + +/** + * Display recently visited pages for the homepage + * @public + */ +export const HomePageRecentlyVisited = homePlugin.provide( + createCardExtension>({ + name: 'HomePageRecentlyVisited', + components: () => + import('./homePageComponents/VisitedByType/RecentlyVisited'), }), ); From 14db0dd105d3db5051132b51ab489ba3fed632a4 Mon Sep 17 00:00:00 2001 From: Renan Mendes Carvalho Date: Fri, 22 Sep 2023 09:51:14 +0200 Subject: [PATCH 25/28] feature(plugins/home): Improves title resolution Signed-off-by: Renan Mendes Carvalho --- plugins/home/src/components/VisitListener.test.tsx | 7 ++----- plugins/home/src/components/VisitListener.tsx | 12 +++++++++--- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/plugins/home/src/components/VisitListener.test.tsx b/plugins/home/src/components/VisitListener.test.tsx index 7a0ce57de5..72c530071a 100644 --- a/plugins/home/src/components/VisitListener.test.tsx +++ b/plugins/home/src/components/VisitListener.test.tsx @@ -53,7 +53,6 @@ describe('', () => { afterEach(jest.resetAllMocks); it('registers a visit', async () => { - jest.spyOn(document, 'title', 'get').mockReturnValue('MockedTitle'); const pathname = '/catalog/default/component/playback-order'; await renderInTestApp( @@ -68,7 +67,7 @@ describe('', () => { visit: { pathname, entityRef: 'component:default/playback-order', - name: 'MockedTitle', + name: 'playback-order', }, }); }); @@ -86,7 +85,6 @@ describe('', () => { }); it('is able to override how visit names are defined', async () => { - jest.spyOn(document, 'title', 'get').mockReturnValue('MockedTitle'); const pathname = '/catalog/default/component/playback-order'; const visitNameOverride = ({ pathname: path }: { pathname: string }) => @@ -111,7 +109,6 @@ describe('', () => { }); it('is able to override how entityRefs are defined', async () => { - jest.spyOn(document, 'title', 'get').mockReturnValue('MockedTitle'); const pathname = '/catalog/default/component/playback-order'; const toEntityRefOverride = ({ pathname: path }: { pathname: string }) => @@ -129,7 +126,7 @@ describe('', () => { visit: { pathname, entityRef: pathname, - name: 'MockedTitle', + name: 'playback-order', }, }), ); diff --git a/plugins/home/src/components/VisitListener.tsx b/plugins/home/src/components/VisitListener.tsx index 4cddd307c5..de7d16e44f 100644 --- a/plugins/home/src/components/VisitListener.tsx +++ b/plugins/home/src/components/VisitListener.tsx @@ -54,17 +54,23 @@ const getToEntityRef = /** * @public * This function returns an implementation of visitName which is responsible - * for receiving a pathname and returning a string (name). The default - * implementation ignores the pathname and uses the document.title . + * for receiving a pathname and returning a string (name). */ export const getVisitName = ({ rootPath = 'catalog', document = global.document } = {}) => ({ pathname }: { pathname: string }) => { + // If it is a catalog entity, get the name from the path const regex = new RegExp( `^\/${rootPath}\/(?[^\/]+)\/(?[^\/]+)\/(?[^\/]+)`, ); - const result = regex.exec(pathname); + let result = regex.exec(pathname); if (result && result?.groups) return result.groups.name; + + // If it is a root pathname, get the name from there + result = /^\/(?[^\/]+)$/.exec(pathname); + if (result && result?.groups) return result.groups.name; + + // Fallback to document title return document.title; }; From 0c927cab64f0306659c57271cbf70c791900db07 Mon Sep 17 00:00:00 2001 From: Renan Mendes Carvalho Date: Fri, 22 Sep 2023 09:53:02 +0200 Subject: [PATCH 26/28] feature(app): Adds TopVisited and RecentlyVisited to homepage This patch adds TopVisited and RecentlyVisited to the homepage example accessible in http://localhost:3000/home when running yarn dev Signed-off-by: Renan Mendes Carvalho --- packages/app/src/App.tsx | 3 ++- packages/app/src/components/home/HomePage.tsx | 4 ++++ packages/app/src/plugins.ts | 1 + 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index a0a6419328..f2b7d66b21 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -61,7 +61,7 @@ import { import { orgPlugin } from '@backstage/plugin-org'; import { ExplorePage } from '@backstage/plugin-explore'; import { GcpProjectsPage } from '@backstage/plugin-gcp-projects'; -import { HomepageCompositionRoot } from '@backstage/plugin-home'; +import { HomepageCompositionRoot, VisitListener } from '@backstage/plugin-home'; import { LighthousePage } from '@backstage/plugin-lighthouse'; import { NewRelicPage } from '@backstage/plugin-newrelic'; import { NextScaffolderPage } from '@backstage/plugin-scaffolder/alpha'; @@ -330,6 +330,7 @@ export default app.createRoot( + {routes} , diff --git a/packages/app/src/components/home/HomePage.tsx b/packages/app/src/components/home/HomePage.tsx index a8ddc0e0e2..5940b0ac6c 100644 --- a/packages/app/src/components/home/HomePage.tsx +++ b/packages/app/src/components/home/HomePage.tsx @@ -22,6 +22,8 @@ import { HomePageRandomJoke, HomePageStarredEntities, HomePageToolkit, + HomePageTopVisited, + HomePageRecentlyVisited, WelcomeTitle, } from '@backstage/plugin-home'; import { Content, Header, Page } from '@backstage/core-components'; @@ -111,6 +113,8 @@ export const homePage = ( }, ]} /> + +
diff --git a/packages/app/src/plugins.ts b/packages/app/src/plugins.ts index a1e952a07c..36f7a86acc 100644 --- a/packages/app/src/plugins.ts +++ b/packages/app/src/plugins.ts @@ -18,3 +18,4 @@ // ideally we have an API for the context menu that permits that. export { badgesPlugin } from '@backstage/plugin-badges'; export { shortcutsPlugin } from '@backstage/plugin-shortcuts'; +export { homePlugin } from '@backstage/plugin-home'; From d65cb238e8a661c05a11f9c66740e419a8d61384 Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Mon, 2 Oct 2023 13:24:22 +0200 Subject: [PATCH 27/28] fix(home): changeset version Signed-off-by: Camila Belo --- .changeset/happy-books-smoke.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/happy-books-smoke.md b/.changeset/happy-books-smoke.md index b035f84cfd..68ea19ea5e 100644 --- a/.changeset/happy-books-smoke.md +++ b/.changeset/happy-books-smoke.md @@ -1,5 +1,5 @@ --- -'@backstage/plugin-home': minor +'@backstage/plugin-home': patch --- Adds Top/Recently Visited components to homepage From e10a5eaae54540fa148ffb5234aca33d6c5e9342 Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Mon, 2 Oct 2023 13:25:43 +0200 Subject: [PATCH 28/28] fix(home): annotate `getVisitName` as internal utility Signed-off-by: Camila Belo --- plugins/home/api-report.md | 9 --------- plugins/home/src/components/VisitListener.tsx | 4 ++-- 2 files changed, 2 insertions(+), 11 deletions(-) diff --git a/plugins/home/api-report.md b/plugins/home/api-report.md index 8b43f59811..14f685cfc1 100644 --- a/plugins/home/api-report.md +++ b/plugins/home/api-report.md @@ -101,15 +101,6 @@ export type CustomHomepageGridProps = { preventCollision?: boolean; }; -// @public -export const getVisitName: ({ - rootPath, - document, -}?: { - rootPath?: string | undefined; - document?: Document | undefined; -}) => ({ pathname }: { pathname: string }) => string; - // @public export const HeaderWorldClock: (props: { clockConfigs: ClockConfig[]; diff --git a/plugins/home/src/components/VisitListener.tsx b/plugins/home/src/components/VisitListener.tsx index de7d16e44f..866fd69885 100644 --- a/plugins/home/src/components/VisitListener.tsx +++ b/plugins/home/src/components/VisitListener.tsx @@ -52,11 +52,11 @@ const getToEntityRef = }; /** - * @public + * @internal * This function returns an implementation of visitName which is responsible * for receiving a pathname and returning a string (name). */ -export const getVisitName = +const getVisitName = ({ rootPath = 'catalog', document = global.document } = {}) => ({ pathname }: { pathname: string }) => { // If it is a catalog entity, get the name from the path