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 <aitherios@gmail.com>
This commit is contained in:
Renan Mendes Carvalho
2023-08-23 16:13:39 +02:00
committed by Camila Belo
parent 46aa692057
commit 160761b049
17 changed files with 659 additions and 225 deletions
+20 -34
View File
@@ -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<unknown>,
) => JSX.Element;
) => JSX_2.Element;
// @public
export const HomePageToolkit: (
props: CardExtensionProps_2<ToolkitContentProps>,
) => JSX.Element;
) => JSX_2.Element;
// @public
export const HomePageVisitedByType: (
props: CardExtensionProps_2<VisitedByTypeProps>,
) => 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<Visit>;
@@ -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<Visit[]>;
// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen
listUserVisits(queryParams?: VisitsApiQueryParams): Promise<Visit[]>;
saveVisit(pageVisit: Omit<Visit, 'id' | 'hits' | 'timestamp'>): Promise<void>;
}
// Warning: (ae-missing-release-tag) "VisitsApiQueryParams" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public
export type VisitsApiQueryParams = {
limit?: number;
@@ -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<VisitsApi>;
// @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
```
+8 -6
View File
@@ -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<Visit, 'id' | 'hits' | 'timestamp'>): Promise<void>;
/**
* Get the logged user visits.
* @param queryParams | optional search query params.
* @param queryParams - optional search query params.
*/
listUserVisits(queryParams: VisitsApiQueryParams): Promise<Visit[]>;
listUserVisits(queryParams?: VisitsApiQueryParams): Promise<Visit[]>;
}
/** @public */
export const visitsApiRef = createApiRef<VisitsApi>({
id: 'homepage.visits',
});
@@ -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('<VisitList/>', () => {
it('renders with mandatory parameters', async () => {
const { getByText } = await render(
<VisitList title="My title" detailType="time-ago" />,
);
expect(getByText('My title')).toBeInTheDocument();
});
it('renders skeleton when loading is true', async () => {
const { container } = await render(
<VisitList title="My title" detailType="time-ago" loading />,
);
expect(container.querySelectorAll('li')).toHaveLength(8);
expect(container.querySelectorAll('.MuiSkeleton-root')).toHaveLength(16);
});
it('renders specified amount of items', async () => {
const { container } = await render(
<VisitList
title="My title"
detailType="time-ago"
loading
numVisitsOpen={1}
numVisitsTotal={2}
/>,
);
expect(container.querySelectorAll('li')).toHaveLength(2);
});
it('renders some items hidden', async () => {
const { container } = await render(
<VisitList
title="My title"
detailType="time-ago"
loading
numVisitsOpen={1}
numVisitsTotal={2}
/>,
);
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(
<VisitList
title="My title"
detailType="time-ago"
loading
collapsed={false}
numVisitsOpen={1}
numVisitsTotal={2}
/>,
);
expect(container.querySelectorAll('li')[0]).toBeVisible();
expect(container.querySelectorAll('li')[1]).toBeVisible();
});
it('renders visit with time-ago', async () => {
const { container, getByText } = await render(
<BrowserRouter>
<VisitList
title="My title"
detailType="time-ago"
visits={[
{
id: 'explore',
name: 'Explore Backstage',
pathname: '/explore',
hits: 35,
timestamp: Date.now() - 86400_000,
},
]}
/>
,
</BrowserRouter>,
);
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(
<BrowserRouter>
<VisitList
title="My title"
detailType="hits"
visits={[
{
id: 'explore',
name: 'Explore Backstage',
pathname: '/explore',
hits: 35,
timestamp: Date.now() - 86400_000,
},
]}
/>
,
</BrowserRouter>,
);
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(
<BrowserRouter>
<VisitList
title="My title"
detailType="hits"
visits={[
{
id: 'explore',
name: 'Explore Backstage',
pathname: '/explore',
hits: 35,
timestamp: Date.now() - 86400_000,
},
]}
/>
,
</BrowserRouter>,
);
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(
<BrowserRouter>
<VisitList title="My title" detailType="hits" visits={[]} />,
</BrowserRouter>,
);
expect(getByText('There are no visits to show yet.')).toBeInTheDocument();
});
});
@@ -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<Visit>;
title: string;
detailType: ItemDetailType;
visits?: Visit[];
numVisitsOpen?: number;
numVisitsTotal?: number;
collapsed: boolean;
loading: boolean;
collapsed?: boolean;
loading?: boolean;
}) => {
const classes = useStyles();
@@ -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<React.SetStateAction<boolean>>;
numVisitsOpen: number;
setNumVisitsOpen: React.Dispatch<React.SetStateAction<number>>;
numVisitsTotal: number;
setNumVisitsTotal: React.Dispatch<React.SetStateAction<number>>;
visits: Array<Visit>;
setVisits: React.Dispatch<React.SetStateAction<Array<Visit>>>;
loading: boolean;
setLoading: React.Dispatch<boolean>;
};
const defaultContextValue = {
collapsed: true,
setCollapsed: () => {},
numVisitsOpen: 3,
setNumVisitsOpen: () => {},
numVisitsTotal: 8,
setNumVisitsTotal: () => {},
visits: [],
setVisits: () => {},
loading: true,
setLoading: () => {},
};
const Context = createContext<ContextValue>(defaultContextValue);
export const ContextProvider = ({ children }: { children: JSX.Element }) => {
const [collapsed, setCollapsed] = React.useState(
defaultContextValue.collapsed,
);
const [numVisitsOpen, setNumVisitsOpen] = React.useState(
defaultContextValue.numVisitsOpen,
);
const [numVisitsTotal, setNumVisitsTotal] = React.useState(
defaultContextValue.numVisitsTotal,
);
const [visits, setVisits] = React.useState<Array<Visit>>(
defaultContextValue.visits,
);
const [loading, setLoading] = React.useState<boolean>(
defaultContextValue.loading,
);
const value: ContextValue = {
collapsed,
setCollapsed,
numVisitsOpen,
setNumVisitsOpen,
numVisitsTotal,
setNumVisitsTotal,
visits,
setVisits,
loading,
setLoading,
};
return <Context.Provider value={value}>{children}</Context.Provider>;
};
export const useContext = () => {
const value = React.useContext(Context);
if (value === undefined)
throw new Error(
'RecentlyVisited useContext found undefined ContextValue, <ContextProvider/> could be missing',
);
return value;
};
export default Context;
@@ -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('<RecentlyVisited/>', () => {
it('should render', async () => {
const { getByText } = await renderInTestApp(<RecentlyVisited />);
expect(getByText('RecentlyVisited')).toBeInTheDocument();
});
});
@@ -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('<Content kind="recent"/>', () => {
it('renders', async () => {
const { getByText } = await renderInTestApp(
<TestApiProvider apis={[[visitsApiRef, mockVisitsApi]]}>
<ContextProvider>
<Content kind="recent" />
</ContextProvider>
</TestApiProvider>,
);
expect(getByText('Recently Visited')).toBeInTheDocument();
await waitFor(() =>
expect(getByText('Explore Backstage')).toBeInTheDocument(),
);
});
it('allows visits to be overridden', async () => {
const { getByText } = await renderInTestApp(
<TestApiProvider apis={[[visitsApiRef, mockVisitsApi]]}>
<ContextProvider>
<Content
kind="recent"
visits={[
{
id: 'tech-radar',
name: 'Tech Radar',
pathname: '/tech-radar',
hits: 40,
timestamp: Date.now() - 360_000,
},
]}
/>
</ContextProvider>
</TestApiProvider>,
);
expect(getByText('Recently Visited')).toBeInTheDocument();
await waitFor(() => expect(getByText('Tech Radar')).toBeInTheDocument());
});
it('allows loading to be overridden', async () => {
const { container } = await renderInTestApp(
<TestApiProvider apis={[[visitsApiRef, mockVisitsApi]]}>
<ContextProvider>
<Content kind="recent" loading />
</ContextProvider>
</TestApiProvider>,
);
expect(container.querySelector('.MuiSkeleton-root')).toBeInTheDocument();
});
it('allows number of items to be specified', async () => {
const { container } = await renderInTestApp(
<TestApiProvider apis={[[visitsApiRef, mockVisitsApi]]}>
<ContextProvider>
<Content
kind="recent"
visits={[
{
id: 'explore',
name: 'Explore Backstage',
pathname: '/explore',
hits: 35,
timestamp: Date.now() - 86400_000,
},
{
id: 'tech-radar',
name: 'Tech Radar',
pathname: '/tech-radar',
hits: 40,
timestamp: Date.now() - 360_000,
},
]}
numVisitsOpen={1}
numVisitsTotal={2}
/>
</ContextProvider>
</TestApiProvider>,
);
expect(container.querySelectorAll('li')).toHaveLength(2);
expect(container.querySelectorAll('li')[0]).toBeVisible();
expect(container.querySelectorAll('li')[1]).not.toBeVisible();
});
});
describe('<Content kind="top"/>', () => {
it('renders', async () => {
const { getByText } = await renderInTestApp(
<TestApiProvider apis={[[visitsApiRef, mockVisitsApi]]}>
<ContextProvider>
<Content kind="top" />
</ContextProvider>
</TestApiProvider>,
);
expect(getByText('Top Visited')).toBeInTheDocument();
await waitFor(() =>
expect(getByText('Explore Backstage')).toBeInTheDocument(),
);
});
});
@@ -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<Visit>;
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<ContextValueOnly> = {};
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 <RecentlyVisited />;
return <VisitedByType />;
};
@@ -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<Visit>;
loading: boolean;
kind: VisitedByTypeKind;
};
export type ContextValue = ContextValueOnly & {
setCollapsed: Dispatch<SetStateAction<boolean>>;
setNumVisitsOpen: Dispatch<SetStateAction<number>>;
setNumVisitsTotal: Dispatch<SetStateAction<number>>;
setVisits: Dispatch<SetStateAction<Array<Visit>>>;
setLoading: Dispatch<SetStateAction<boolean>>;
setKind: Dispatch<SetStateAction<VisitedByTypeKind>>;
setContext: Dispatch<SetStateAction<ContextValueOnly>>;
};
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<ContextValue>(defaultContextValue);
const getFilteredSet =
(
setContext: Dispatch<SetStateAction<ContextValueOnly>>,
contextKey: keyof ContextValueOnly,
) =>
(e: SetStateAction<any>) =>
setContext(state => ({
...state,
[contextKey]: typeof e === 'function' ? e(state[contextKey]) : e,
}));
export const ContextProvider = ({ children }: { children: JSX.Element }) => {
const [context, setContext] = React.useState<ContextValueOnly>(
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 <Context.Provider value={value}>{children}</Context.Provider>;
};
export const useContext = () => {
const value = React.useContext(Context);
if (value === undefined)
throw new Error(
'VisitedByType useContext found undefined ContextValue, <ContextProvider/> could be missing',
);
return value;
};
export default Context;
@@ -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<Visit> = [
{
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<Visit> = [
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<PropsWithChildren<{}>>) =>
wrapInTestApp(
@@ -103,42 +103,96 @@ export default {
],
};
export const Default = () => {
export const RecentlyDefault = () => {
return (
<Grid item xs={12} md={6}>
<HomePageRecentlyVisited />
<HomePageVisitedByType kind="recent" />
</Grid>
);
};
export const Empty = () => {
export const RecentlyEmpty = () => {
return (
<Grid item xs={12} md={6}>
<HomePageRecentlyVisited visits={[]} />
<HomePageVisitedByType kind="recent" visits={[]} />
</Grid>
);
};
export const FewItems = () => {
export const RecentlyFewItems = () => {
return (
<Grid item xs={12} md={6}>
<HomePageRecentlyVisited visits={visits.slice(0, 1)} />
<HomePageVisitedByType kind="recent" visits={visits.slice(0, 1)} />
</Grid>
);
};
export const MoreItems = () => {
export const RecentlyMoreItems = () => {
return (
<Grid item xs={12} md={6}>
<HomePageRecentlyVisited numVisitsOpen={5} numVisitsTotal={6} />
<HomePageVisitedByType
kind="recent"
numVisitsOpen={5}
numVisitsTotal={6}
/>
</Grid>
);
};
export const Loading = () => {
export const RecentlyLoading = () => {
return (
<Grid item xs={12} md={6}>
<HomePageRecentlyVisited numVisitsOpen={5} numVisitsTotal={6} loading />
<HomePageVisitedByType
kind="recent"
numVisitsOpen={5}
numVisitsTotal={6}
loading
/>
</Grid>
);
};
export const TopDefault = () => {
return (
<Grid item xs={12} md={6}>
<HomePageVisitedByType kind="top" />
</Grid>
);
};
export const TopEmpty = () => {
return (
<Grid item xs={12} md={6}>
<HomePageVisitedByType kind="top" visits={[]} />
</Grid>
);
};
export const TopFewItems = () => {
return (
<Grid item xs={12} md={6}>
<HomePageVisitedByType kind="top" visits={visits.slice(0, 1)} />
</Grid>
);
};
export const TopMoreItems = () => {
return (
<Grid item xs={12} md={6}>
<HomePageVisitedByType kind="top" numVisitsOpen={5} numVisitsTotal={6} />
</Grid>
);
};
export const TopLoading = () => {
return (
<Grid item xs={12} md={6}>
<HomePageVisitedByType
kind="top"
numVisitsOpen={5}
numVisitsTotal={6}
loading
/>
</Grid>
);
};
@@ -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('<VisitedByType/> kind="top"', () => {
it('should render', async () => {
const { getByText } = await renderInTestApp(
<Context.Provider value={{ ...defaultContextValue, kind: 'top' }}>
<VisitedByType />
</Context.Provider>,
);
expect(getByText('Top Visited')).toBeInTheDocument();
});
it('should display hits', async () => {
const { getByText } = await renderInTestApp(
<Context.Provider
value={{
...defaultContextValue,
kind: 'top',
loading: false,
visits: [
{
id: 'tech-radar',
name: 'Tech Radar',
pathname: '/tech-radar',
hits: 40,
timestamp: Date.now() - 360_000,
},
],
}}
>
<VisitedByType />
</Context.Provider>,
);
await waitFor(() => expect(getByText('40 times')).toBeInTheDocument());
});
});
describe('<VisitedByType/> kind="recent"', () => {
it('should render', async () => {
const { getByText } = await renderInTestApp(
<Context.Provider value={{ ...defaultContextValue, kind: 'recent' }}>
<VisitedByType />
</Context.Provider>,
);
expect(getByText('Recently Visited')).toBeInTheDocument();
});
it('should display how long ago a visit happened', async () => {
const { getByText } = await renderInTestApp(
<Context.Provider
value={{
...defaultContextValue,
kind: 'recent',
loading: false,
visits: [
{
id: 'tech-radar',
name: 'Tech Radar',
pathname: '/tech-radar',
hits: 40,
timestamp: Date.now() - 86400_000,
},
],
}}
>
<VisitedByType />
</Context.Provider>,
);
await waitFor(() => expect(getByText('1 day ago')).toBeInTheDocument());
});
});
@@ -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 (
<VisitList
visits={visits}
title="Recently visited"
detailType="time-ago"
title={kind === 'top' ? 'Top Visited' : 'Recently Visited'}
detailType={kind === 'top' ? 'hits' : 'time-ago'}
collapsed={collapsed}
numVisitsOpen={numVisitsOpen}
numVisitsTotal={numVisitsTotal}
@@ -17,4 +17,4 @@
export { Content } from './Content';
export { Actions } from './Actions';
export { ContextProvider } from './Context';
export type { RecentlyVisitedProps } from './Content';
export type { VisitedByTypeProps, VisitedByTypeKind } from './Content';
+1 -1
View File
@@ -17,4 +17,4 @@
export type { ToolkitContentProps, Tool } from './Toolkit';
export type { ClockConfig } from './HeaderWorldClock';
export type { WelcomeTitleLanguageProps } from './WelcomeTitle';
export type { RecentlyVisitedProps } from './RecentlyVisited';
export type { VisitedByTypeProps, VisitedByTypeKind } from './VisitedByType';
+1
View File
@@ -32,6 +32,7 @@ export {
ComponentTab,
WelcomeTitle,
HeaderWorldClock,
HomePageVisitedByType,
} from './plugin';
export * from './components';
export * from './assets';
+6 -9
View File
@@ -20,10 +20,7 @@ import {
createRoutableExtension,
} from '@backstage/core-plugin-api';
import { createCardExtension } from '@backstage/plugin-home-react';
import {
ToolkitContentProps,
RecentlyVisitedProps,
} from './homePageComponents';
import { ToolkitContentProps, VisitedByTypeProps } from './homePageComponents';
import { rootRouteRef } from './routes';
/** @public */
@@ -177,12 +174,12 @@ export const HeaderWorldClock = homePlugin.provide(
);
/**
* Display recently visited pages for the homepage
* Display recently/top visited pages for the homepage
* @public
*/
export const HomePageRecentlyVisited = homePlugin.provide(
createCardExtension<RecentlyVisitedProps>({
name: 'HomePageRecentlyVisited',
components: () => import('./homePageComponents/RecentlyVisited'),
export const HomePageVisitedByType = homePlugin.provide(
createCardExtension<VisitedByTypeProps>({
name: 'HomePageVisitedByType',
components: () => import('./homePageComponents/VisitedByType'),
}),
);