From 160761b049b22ad16807ea0636322057e54f80ad Mon Sep 17 00:00:00 2001 From: Renan Mendes Carvalho Date: Wed, 23 Aug 2023 16:13:39 +0200 Subject: [PATCH] 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'), }), );