Composable Homepage (#6486)

feat: implement homepage plugin
This commit is contained in:
Ruben Lindström
2021-08-17 14:44:30 +02:00
committed by GitHub
parent 6f98aec868
commit 2b7d3455b5
29 changed files with 1225 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
---
'@backstage/plugin-home': minor
---
Create the `Home` plugin which exports some basic functionality that's used to compose a homepage. An example of a composed homepage is added to the example app.
This change also introduces the `createCardExtension` which creates a lazy loaded card that is intended to be used for homepage components.
Adoption of this homepage requires setup similar to what can be found in the example app.
+1
View File
@@ -17,6 +17,7 @@
/plugins/techdocs @backstage/techdocs-core
/plugins/techdocs-backend @backstage/techdocs-core
/plugins/ilert @yacut
/plugins/home @backstage/techdocs-core
/packages/search-common @backstage/techdocs-core
/packages/techdocs-common @backstage/techdocs-core
/.changeset/cost-insights-* @backstage/silver-lining
+1
View File
@@ -23,6 +23,7 @@
"@backstage/plugin-gcp-projects": "^0.3.2",
"@backstage/plugin-github-actions": "^0.4.16",
"@backstage/plugin-graphiql": "^0.2.14",
"@backstage/plugin-home": "^0.1.1",
"@backstage/plugin-jenkins": "^0.5.3",
"@backstage/plugin-kafka": "^0.2.13",
"@backstage/plugin-kubernetes": "^0.4.11",
+7
View File
@@ -20,6 +20,7 @@ import {
OAuthRequestDialog,
SignInPage,
} from '@backstage/core-components';
import { HomepageCompositionRoot } from '@backstage/plugin-home';
import { apiDocsPlugin, ApiExplorerPage } from '@backstage/plugin-api-docs';
import {
CatalogEntityPage,
@@ -63,6 +64,8 @@ import { Root } from './components/Root';
import { entityPage } from './components/catalog/EntityPage';
import { searchPage } from './components/search/SearchPage';
import { LowerCaseValuePickerFieldExtension } from './components/scaffolder/customScaffolderExtensions';
import { HomePage } from './components/home/HomePage';
import { providers } from './identityProviders';
import * as plugins from './plugins';
@@ -108,6 +111,10 @@ const AppRouter = app.getRouter();
const routes = (
<FlatRoutes>
<Navigate key="/" to="catalog" />
{/* TODO(rubenl): Move this to / once its more mature and components exist */}
<Route path="/home" element={<HomepageCompositionRoot />}>
<HomePage />
</Route>
<Route path="/catalog" element={<CatalogIndexPage />} />
<Route
path="/catalog/:namespace/:kind/:name"
@@ -0,0 +1,72 @@
/*
* Copyright 2021 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 Grid from '@material-ui/core/Grid';
import {
RandomJokeHomePageComponent,
ComponentAccordion,
ComponentTabs,
ComponentTab,
} from '@backstage/plugin-home';
export const HomePage = () => (
<Grid container spacing={3}>
<Grid item xs={12} md={4}>
<RandomJokeHomePageComponent />
</Grid>
<Grid item xs={12} md={4}>
<RandomJokeHomePageComponent
defaultCategory="any"
Renderer={ComponentAccordion}
/>
<RandomJokeHomePageComponent
title="Another Random Joke"
Renderer={ComponentAccordion}
/>
<RandomJokeHomePageComponent
title="One More Random Joke"
defaultCategory="programming"
Renderer={ComponentAccordion}
/>
</Grid>
<Grid item xs={12} md={4}>
<ComponentTabs
title="Random Jokes"
tabs={[
{
label: 'Programming',
Component: () => (
<RandomJokeHomePageComponent
defaultCategory="programming"
Renderer={ComponentTab}
/>
),
},
{
label: 'Any',
Component: () => (
<RandomJokeHomePageComponent
defaultCategory="any"
Renderer={ComponentTab}
/>
),
},
]}
/>
</Grid>
</Grid>
);
+3
View File
@@ -0,0 +1,3 @@
module.exports = {
extends: [require.resolve('@backstage/cli/config/eslint')],
};
+95
View File
@@ -0,0 +1,95 @@
# Home
Development is ongoing. You can follow the progress and contribute at the Backstage [Home Project Board](https://github.com/backstage/backstage/projects/7) or reach out to us in the [`#support` Discord channel](https://discord.com/channels/687207715902193673/687235481154617364).
## Overview
The Home plugin introduces a system for composing a Home Page for Backstage in order to surface relevant info and provide convenient shortcuts for common tasks. It's designed with composability in mind with an open ecosystem that allows anyone to contribute with any component, to be included in any Home Page.
For App Integrators, the system is designed to be composable to give total freedom in designing a Home Page that suits the needs of the organization. From the perspective of a Component Developer who wishes to contribute with building blocks to be included in Home Pages, there's a convenient interface for bundling the different parts and exporting them with both error boundary and lazy loading handled under the surface.
## Getting started
If you have a standalone app (you didn't clone this repo), then do
```bash
# From the Backstage repository root
cd packages/app
yarn add @backstage/plugin-home
```
### Setting up the Home Page
1. Create a Home Page Component that will be used for composition.
`packages/app/src/components/home/HomePage.tsx`
```tsx
import React from 'react';
export const HomePage = () => {
return {
/* TODO: Compose a Home Page here */
};
};
```
2. Add a route where the homepage will live, presumably `/`.
`packages/app/src/App.tsx`
```tsx
import { HomepageCompositionRoot } from '@backstage/plugin-home';
import { HomePage } from './components/home/HomePage';
// ...
<Route path="/" element={<HomepageCompositionRoot />}>
<HomePage />
</Route>;
// ...
```
### Creating Components
The Home Page can be composed with regular React components, so there's no magic in creating components to be used for composition 🪄 🎩 . However, in order to assure that your component fits into a diverse set of Home Pages, there's an extension creator for this purpose, that creates a Card-based layout, for consistency between components (read more about extensions [here](https://backstage.io/docs/plugins/composability#extensions)). The extension creator requires two fields: `title` and `components`. The `components` field is expected to be an asynchronous import that should at least contain a `Content` field. Additionally, you can optionally provide `settings`, `actions` and `contextProvider` as well. These parts will be combined to create a card, where the `content`, `actions` and `settings` will be wrapped within the `contextProvider` in order to be able to access to context and effectively communicate with one another.
Finally, the `createCardExtension` also accepts a generic, such that Component Developers can indicate to App Integrators what custom props their component will accept, such as the example below where the default category of the random jokes can be set.
```tsx
export const RandomJokeHomePageComponent = homePlugin.provide(
createCardExtension<{ defaultCategory?: 'programming' | 'any' }>({
title: 'Random Joke',
components: () => import('./homePageComponents/RandomJoke'),
}),
);
```
In summary: it is not necessary to use the `createCardExtension` extension creator to register a home page component, although it is convenient since it provides error boundary and lazy loading, and it also may hook into other functionality in the future.
### Composing a Home Page
Composing a Home Page is no different from creating a regular React Component, i.e. the App Integrator is free to include whatever content they like. However, there are components developed with the Home Page in mind, as described in the previous section. If created by the `createCardExtension` extension creator, they are rendered like so
```tsx
import React from 'react'
import Grid from '@material-ui/core/Grid'
import { RandomJokeHomePageComponent } from '@backstage/plugin-home';
export const HomePage = () => {
return (
<Grid container spacing={3}>
<Grid item xs={12} md={4}>
<RandomJokeHomePageComponent>
</Grid>
</Grid>
)
}
```
Additionally, the App Integrator is provided an escape hatch in case the way the card is rendered does not fit their requirements. They may optionally pass the `Renderer`-prop, which will receive the `title`, `content` and optionally `actions`, `settings` and `contextProvider`, if they exist for the component. This allows the App Integrator to render the content in any way they want.
## Contributing
We believe that people have great ideas for what makes a useful Home Page, and we want to make it easy for every to benefit from the effort you put in to create something cool for the Home Page. Therefore, a great way of contributing is by simply creating more Home Page Components, than can then be used by everyone when composing their own Home Page. If they are tightly coupled to an existing plugin, it is recommended to allow them to live within that plugin, for convenience and to limit complex dependencies. On the other hand, if there's no clear plugin that the component is based on, it's also fine to contribute them into the [home plugin](/plugins/home/src/homePageComponents)
Additionally, the API is at a very early state, so contributing with additional use cases may expose weaknesses in the current solution that we may iterate on, to provide more flexibility and ease of use for those who wish to develop components for the Home Page.
+130
View File
@@ -0,0 +1,130 @@
## API Report File for "@backstage/plugin-home"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
/// <reference types="react" />
import { BackstagePlugin } from '@backstage/core-plugin-api';
import { Extension } from '@backstage/core-plugin-api';
import { ReactNode } from 'react';
import { RouteRef } from '@backstage/core-plugin-api';
// Warning: (ae-missing-release-tag) "ComponentAccordion" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export const ComponentAccordion: ({
title,
Content,
Actions,
Settings,
ContextProvider,
...childProps
}: {
title: string;
Content: () => JSX.Element;
Actions?: (() => JSX.Element) | undefined;
Settings?: (() => JSX.Element) | undefined;
ContextProvider?: ((props: any) => JSX.Element) | undefined;
}) => JSX.Element;
// Warning: (ae-missing-release-tag) "ComponentTab" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export const ComponentTab: ({
title,
Content,
ContextProvider,
...childProps
}: {
title: string;
Content: () => JSX.Element;
ContextProvider?: ((props: any) => JSX.Element) | undefined;
}) => JSX.Element;
// Warning: (ae-missing-release-tag) "ComponentTabs" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export const ComponentTabs: ({
title,
tabs,
}: {
title: string;
tabs: {
label: string;
Component: () => JSX.Element;
}[];
}) => JSX.Element;
// Warning: (ae-forgotten-export) The symbol "ComponentRenderer" needs to be exported by the entry point index.d.ts
// Warning: (ae-missing-release-tag) "createCardExtension" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export function createCardExtension<T>({
title,
components,
}: {
title: string;
components: () => Promise<ComponentParts>;
}): Extension<
({
Renderer,
title: overrideTitle,
...childProps
}: ComponentRenderer & {
title?: string;
} & T) => JSX.Element
>;
// Warning: (ae-missing-release-tag) "HomepageCompositionRoot" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export const HomepageCompositionRoot: (props: {
title?: string | undefined;
children?: ReactNode;
}) => JSX.Element;
// Warning: (ae-missing-release-tag) "homePlugin" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export const homePlugin: BackstagePlugin<
{
root: RouteRef<undefined>;
},
{}
>;
// Warning: (ae-missing-release-tag) "RandomJokeHomePageComponent" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export const RandomJokeHomePageComponent: ({
Renderer,
title: overrideTitle,
...childProps
}: ComponentRenderer & {
title?: string | undefined;
} & {
defaultCategory?: 'any' | 'programming' | undefined;
}) => JSX.Element;
// Warning: (ae-missing-release-tag) "SettingsModal" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export const SettingsModal: ({
open,
close,
componentName,
children,
}: {
open: boolean;
close: Function;
componentName: string;
children: JSX.Element;
}) => JSX.Element;
// Warnings were encountered during analysis:
//
// src/extensions.d.ts:16:5 - (ae-forgotten-export) The symbol "ComponentParts" needs to be exported by the entry point index.d.ts
// (No @packageDocumentation comment for this package)
```
+27
View File
@@ -0,0 +1,27 @@
/*
* Copyright 2021 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 { createDevApp } from '@backstage/dev-utils';
import { homePlugin, HomepageCompositionRoot } from '../src/plugin';
createDevApp()
.registerPlugin(homePlugin)
.addPage({
element: <HomepageCompositionRoot />,
title: 'Root Page',
path: '/',
})
.render();
+51
View File
@@ -0,0 +1,51 @@
{
"name": "@backstage/plugin-home",
"version": "0.1.1",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"publishConfig": {
"access": "public",
"main": "dist/index.esm.js",
"types": "dist/index.d.ts"
},
"scripts": {
"build": "backstage-cli plugin:build",
"start": "backstage-cli plugin:serve",
"lint": "backstage-cli lint",
"test": "backstage-cli test",
"diff": "backstage-cli plugin:diff",
"prepack": "backstage-cli prepack",
"postpack": "backstage-cli postpack",
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/core-components": "^0.3.1",
"@backstage/core-plugin-api": "^0.1.6",
"@backstage/theme": "^0.2.9",
"@material-ui/core": "^4.12.2",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
"@types/react": "^16.9",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-router": "^6.0.0-beta.0",
"react-use": "^17.2.4"
},
"devDependencies": {
"@backstage/cli": "^0.7.8",
"@backstage/core-app-api": "^0.1.8",
"@backstage/dev-utils": "^0.2.6",
"@backstage/test-utils": "^0.1.17",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^11.2.5",
"@testing-library/user-event": "^13.1.8",
"@types/jest": "^26.0.7",
"@types/node": "^14.14.32",
"cross-fetch": "^3.0.6",
"msw": "^0.29.0"
},
"files": [
"dist"
]
}
@@ -0,0 +1,102 @@
/*
* Copyright 2021 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 {
Accordion,
AccordionDetails,
AccordionSummary,
Typography,
IconButton,
Theme,
} from '@material-ui/core';
import { makeStyles } from '@material-ui/core/styles';
import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
import SettingsIcon from '@material-ui/icons/Settings';
import { SettingsModal } from '../components';
const useStyles = makeStyles((theme: Theme) => ({
settingsIconButton: {
padding: theme.spacing(0, 1, 0, 0),
},
}));
export const ComponentAccordion = ({
title,
Content,
Actions,
Settings,
ContextProvider,
...childProps
}: {
title: string;
Content: () => JSX.Element;
Actions?: () => JSX.Element;
Settings?: () => JSX.Element;
ContextProvider?: (props: any) => JSX.Element;
}) => {
const classes = useStyles();
const [settingsIsExpanded, setSettingsIsExpanded] = React.useState(false);
const [isExpanded, setIsExpanded] = React.useState(false);
const handleOpenSettings = (e: any) => {
e.stopPropagation();
setSettingsIsExpanded(prevState => !prevState);
};
const innerContent = (
<>
{Settings && (
<SettingsModal
open={settingsIsExpanded}
close={() => setSettingsIsExpanded(false)}
componentName={title}
>
<Settings />
</SettingsModal>
)}
<Accordion
expanded={isExpanded}
onChange={(_e: any, expanded: boolean) => setIsExpanded(expanded)}
>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
{Settings && (
<IconButton
onClick={handleOpenSettings}
className={classes.settingsIconButton}
>
<SettingsIcon />
</IconButton>
)}
<Typography>{title}</Typography>
</AccordionSummary>
<AccordionDetails>
<div>
<Content />
{Actions && <Actions />}
</div>
</AccordionDetails>
</Accordion>
</>
);
return ContextProvider ? (
<ContextProvider {...childProps}>{innerContent}</ContextProvider>
) : (
innerContent
);
};
@@ -0,0 +1,36 @@
/*
* Copyright 2021 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';
export const ComponentTab = ({
title,
Content,
ContextProvider,
...childProps
}: {
title: string;
Content: () => JSX.Element;
ContextProvider?: (props: any) => JSX.Element;
}) => {
return ContextProvider ? (
<ContextProvider {...childProps}>
<Content />
</ContextProvider>
) : (
<Content />
);
};
@@ -0,0 +1,53 @@
/*
* Copyright 2021 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 { Tabs, Tab } from '@material-ui/core';
import { InfoCard } from '@backstage/core-components';
type TabType = {
label: string;
Component: () => JSX.Element;
};
export const ComponentTabs = ({
title,
tabs,
}: {
title: string;
tabs: TabType[];
}) => {
const [value, setValue] = React.useState(0);
const handleChange = (_event: any, newValue: number) => {
setValue(newValue);
};
return (
<InfoCard title={title}>
<Tabs value={value} onChange={handleChange}>
{tabs.map(t => (
<Tab key={t.label} label={t.label} />
))}
</Tabs>
{tabs.map(({ Component }, idx) => (
<div {...(idx === value ? { style: { display: 'none' } } : {})}>
<Component />
</div>
))}
</InfoCard>
);
};
@@ -0,0 +1,18 @@
/*
* Copyright 2021 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 { ComponentTabs } from './ComponentTabs';
export { ComponentTab } from './ComponentTab';
@@ -0,0 +1,18 @@
/*
* Copyright 2021 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 { ComponentAccordion } from './ComponentAccordion';
export { ComponentTabs, ComponentTab } from './ComponentTabs';
@@ -0,0 +1,33 @@
/*
* Copyright 2021 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, { ReactNode } from 'react';
import { useOutlet } from 'react-router';
import { Content, Header, Page } from '@backstage/core-components';
export const HomepageCompositionRoot = (props: {
title?: string;
children?: ReactNode;
}) => {
const outlet = useOutlet();
const children = props.children ?? outlet;
return (
<Page themeId="home">
<Header title={props.title ?? 'Home'} />
<Content>{children}</Content>
</Page>
);
};
@@ -0,0 +1,48 @@
/*
* Copyright 2021 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 {
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
} from '@material-ui/core';
export const SettingsModal = ({
open,
close,
componentName,
children,
}: {
open: boolean;
close: Function;
componentName: string;
children: JSX.Element;
}) => {
return (
<Dialog open={open} onClose={() => close()}>
<DialogTitle>Settings - {componentName}</DialogTitle>
<DialogContent>{children}</DialogContent>
<DialogActions>
<Button onClick={() => close()} color="primary" variant="contained">
Close
</Button>
</DialogActions>
</Dialog>
);
};
+18
View File
@@ -0,0 +1,18 @@
/*
* Copyright 2020 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 { HomepageCompositionRoot } from './HomepageCompositionRoot';
export { SettingsModal } from './SettingsModal';
+123
View File
@@ -0,0 +1,123 @@
/*
* Copyright 2021 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, { Suspense } from 'react';
import { IconButton } from '@material-ui/core';
import SettingsIcon from '@material-ui/icons/Settings';
import { InfoCard } from '@backstage/core-components';
import { SettingsModal } from './components';
import { createReactExtension, useApp } from '@backstage/core-plugin-api';
export type ComponentRenderer = {
Renderer?: (props: RendererProps) => JSX.Element;
};
type ComponentParts = {
Content: () => JSX.Element;
Actions?: () => JSX.Element;
Settings?: () => JSX.Element;
ContextProvider?: (props: any) => JSX.Element;
};
type RendererProps = { title: string } & ComponentParts;
export function createCardExtension<T>({
title,
components,
}: {
title: string;
components: () => Promise<ComponentParts>;
}) {
return createReactExtension({
component: {
lazy: () =>
components().then(({ Content, Actions, Settings, ContextProvider }) => {
const CardExtension = ({
Renderer,
title: overrideTitle,
...childProps
}: ComponentRenderer & { title?: string } & T) => {
const app = useApp();
const { Progress } = app.getComponents();
const [settingsOpen, setSettingsOpen] = React.useState(false);
if (Renderer) {
return (
<Suspense fallback={<Progress />}>
<Renderer
title={overrideTitle || title}
{...{
Content,
...(Actions ? { Actions } : {}),
...(Settings ? { Settings } : {}),
...(ContextProvider ? { ContextProvider } : {}),
...childProps,
}}
/>
</Suspense>
);
}
const cardProps = {
title: overrideTitle ?? title,
...(Settings
? {
action: (
<IconButton onClick={() => setSettingsOpen(true)}>
<SettingsIcon>Settings</SettingsIcon>
</IconButton>
),
}
: {}),
...(Actions
? {
actions: <Actions />,
}
: {}),
};
const innerContent = (
<InfoCard {...cardProps}>
{Settings && (
<SettingsModal
open={settingsOpen}
componentName={title}
close={() => setSettingsOpen(false)}
>
<Settings />
</SettingsModal>
)}
<Content />
</InfoCard>
);
return (
<Suspense fallback={<Progress />}>
{ContextProvider ? (
<ContextProvider {...childProps}>
{innerContent}
</ContextProvider>
) : (
innerContent
)}
</Suspense>
);
};
return CardExtension;
}),
},
});
}
@@ -0,0 +1,29 @@
/*
* Copyright 2021 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 { Button } from '@material-ui/core';
import { useRandomJoke } from './Context';
export const Actions = () => {
const { rerollJoke } = useRandomJoke();
return (
<Button variant="contained" color="primary" onClick={() => rerollJoke()}>
Reroll
</Button>
);
};
@@ -0,0 +1,31 @@
/*
* Copyright 2021 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 { useRandomJoke } from './Context';
export const Content = () => {
const { joke, loading } = useRandomJoke();
if (loading) return <p>Loading...</p>;
return (
<div>
<p>{joke.setup}</p>
<p>{joke.punchline}</p>
</div>
);
};
@@ -0,0 +1,99 @@
/*
* Copyright 2021 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';
export type JokeType = 'any' | 'programming';
type Joke = {
setup: string;
punchline: string;
};
type RandomJokeContextValue = {
loading: boolean;
joke: Joke;
type: JokeType;
rerollJoke: Function;
handleChangeType: Function;
};
const Context = createContext<RandomJokeContextValue | undefined>(undefined);
const getNewJoke = (type: string): Promise<Joke> =>
fetch(
`https://official-joke-api.appspot.com/jokes${
type !== 'any' ? `/${type}` : ''
}/random`,
)
.then(res => res.json())
.then(data => (Array.isArray(data) ? data[0] : data));
export const ContextProvider = ({
children,
defaultCategory,
}: {
children: JSX.Element;
defaultCategory?: JokeType;
}) => {
const [loading, setLoading] = React.useState(true);
const [joke, setJoke] = React.useState<Joke>({
setup: '',
punchline: '',
});
const [type, setType] = React.useState<JokeType>(
defaultCategory || ('programming' as JokeType),
);
const rerollJoke = React.useCallback(() => {
setLoading(true);
getNewJoke(type).then(newJoke => setJoke(newJoke));
}, [type]);
const handleChangeType = (newType: JokeType) => {
setType(newType);
};
React.useEffect(() => {
setLoading(false);
}, [joke]);
React.useEffect(() => {
rerollJoke();
}, [rerollJoke]);
const value: RandomJokeContextValue = {
loading,
joke,
type,
rerollJoke,
handleChangeType,
};
return <Context.Provider value={value}>{children}</Context.Provider>;
};
export const useRandomJoke = () => {
const value = React.useContext(Context);
if (value === undefined) {
throw new Error('useRandomJoke must be used within a RandomJokeProvider');
}
return value;
};
export default Context;
@@ -0,0 +1,48 @@
/*
* Copyright 2021 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 {
FormControl,
FormLabel,
RadioGroup,
FormControlLabel,
Radio,
} from '@material-ui/core';
import React from 'react';
import { useRandomJoke, JokeType } from './Context';
export const Settings = () => {
const { type, handleChangeType } = useRandomJoke();
const JOKE_TYPES: JokeType[] = ['any' as JokeType, 'programming' as JokeType];
return (
<FormControl component="fieldset">
<FormLabel component="legend">Joke Type</FormLabel>
<RadioGroup
aria-label="joke type"
value={type}
onChange={e => handleChangeType(e.target.value)}
>
{JOKE_TYPES.map(t => (
<FormControlLabel
key={t}
value={t}
control={<Radio />}
label={`${t.slice(0, 1).toUpperCase()}${t.slice(1)}`}
/>
))}
</RadioGroup>
</FormControl>
);
};
@@ -0,0 +1,20 @@
/*
* Copyright 2021 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 { Content } from './Content';
export { Settings } from './Settings';
export { ContextProvider } from './Context';
+26
View File
@@ -0,0 +1,26 @@
/*
* Copyright 2021 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 {
homePlugin,
HomepageCompositionRoot,
RandomJokeHomePageComponent,
ComponentAccordion,
ComponentTabs,
ComponentTab,
} from './plugin';
export { SettingsModal } from './components';
export { createCardExtension } from './extensions';
+22
View File
@@ -0,0 +1,22 @@
/*
* Copyright 2021 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 { homePlugin } from './plugin';
describe('home', () => {
it('should export plugin', () => {
expect(homePlugin).toBeDefined();
});
});
+68
View File
@@ -0,0 +1,68 @@
/*
* Copyright 2021 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 {
createComponentExtension,
createPlugin,
createRoutableExtension,
} from '@backstage/core-plugin-api';
import { createCardExtension } from './extensions';
import { rootRouteRef } from './routes';
export const homePlugin = createPlugin({
id: 'home',
routes: {
root: rootRouteRef,
},
});
export const HomepageCompositionRoot = homePlugin.provide(
createRoutableExtension({
component: () =>
import('./components').then(m => m.HomepageCompositionRoot),
mountPoint: rootRouteRef,
}),
);
export const ComponentAccordion = homePlugin.provide(
createComponentExtension({
component: {
lazy: () =>
import('./componentRenderers').then(m => m.ComponentAccordion),
},
}),
);
export const ComponentTabs = homePlugin.provide(
createComponentExtension({
component: {
lazy: () => import('./componentRenderers').then(m => m.ComponentTabs),
},
}),
);
export const ComponentTab = homePlugin.provide(
createComponentExtension({
component: {
lazy: () => import('./componentRenderers').then(m => m.ComponentTab),
},
}),
);
export const RandomJokeHomePageComponent = homePlugin.provide(
createCardExtension<{ defaultCategory?: 'any' | 'programming' }>({
title: 'Random Joke',
components: () => import('./homePageComponents/RandomJoke'),
}),
);
+20
View File
@@ -0,0 +1,20 @@
/*
* Copyright 2021 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 { createRouteRef } from '@backstage/core-plugin-api';
export const rootRouteRef = createRouteRef({
title: 'home',
});
+17
View File
@@ -0,0 +1,17 @@
/*
* Copyright 2021 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 '@testing-library/jest-dom';
import 'cross-fetch/polyfill';