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
@@ -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';