feat: customizable homepage
Allows customizing homepage components, their placement, size and individual settings. For maximum size and settings, the existing home components should change to use `createCustomizableCardExtension` function. This disables the default `Settings` entity and replaces it with form using react-json-schema-form. relates to #16535 Signed-off-by: Heikki Hellgren <heikki.hellgren@op.fi>
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
---
|
||||
'@backstage/plugin-home': minor
|
||||
---
|
||||
|
||||
Add support for customizable homepage.
|
||||
|
||||
Allows customizing homepage components, their placement, size and
|
||||
individual settings. For maximum size and settings, the existing home
|
||||
components should add necessary data attributes to their components.
|
||||
|
||||
See `plugins/home/README.md` for more information how to configure
|
||||
the customizable homepage.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/core-plugin-api': patch
|
||||
---
|
||||
|
||||
Add component name as data attribute for all components
|
||||
@@ -16,19 +16,16 @@
|
||||
|
||||
import {
|
||||
HomePageRandomJoke,
|
||||
ComponentAccordion,
|
||||
ComponentTabs,
|
||||
ComponentTab,
|
||||
WelcomeTitle,
|
||||
HeaderWorldClock,
|
||||
ClockConfig,
|
||||
HomePageStarredEntities,
|
||||
CustomHomepageGrid,
|
||||
} from '@backstage/plugin-home';
|
||||
import { Content, Header, Page } from '@backstage/core-components';
|
||||
import { HomePageSearchBar } from '@backstage/plugin-search';
|
||||
import { HomePageCalendar } from '@backstage/plugin-gcalendar';
|
||||
import { MicrosoftCalendarCard } from '@backstage/plugin-microsoft-calendar';
|
||||
import Grid from '@material-ui/core/Grid';
|
||||
import React from 'react';
|
||||
|
||||
const clockConfigs: ClockConfig[] = [
|
||||
@@ -56,6 +53,16 @@ const timeFormat: Intl.DateTimeFormatOptions = {
|
||||
hour12: false,
|
||||
};
|
||||
|
||||
const defaultConfig = [
|
||||
{
|
||||
component: 'HomePageSearchBar',
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 12,
|
||||
height: 1,
|
||||
},
|
||||
];
|
||||
|
||||
export const homePage = (
|
||||
<Page themeId="home">
|
||||
<Header title={<WelcomeTitle />} pageTitleOverride="Home">
|
||||
@@ -65,63 +72,13 @@ export const homePage = (
|
||||
/>
|
||||
</Header>
|
||||
<Content>
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12}>
|
||||
<HomePageSearchBar />
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<HomePageRandomJoke />
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<HomePageRandomJoke
|
||||
defaultCategory="any"
|
||||
Renderer={ComponentAccordion}
|
||||
/>
|
||||
<HomePageRandomJoke
|
||||
title="Another Random Joke"
|
||||
Renderer={ComponentAccordion}
|
||||
/>
|
||||
<HomePageRandomJoke
|
||||
title="One More Random Joke"
|
||||
defaultCategory="programming"
|
||||
Renderer={ComponentAccordion}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<ComponentTabs
|
||||
title="Random Jokes"
|
||||
tabs={[
|
||||
{
|
||||
label: 'Programming',
|
||||
Component: () => (
|
||||
<HomePageRandomJoke
|
||||
defaultCategory="programming"
|
||||
Renderer={ComponentTab}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: 'Any',
|
||||
Component: () => (
|
||||
<HomePageRandomJoke
|
||||
defaultCategory="any"
|
||||
Renderer={ComponentTab}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<HomePageCalendar />
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<MicrosoftCalendarCard />
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<HomePageStarredEntities />
|
||||
</Grid>
|
||||
</Grid>
|
||||
<CustomHomepageGrid config={defaultConfig}>
|
||||
<HomePageSearchBar />
|
||||
<HomePageRandomJoke />
|
||||
<HomePageCalendar />
|
||||
<MicrosoftCalendarCard />
|
||||
<HomePageStarredEntities />
|
||||
</CustomHomepageGrid>
|
||||
</Content>
|
||||
</Page>
|
||||
);
|
||||
|
||||
@@ -256,6 +256,7 @@ export function createReactExtension<
|
||||
};
|
||||
|
||||
attachComponentData(Result, 'core.plugin', plugin);
|
||||
attachComponentData(Result, 'core.extensionName', name);
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
attachComponentData(Result, key, value);
|
||||
}
|
||||
|
||||
+153
-5
@@ -66,23 +66,171 @@ In summary: it is not necessary to use the `createCardExtension` extension creat
|
||||
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 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>
|
||||
<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.
|
||||
|
||||
## Customizable home page
|
||||
|
||||
If you want to allow users to customize the components that are shown in the home page, you can use CustomHomePageGrid component.
|
||||
By adding the allowed components inside the grid, the user can add, configure, remove and move the components around in their
|
||||
home page. The user configuration is also saved and restored in the process for later use.
|
||||
|
||||
```tsx
|
||||
import {
|
||||
HomePageRandomJoke,
|
||||
HomePageStarredEntities,
|
||||
CustomHomepageGrid,
|
||||
} from '@backstage/plugin-home';
|
||||
import { Content, Header, Page } from '@backstage/core-components';
|
||||
import { HomePageSearchBar } from '@backstage/plugin-search';
|
||||
import { HomePageCalendar } from '@backstage/plugin-gcalendar';
|
||||
import { MicrosoftCalendarCard } from '@backstage/plugin-microsoft-calendar';
|
||||
|
||||
export const HomePage = () => {
|
||||
return (
|
||||
<CustomHomepageGrid>
|
||||
// Insert the allowed widgets inside the grid
|
||||
<HomePageSearchBar />
|
||||
<HomePageRandomJoke />
|
||||
<HomePageCalendar />
|
||||
<MicrosoftCalendarCard />
|
||||
<HomePageStarredEntities />
|
||||
</CustomHomepageGrid>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### Creating Customizable Components
|
||||
|
||||
The custom home page can use the default components created by using the default `createCardExtension` method but if you
|
||||
want to add additional configuration like component size or settings, you can define those in the `layout`
|
||||
property:
|
||||
|
||||
```tsx
|
||||
export const RandomJokeHomePageComponent = homePlugin.provide(
|
||||
createCardExtension<{ defaultCategory?: 'any' | 'programming' }>({
|
||||
name: 'HomePageRandomJoke',
|
||||
title: 'Random Joke',
|
||||
components: () => import('./homePageComponents/RandomJoke'),
|
||||
layout: {
|
||||
height: { minRows: 7 },
|
||||
width: { minColumns: 3 },
|
||||
},
|
||||
}),
|
||||
);
|
||||
```
|
||||
|
||||
These settings can also be defined for components that use `createReactExtension` instead `createCardExtension` by using
|
||||
the data property:
|
||||
|
||||
```tsx
|
||||
export const HomePageSearchBar = searchPlugin.provide(
|
||||
createReactExtension({
|
||||
name: 'HomePageSearchBar',
|
||||
component: {
|
||||
lazy: () =>
|
||||
import('./components/HomePageComponent').then(m => m.HomePageSearchBar),
|
||||
},
|
||||
data: {
|
||||
'home.widget.config': {
|
||||
layout: {
|
||||
height: { maxRows: 1 },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
```
|
||||
|
||||
Available home page properties that are used for homepage widgets are:
|
||||
|
||||
| Key | Type | Description |
|
||||
| ----------------------------- | ------- | ------------------------------------------------------------ |
|
||||
| `title` | string | User friend title. Shown when user adds widgets to homepage |
|
||||
| `description` | string | Widget description. Shown when user adds widgets to homepage |
|
||||
| `layout.width.defaultColumns` | integer | Default width of the widget (1-12) |
|
||||
| `layout.width.minColumns` | integer | Minimum width of the widget (1-12) |
|
||||
| `layout.width.maxColumns` | integer | Maximum width of the widget (1-12) |
|
||||
| `layout.height.defaultRows` | integer | Default height of the widget (1-12) |
|
||||
| `layout.height.minRows` | integer | Minimum height of the widget (1-12) |
|
||||
| `layout.height.maxRows` | integer | Maximum height of the widget (1-12) |
|
||||
| `settings.schema` | object | Customization settings of the widget, see below |
|
||||
|
||||
#### Widget Specific Settings
|
||||
|
||||
To define settings that the users can change for your component, you should define the `layout` and `settings`
|
||||
properties. The `settings.schema` object should follow
|
||||
[react-jsonschema-form](https://rjsf-team.github.io/react-jsonschema-form/docs/) definition and the type of the schema
|
||||
must be `object`.
|
||||
|
||||
```tsx
|
||||
export const HomePageRandomJoke = homePlugin.provide(
|
||||
createCardExtension<{ defaultCategory?: 'any' | 'programming' }>({
|
||||
name: 'HomePageRandomJoke',
|
||||
title: 'Random Joke',
|
||||
components: () => import('./homePageComponents/RandomJoke'),
|
||||
description: 'Shows a random joke about optional category',
|
||||
layout: {
|
||||
height: { minRows: 4 },
|
||||
width: { minColumns: 3 },
|
||||
},
|
||||
settings: {
|
||||
schema: {
|
||||
title: 'Random Joke settings',
|
||||
type: 'object',
|
||||
properties: {
|
||||
defaultCategory: {
|
||||
title: 'Category',
|
||||
type: 'string',
|
||||
enum: ['any', 'programming', 'dad'],
|
||||
default: 'any',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
```
|
||||
|
||||
This allows the user to select `defaultCategory` for the RandomJoke widgets that are added to the homepage.
|
||||
Each widget has its own settings and the setting values are passed to the underlying React component in props.
|
||||
|
||||
In case your `CardExtension` had `Settings` component defined, it will automatically disappear when you add the
|
||||
`settingsSchema` to the component data structure.
|
||||
|
||||
### Adding Default Layout
|
||||
|
||||
You can set the default layout of the customizable home page by passing configuration to the `CustomHomepageGrid`
|
||||
component:
|
||||
|
||||
```tsx
|
||||
const defaultConfig = [
|
||||
{
|
||||
component: <HomePageSearchBar />, // Or 'HomePageSearchBar' as a string if you know the component name
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 12,
|
||||
height: 1,
|
||||
},
|
||||
];
|
||||
|
||||
<CustomHomepageGrid config={defaultConfig}>
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
### Homepage Components
|
||||
|
||||
@@ -8,7 +8,9 @@
|
||||
import { BackstagePlugin } from '@backstage/core-plugin-api';
|
||||
import { Extension } from '@backstage/core-plugin-api';
|
||||
import { default as React_2 } from 'react';
|
||||
import { ReactElement } from 'react';
|
||||
import { ReactNode } from 'react';
|
||||
import { RJSFSchema } from '@rjsf/utils';
|
||||
import { RouteRef } from '@backstage/core-plugin-api';
|
||||
|
||||
// @public (undocumented)
|
||||
@@ -16,6 +18,25 @@ export type CardExtensionProps<T> = ComponentRenderer & {
|
||||
title?: string;
|
||||
} & T;
|
||||
|
||||
// @public (undocumented)
|
||||
export type CardLayout = {
|
||||
width?: {
|
||||
minColumns?: number;
|
||||
maxColumns?: number;
|
||||
defaultColumns?: number;
|
||||
};
|
||||
height?: {
|
||||
minRows?: number;
|
||||
maxRows?: number;
|
||||
defaultRows?: number;
|
||||
};
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export type CardSettings = {
|
||||
schema?: RJSFSchema;
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export type ClockConfig = {
|
||||
label: string;
|
||||
@@ -66,8 +87,23 @@ export function createCardExtension<T>(options: {
|
||||
title: string;
|
||||
components: () => Promise<ComponentParts>;
|
||||
name?: string;
|
||||
description?: string;
|
||||
layout?: CardLayout;
|
||||
settings?: CardSettings;
|
||||
}): Extension<(props: CardExtensionProps<T>) => JSX.Element>;
|
||||
|
||||
// @public
|
||||
export const CustomHomepageGrid: (
|
||||
props: CustomHomepageGridProps,
|
||||
) => JSX.Element;
|
||||
|
||||
// @public (undocumented)
|
||||
export type CustomHomepageGridProps = {
|
||||
children?: ReactNode;
|
||||
config?: LayoutConfiguration[];
|
||||
rowHeight?: number;
|
||||
};
|
||||
|
||||
// @public
|
||||
export const HeaderWorldClock: (props: {
|
||||
clockConfigs: ClockConfig[];
|
||||
@@ -112,6 +148,15 @@ export const homePlugin: BackstagePlugin<
|
||||
{}
|
||||
>;
|
||||
|
||||
// @public
|
||||
export type LayoutConfiguration = {
|
||||
component: ReactElement | string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export type RendererProps = {
|
||||
title: string;
|
||||
|
||||
@@ -42,9 +42,15 @@
|
||||
"@material-ui/core": "^4.12.2",
|
||||
"@material-ui/icons": "^4.9.1",
|
||||
"@material-ui/lab": "4.0.0-alpha.61",
|
||||
"@rjsf/material-ui": "^3.2.1",
|
||||
"@rjsf/utils": "5.6.0",
|
||||
"@types/react": "^16.13.1 || ^17.0.0",
|
||||
"lodash": "^4.17.21",
|
||||
"react-use": "^17.2.4"
|
||||
"object-hash": "^3.0.0",
|
||||
"react-grid-layout": "^1.3.4",
|
||||
"react-resizable": "^3.0.4",
|
||||
"react-use": "^17.2.4",
|
||||
"zod": "~3.21.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.13.1 || ^17.0.0",
|
||||
@@ -61,6 +67,7 @@
|
||||
"@testing-library/react": "^12.1.3",
|
||||
"@testing-library/user-event": "^14.0.0",
|
||||
"@types/node": "^16.11.26",
|
||||
"@types/react-grid-layout": "^1.3.2",
|
||||
"cross-fetch": "^3.1.5",
|
||||
"msw": "^1.0.0"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
/*
|
||||
* 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 { Widget } from './types';
|
||||
import List from '@material-ui/core/List';
|
||||
import ListItem from '@material-ui/core/ListItem';
|
||||
import { DialogContent, DialogTitle, ListItemAvatar } from '@material-ui/core';
|
||||
import AddIcon from '@material-ui/icons/Add';
|
||||
import ListItemText from '@material-ui/core/ListItemText';
|
||||
import React from 'react';
|
||||
import Typography from '@material-ui/core/Typography';
|
||||
|
||||
interface AddWidgetDialogProps {
|
||||
widgets: Widget[];
|
||||
handleAdd: (widget: Widget) => void;
|
||||
}
|
||||
|
||||
const getTitle = (widget: Widget) => {
|
||||
return widget.title ?? widget.name;
|
||||
};
|
||||
|
||||
export const AddWidgetDialog = (props: AddWidgetDialogProps) => {
|
||||
const { widgets, handleAdd } = props;
|
||||
return (
|
||||
<>
|
||||
<DialogTitle>Add new widget to dashboard</DialogTitle>
|
||||
<DialogContent>
|
||||
<List dense>
|
||||
{widgets.map(widget => {
|
||||
return (
|
||||
<ListItem
|
||||
key={widget.name}
|
||||
button
|
||||
onClick={() => handleAdd(widget)}
|
||||
>
|
||||
<ListItemAvatar>
|
||||
<AddIcon />
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
secondary={
|
||||
widget.description && (
|
||||
<Typography
|
||||
component="span"
|
||||
variant="caption"
|
||||
color="textPrimary"
|
||||
>
|
||||
{widget.description}
|
||||
</Typography>
|
||||
)
|
||||
}
|
||||
primary={
|
||||
<Typography variant="body1" color="textPrimary">
|
||||
{getTitle(widget)}
|
||||
</Typography>
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
</DialogContent>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,102 @@
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import Button from '@material-ui/core/Button';
|
||||
import React from 'react';
|
||||
import { createStyles, makeStyles, Theme } from '@material-ui/core';
|
||||
import SaveIcon from '@material-ui/icons/Save';
|
||||
import DeleteIcon from '@material-ui/icons/Delete';
|
||||
import AddIcon from '@material-ui/icons/Add';
|
||||
import EditIcon from '@material-ui/icons/Edit';
|
||||
|
||||
const useStyles = makeStyles((theme: Theme) =>
|
||||
createStyles({
|
||||
contentHeaderBtn: {
|
||||
marginLeft: theme.spacing(2),
|
||||
},
|
||||
widgetWrapper: {
|
||||
'& > *:first-child': {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
interface CustomHomepageButtonsProps {
|
||||
editMode: boolean;
|
||||
numWidgets: number;
|
||||
clearLayout: () => void;
|
||||
setAddWidgetDialogOpen: (open: boolean) => void;
|
||||
changeEditMode: (mode: boolean) => void;
|
||||
}
|
||||
export const CustomHomepageButtons = (props: CustomHomepageButtonsProps) => {
|
||||
const {
|
||||
editMode,
|
||||
numWidgets,
|
||||
clearLayout,
|
||||
setAddWidgetDialogOpen,
|
||||
changeEditMode,
|
||||
} = props;
|
||||
const styles = useStyles();
|
||||
|
||||
return (
|
||||
<>
|
||||
{!editMode && numWidgets > 0 ? (
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={() => changeEditMode(true)}
|
||||
startIcon={<EditIcon />}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
{numWidgets > 0 && (
|
||||
<Button
|
||||
variant="contained"
|
||||
color="secondary"
|
||||
className={styles.contentHeaderBtn}
|
||||
onClick={clearLayout}
|
||||
startIcon={<DeleteIcon />}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="contained"
|
||||
className={styles.contentHeaderBtn}
|
||||
onClick={() => setAddWidgetDialogOpen(true)}
|
||||
startIcon={<AddIcon />}
|
||||
>
|
||||
Add widget
|
||||
</Button>
|
||||
{numWidgets > 0 && (
|
||||
<Button
|
||||
className={styles.contentHeaderBtn}
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={() => changeEditMode(false)}
|
||||
startIcon={<SaveIcon />}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,363 @@
|
||||
/*
|
||||
* 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, { ReactNode, useCallback, useMemo } from 'react';
|
||||
import { Layout, Layouts, Responsive, WidthProvider } from 'react-grid-layout';
|
||||
import {
|
||||
storageApiRef,
|
||||
useApi,
|
||||
getComponentData,
|
||||
useElementFilter,
|
||||
ElementCollection,
|
||||
} from '@backstage/core-plugin-api';
|
||||
import 'react-grid-layout/css/styles.css';
|
||||
import 'react-resizable/css/styles.css';
|
||||
import { createStyles, Dialog, makeStyles, Theme } from '@material-ui/core';
|
||||
import { compact } from 'lodash';
|
||||
import useObservable from 'react-use/lib/useObservable';
|
||||
import { ContentHeader, ErrorBoundary } from '@backstage/core-components';
|
||||
import Typography from '@material-ui/core/Typography';
|
||||
import { WidgetSettingsOverlay } from './WidgetSettingsOverlay';
|
||||
import { AddWidgetDialog } from './AddWidgetDialog';
|
||||
import { CustomHomepageButtons } from './CustomHomepageButtons';
|
||||
import {
|
||||
CustomHomepageGridStateV1,
|
||||
CustomHomepageGridStateV1Schema,
|
||||
LayoutConfiguration,
|
||||
Widget,
|
||||
GridWidget,
|
||||
LayoutConfigurationSchema,
|
||||
WidgetSchema,
|
||||
} from './types';
|
||||
import { CardConfig } from '../../extensions';
|
||||
|
||||
// eslint-disable-next-line new-cap
|
||||
const ResponsiveGrid = WidthProvider(Responsive);
|
||||
const hash = require('object-hash');
|
||||
|
||||
const useStyles = makeStyles((theme: Theme) =>
|
||||
createStyles({
|
||||
responsiveGrid: {
|
||||
'& .react-grid-item > .react-resizable-handle:after': {
|
||||
position: 'absolute',
|
||||
content: '""',
|
||||
borderStyle: 'solid',
|
||||
borderWidth: '0 0 20px 20px',
|
||||
borderColor: `transparent transparent ${theme.palette.primary.light} transparent`,
|
||||
},
|
||||
},
|
||||
contentHeaderBtn: {
|
||||
marginLeft: theme.spacing(2),
|
||||
},
|
||||
widgetWrapper: {
|
||||
'& > *:first-child': {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
'& + .react-grid-placeholder': {
|
||||
backgroundColor: theme.palette.primary.light,
|
||||
},
|
||||
'&.edit > :active': {
|
||||
cursor: 'move',
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
function useHomeStorage(
|
||||
defaultWidgets: GridWidget[],
|
||||
): [GridWidget[], (value: GridWidget[]) => void] {
|
||||
const key = 'home';
|
||||
const storageApi = useApi(storageApiRef).forBucket('home.customHomepage');
|
||||
// TODO: Support multiple home pages
|
||||
const setWidgets = useCallback(
|
||||
(value: GridWidget[]) => {
|
||||
const grid: CustomHomepageGridStateV1 = {
|
||||
version: 1,
|
||||
pages: {
|
||||
default: value,
|
||||
},
|
||||
};
|
||||
storageApi.set(key, JSON.stringify(grid));
|
||||
},
|
||||
[key, storageApi],
|
||||
);
|
||||
const homeSnapshot = useObservable(
|
||||
storageApi.observe$<string>(key),
|
||||
storageApi.snapshot(key),
|
||||
);
|
||||
const widgets: GridWidget[] = useMemo(() => {
|
||||
if (homeSnapshot.presence === 'absent') {
|
||||
return defaultWidgets;
|
||||
}
|
||||
try {
|
||||
const grid: CustomHomepageGridStateV1 = JSON.parse(homeSnapshot.value!);
|
||||
return CustomHomepageGridStateV1Schema.parse(grid).pages.default;
|
||||
} catch (e) {
|
||||
return defaultWidgets;
|
||||
}
|
||||
}, [homeSnapshot, defaultWidgets]);
|
||||
|
||||
return [widgets, setWidgets];
|
||||
}
|
||||
|
||||
const convertConfigToDefaultWidgets = (
|
||||
config: LayoutConfiguration[],
|
||||
availableWidgets: Widget[],
|
||||
): GridWidget[] => {
|
||||
const ret = config.map((conf, i) => {
|
||||
const c = LayoutConfigurationSchema.parse(conf);
|
||||
const name = React.isValidElement(c.component)
|
||||
? getComponentData(c.component, 'core.extensionName')
|
||||
: (c.component as unknown as string);
|
||||
if (!name) {
|
||||
return null;
|
||||
}
|
||||
const widget = availableWidgets.find(w => w.name === name);
|
||||
if (!widget) {
|
||||
return null;
|
||||
}
|
||||
const widgetId = `${widget.name}__${i}${Math.random()
|
||||
.toString(36)
|
||||
.slice(2)}`;
|
||||
return {
|
||||
id: widgetId,
|
||||
layout: {
|
||||
i: widgetId,
|
||||
x: c.x,
|
||||
y: c.y,
|
||||
w: Math.min(widget.maxWidth ?? Number.MAX_VALUE, c.width),
|
||||
h: Math.min(widget.maxHeight ?? Number.MAX_VALUE, c.height),
|
||||
minW: widget.minWidth,
|
||||
maxW: widget.maxWidth,
|
||||
minH: widget.minHeight,
|
||||
maxH: widget.maxHeight,
|
||||
isDraggable: false,
|
||||
isResizable: false,
|
||||
},
|
||||
settings: {},
|
||||
};
|
||||
});
|
||||
return compact(ret);
|
||||
};
|
||||
|
||||
const availableWidgetsFilter = (elements: ElementCollection) => {
|
||||
return elements
|
||||
.selectByComponentData({
|
||||
key: 'core.extensionName',
|
||||
})
|
||||
.getElements<Widget>()
|
||||
.flatMap(elem => {
|
||||
const config = getComponentData<CardConfig>(elem, 'home.widget.config');
|
||||
return [
|
||||
WidgetSchema.parse({
|
||||
component: elem,
|
||||
name: getComponentData<string>(elem, 'core.extensionName'),
|
||||
title: getComponentData<string>(elem, 'title'),
|
||||
description: getComponentData<string>(elem, 'description'),
|
||||
settingsSchema: config?.settings?.schema,
|
||||
width: config?.layout?.width?.defaultColumns,
|
||||
minWidth: config?.layout?.width?.minColumns,
|
||||
maxWidth: config?.layout?.width?.maxColumns,
|
||||
height: config?.layout?.height?.defaultRows,
|
||||
minHeight: config?.layout?.height?.minRows,
|
||||
maxHeight: config?.layout?.height?.maxRows,
|
||||
}),
|
||||
];
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export type CustomHomepageGridProps = {
|
||||
children?: ReactNode;
|
||||
config?: LayoutConfiguration[];
|
||||
rowHeight?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* A component that allows customizing components in home grid layout.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const CustomHomepageGrid = (props: CustomHomepageGridProps) => {
|
||||
const styles = useStyles();
|
||||
const availableWidgets = useElementFilter(
|
||||
props.children,
|
||||
availableWidgetsFilter,
|
||||
[props],
|
||||
);
|
||||
|
||||
const defaultLayout = props.config
|
||||
? convertConfigToDefaultWidgets(props.config, availableWidgets)
|
||||
: [];
|
||||
const [widgets, setWidgets] = useHomeStorage(defaultLayout);
|
||||
const [addWidgetDialogOpen, setAddWidgetDialogOpen] = React.useState(false);
|
||||
const editModeOn = widgets.find(w => w.layout.isResizable) !== undefined;
|
||||
const [editMode, setEditMode] = React.useState(editModeOn);
|
||||
const getWidgetByName = (name: string) => {
|
||||
return availableWidgets.find(widget => widget.name === name);
|
||||
};
|
||||
|
||||
const getWidgetNameFromKey = (key: string) => {
|
||||
return key.split('__')[0];
|
||||
};
|
||||
|
||||
const handleAdd = (widget: Widget) => {
|
||||
const widgetId = `${widget.name}__${widgets.length + 1}${Math.random()
|
||||
.toString(36)
|
||||
.slice(2)}`;
|
||||
|
||||
setWidgets([
|
||||
...widgets,
|
||||
{
|
||||
id: widgetId,
|
||||
layout: {
|
||||
i: widgetId,
|
||||
x: 0,
|
||||
y: Math.max(...widgets.map(w => w.layout.y + w.layout.h)) + 1,
|
||||
w: Math.min(widget.maxWidth ?? Number.MAX_VALUE, widget.width ?? 12),
|
||||
h: Math.min(widget.maxHeight ?? Number.MAX_VALUE, widget.height ?? 4),
|
||||
minW: widget.minWidth,
|
||||
maxW: widget.maxWidth,
|
||||
minH: widget.minHeight,
|
||||
maxH: widget.maxHeight,
|
||||
isResizable: editMode,
|
||||
isDraggable: editMode,
|
||||
},
|
||||
settings: {},
|
||||
},
|
||||
]);
|
||||
setAddWidgetDialogOpen(false);
|
||||
};
|
||||
|
||||
const handleRemove = (widgetId: string) => {
|
||||
setWidgets(widgets.filter(w => w.id !== widgetId));
|
||||
};
|
||||
|
||||
const handleSettingsSave = (
|
||||
widgetId: string,
|
||||
widgetSettings: Record<string, any>,
|
||||
) => {
|
||||
const idx = widgets.findIndex(w => w.id === widgetId);
|
||||
if (idx >= 0) {
|
||||
const widget = widgets[idx];
|
||||
widget.settings = widgetSettings;
|
||||
widgets[idx] = widget;
|
||||
setWidgets(widgets);
|
||||
}
|
||||
};
|
||||
|
||||
const clearLayout = () => {
|
||||
setWidgets([]);
|
||||
};
|
||||
|
||||
const changeEditMode = (mode: boolean) => {
|
||||
setEditMode(mode);
|
||||
setWidgets(
|
||||
widgets.map(w => {
|
||||
return {
|
||||
...w,
|
||||
layout: { ...w.layout, isDraggable: mode, isResizable: mode },
|
||||
};
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const handleLayoutChange = (newLayout: Layout[], _: Layouts) => {
|
||||
if (editMode) {
|
||||
const newWidgets = newLayout.map(l => {
|
||||
const widget = widgets.find(w => w.id === l.i);
|
||||
return {
|
||||
...widget,
|
||||
layout: l,
|
||||
} as GridWidget;
|
||||
});
|
||||
setWidgets(newWidgets);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ContentHeader title="">
|
||||
<CustomHomepageButtons
|
||||
editMode={editMode}
|
||||
numWidgets={widgets.length}
|
||||
clearLayout={clearLayout}
|
||||
setAddWidgetDialogOpen={setAddWidgetDialogOpen}
|
||||
changeEditMode={changeEditMode}
|
||||
/>
|
||||
</ContentHeader>
|
||||
<Dialog
|
||||
open={addWidgetDialogOpen}
|
||||
onClose={() => setAddWidgetDialogOpen(false)}
|
||||
>
|
||||
<AddWidgetDialog widgets={availableWidgets} handleAdd={handleAdd} />
|
||||
</Dialog>
|
||||
{!editMode && widgets.length === 0 && (
|
||||
<Typography variant="h5" align="center">
|
||||
No widgets added. Start by clicking the 'Add widget' button.
|
||||
</Typography>
|
||||
)}
|
||||
<ResponsiveGrid
|
||||
className={styles.responsiveGrid}
|
||||
measureBeforeMount
|
||||
compactType="horizontal"
|
||||
draggableCancel=".overlayGridItem,.widgetSettingsDialog"
|
||||
breakpoints={{ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }}
|
||||
cols={{ lg: 12, md: 10, sm: 6, xs: 4, xxs: 2 }}
|
||||
rowHeight={props.rowHeight ?? 60}
|
||||
onLayoutChange={handleLayoutChange}
|
||||
layouts={{ lg: widgets.map(w => w.layout) }}
|
||||
>
|
||||
{widgets.map((w: GridWidget) => {
|
||||
const l = w.layout;
|
||||
const widgetName = getWidgetNameFromKey(l.i);
|
||||
const widget = getWidgetByName(widgetName);
|
||||
if (!widget || !widget.component) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const widgetProps = {
|
||||
...widget.component.props,
|
||||
...(w.settings ?? {}),
|
||||
};
|
||||
const propsHash = hash(widgetProps, {});
|
||||
return (
|
||||
<div
|
||||
key={l.i}
|
||||
className={`${styles.widgetWrapper} ${editMode && 'edit'}`}
|
||||
>
|
||||
<ErrorBoundary>
|
||||
<widget.component.type key={propsHash} {...widgetProps} />
|
||||
</ErrorBoundary>
|
||||
{editMode && (
|
||||
<WidgetSettingsOverlay
|
||||
id={l.i}
|
||||
widget={widget}
|
||||
handleRemove={handleRemove}
|
||||
handleSettingsSave={handleSettingsSave}
|
||||
settings={w.settings}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</ResponsiveGrid>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,118 @@
|
||||
/*
|
||||
* 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 {
|
||||
createStyles,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
Grid,
|
||||
makeStyles,
|
||||
Theme,
|
||||
Tooltip,
|
||||
} from '@material-ui/core';
|
||||
import IconButton from '@material-ui/core/IconButton';
|
||||
import SettingsIcon from '@material-ui/icons/Settings';
|
||||
import DeleteIcon from '@material-ui/icons/Delete';
|
||||
import React from 'react';
|
||||
import { Widget } from './types';
|
||||
import Form from '@rjsf/material-ui';
|
||||
|
||||
const useStyles = makeStyles((theme: Theme) =>
|
||||
createStyles({
|
||||
iconGrid: {
|
||||
height: '100%',
|
||||
'& *': {
|
||||
padding: 0,
|
||||
},
|
||||
},
|
||||
settingsOverlay: {
|
||||
position: 'absolute',
|
||||
backgroundColor: 'rgba(40, 40, 40, 0.93)',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
top: 0,
|
||||
left: 0,
|
||||
padding: theme.spacing(2),
|
||||
color: 'white',
|
||||
},
|
||||
}),
|
||||
);
|
||||
interface WidgetSettingsOverlayProps {
|
||||
id: string;
|
||||
widget: Widget;
|
||||
handleRemove: (id: string) => void;
|
||||
handleSettingsSave: (id: string, settings: Record<string, any>) => void;
|
||||
settings?: Record<string, any>;
|
||||
}
|
||||
|
||||
export const WidgetSettingsOverlay = (props: WidgetSettingsOverlayProps) => {
|
||||
const { id, widget, settings, handleRemove, handleSettingsSave } = props;
|
||||
const [settingsDialogOpen, setSettingsDialogOpen] = React.useState(false);
|
||||
const styles = useStyles();
|
||||
|
||||
return (
|
||||
<div className={styles.settingsOverlay}>
|
||||
{widget.settingsSchema && (
|
||||
<Dialog
|
||||
open={settingsDialogOpen}
|
||||
className="widgetSettingsDialog"
|
||||
onClose={() => setSettingsDialogOpen(false)}
|
||||
>
|
||||
<DialogContent>
|
||||
<Form
|
||||
showErrorList={false}
|
||||
schema={widget.settingsSchema}
|
||||
noHtml5Validate
|
||||
formData={settings}
|
||||
formContext={{ settings }}
|
||||
onSubmit={({ formData, errors }) => {
|
||||
if (errors.length === 0) {
|
||||
handleSettingsSave(id, formData);
|
||||
setSettingsDialogOpen(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
<Grid
|
||||
container
|
||||
className={styles.iconGrid}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
>
|
||||
{widget.settingsSchema && (
|
||||
<Grid item className="overlayGridItem">
|
||||
<Tooltip title="Edit settings">
|
||||
<IconButton
|
||||
color="primary"
|
||||
onClick={() => setSettingsDialogOpen(true)}
|
||||
>
|
||||
<SettingsIcon fontSize="large" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Grid>
|
||||
)}
|
||||
<Grid item className="overlayGridItem">
|
||||
<Tooltip title="Delete widget">
|
||||
<IconButton color="secondary" onClick={() => handleRemove(id)}>
|
||||
<DeleteIcon fontSize="large" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
export { CustomHomepageGrid } from './CustomHomepageGrid';
|
||||
export type { CustomHomepageGridProps } from './CustomHomepageGrid';
|
||||
export type { LayoutConfiguration } from './types';
|
||||
@@ -0,0 +1,84 @@
|
||||
/*
|
||||
* 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 { ReactElement } from 'react';
|
||||
import { Layout } from 'react-grid-layout';
|
||||
import { z } from 'zod';
|
||||
import { RJSFSchema } from '@rjsf/utils';
|
||||
|
||||
const RSJFTypeSchema: z.ZodType<RJSFSchema> = z.any();
|
||||
const ReactElementSchema: z.ZodType<ReactElement> = z.any();
|
||||
const LayoutSchema: z.ZodType<Layout> = z.any();
|
||||
|
||||
export const LayoutConfigurationSchema = z.object({
|
||||
component: ReactElementSchema,
|
||||
x: z.number().nonnegative('x must be positive number'),
|
||||
y: z.number().nonnegative('y must be positive number'),
|
||||
width: z.number().positive('width must be positive number'),
|
||||
height: z.number().positive('height must be positive number'),
|
||||
});
|
||||
|
||||
/**
|
||||
* Layout configuration that can be passed to the custom home page.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type LayoutConfiguration = {
|
||||
component: ReactElement | string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
export const WidgetSchema = z.object({
|
||||
name: z.string(),
|
||||
title: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
component: ReactElementSchema,
|
||||
width: z.number().positive('width must be positive number').optional(),
|
||||
height: z.number().positive('height must be positive number').optional(),
|
||||
minWidth: z.number().positive('minWidth must be positive number').optional(),
|
||||
maxWidth: z.number().positive('maxWidth must be positive number').optional(),
|
||||
minHeight: z
|
||||
.number()
|
||||
.positive('minHeight must be positive number')
|
||||
.optional(),
|
||||
maxHeight: z
|
||||
.number()
|
||||
.positive('maxHeight must be positive number')
|
||||
.optional(),
|
||||
settingsSchema: RSJFTypeSchema.optional(),
|
||||
});
|
||||
|
||||
export type Widget = z.infer<typeof WidgetSchema>;
|
||||
|
||||
const GridWidgetSchema = z.object({
|
||||
id: z.string(),
|
||||
layout: LayoutSchema,
|
||||
settings: z.record(z.string(), z.any()),
|
||||
});
|
||||
|
||||
export type GridWidget = z.infer<typeof GridWidgetSchema>;
|
||||
|
||||
export const CustomHomepageGridStateV1Schema = z.object({
|
||||
version: z.literal(1),
|
||||
pages: z.record(z.string(), z.array(GridWidgetSchema)),
|
||||
});
|
||||
|
||||
export type CustomHomepageGridStateV1 = z.infer<
|
||||
typeof CustomHomepageGridStateV1Schema
|
||||
>;
|
||||
@@ -16,3 +16,4 @@
|
||||
|
||||
export { HomepageCompositionRoot } from './HomepageCompositionRoot';
|
||||
export { SettingsModal } from './SettingsModal';
|
||||
export * from './CustomHomepage';
|
||||
|
||||
+122
-68
@@ -20,6 +20,7 @@ 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';
|
||||
import { RJSFSchema } from '@rjsf/utils';
|
||||
|
||||
/**
|
||||
* @public
|
||||
@@ -48,6 +49,29 @@ export type RendererProps = { title: string } & ComponentParts;
|
||||
*/
|
||||
export type CardExtensionProps<T> = ComponentRenderer & { title?: string } & T;
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export type CardLayout = {
|
||||
width?: { minColumns?: number; maxColumns?: number; defaultColumns?: number };
|
||||
height?: { minRows?: number; maxRows?: number; defaultRows?: number };
|
||||
};
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export type CardSettings = {
|
||||
schema?: RJSFSchema;
|
||||
};
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export type CardConfig = {
|
||||
layout?: CardLayout;
|
||||
settings?: CardSettings;
|
||||
};
|
||||
|
||||
/**
|
||||
* An extension creator to create card based components for the homepage
|
||||
*
|
||||
@@ -57,84 +81,114 @@ export function createCardExtension<T>(options: {
|
||||
title: string;
|
||||
components: () => Promise<ComponentParts>;
|
||||
name?: string;
|
||||
description?: string;
|
||||
layout?: CardLayout;
|
||||
settings?: CardSettings;
|
||||
}) {
|
||||
const { title, components, name } = options;
|
||||
const { title, components, name, description, layout, settings } = options;
|
||||
// If widget settings schema is defined, we don't want to show the Settings icon or dialog
|
||||
const isCustomizable = settings?.schema !== undefined;
|
||||
|
||||
return createReactExtension({
|
||||
name,
|
||||
data: { title, description, 'home.widget.config': { layout, settings } },
|
||||
component: {
|
||||
lazy: () =>
|
||||
components().then(({ Content, Actions, Settings, ContextProvider }) => {
|
||||
const CardExtension = (props: CardExtensionProps<T>) => {
|
||||
const { Renderer, title: overrideTitle, ...childProps } = props;
|
||||
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 {...childProps} />
|
||||
</InfoCard>
|
||||
);
|
||||
|
||||
components().then(componentParts => {
|
||||
return (props: CardExtensionProps<T>) => {
|
||||
return (
|
||||
<Suspense fallback={<Progress />}>
|
||||
{ContextProvider ? (
|
||||
<ContextProvider {...childProps}>
|
||||
{innerContent}
|
||||
</ContextProvider>
|
||||
) : (
|
||||
innerContent
|
||||
)}
|
||||
</Suspense>
|
||||
<CardExtension
|
||||
{...props}
|
||||
{...componentParts}
|
||||
title={props.title || title}
|
||||
isCustomizable={isCustomizable}
|
||||
/>
|
||||
);
|
||||
};
|
||||
return CardExtension;
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
type CardExtensionComponentProps<T> = CardExtensionProps<T> &
|
||||
ComponentParts & {
|
||||
title: string;
|
||||
isCustomizable?: boolean;
|
||||
overrideTitle?: string;
|
||||
};
|
||||
|
||||
function CardExtension<T>(props: CardExtensionComponentProps<T>) {
|
||||
const {
|
||||
Renderer,
|
||||
Content,
|
||||
Settings,
|
||||
Actions,
|
||||
ContextProvider,
|
||||
isCustomizable,
|
||||
title,
|
||||
...childProps
|
||||
} = props;
|
||||
const app = useApp();
|
||||
const { Progress } = app.getComponents();
|
||||
const [settingsOpen, setSettingsOpen] = React.useState(false);
|
||||
|
||||
if (Renderer) {
|
||||
return (
|
||||
<Suspense fallback={<Progress />}>
|
||||
<Renderer
|
||||
title={title}
|
||||
{...{
|
||||
Content,
|
||||
...(Actions ? { Actions } : {}),
|
||||
...(Settings && !isCustomizable ? { Settings } : {}),
|
||||
...(ContextProvider ? { ContextProvider } : {}),
|
||||
...childProps,
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
const cardProps = {
|
||||
title: title,
|
||||
...(Settings && !isCustomizable
|
||||
? {
|
||||
action: (
|
||||
<IconButton onClick={() => setSettingsOpen(true)}>
|
||||
<SettingsIcon>Settings</SettingsIcon>
|
||||
</IconButton>
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
...(Actions
|
||||
? {
|
||||
actions: <Actions />,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
const innerContent = (
|
||||
<InfoCard {...cardProps}>
|
||||
{Settings && !isCustomizable && (
|
||||
<SettingsModal
|
||||
open={settingsOpen}
|
||||
componentName={title}
|
||||
close={() => setSettingsOpen(false)}
|
||||
>
|
||||
<Settings />
|
||||
</SettingsModal>
|
||||
)}
|
||||
<Content {...childProps} />
|
||||
</InfoCard>
|
||||
);
|
||||
|
||||
return (
|
||||
<Suspense fallback={<Progress />}>
|
||||
{ContextProvider ? (
|
||||
<ContextProvider {...childProps}>{innerContent}</ContextProvider>
|
||||
) : (
|
||||
innerContent
|
||||
)}
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ export {
|
||||
WelcomeTitle,
|
||||
HeaderWorldClock,
|
||||
} from './plugin';
|
||||
export { SettingsModal } from './components';
|
||||
export * from './components';
|
||||
export * from './assets';
|
||||
export * from './homePageComponents';
|
||||
export { createCardExtension } from './extensions';
|
||||
@@ -42,4 +42,6 @@ export type {
|
||||
ComponentParts,
|
||||
ComponentRenderer,
|
||||
RendererProps,
|
||||
CardLayout,
|
||||
CardSettings,
|
||||
} from './extensions';
|
||||
|
||||
@@ -108,6 +108,25 @@ export const HomePageRandomJoke = homePlugin.provide(
|
||||
name: 'HomePageRandomJoke',
|
||||
title: 'Random Joke',
|
||||
components: () => import('./homePageComponents/RandomJoke'),
|
||||
description: 'Shows a random joke about optional category',
|
||||
layout: {
|
||||
height: { minRows: 4 },
|
||||
width: { minColumns: 3 },
|
||||
},
|
||||
settings: {
|
||||
schema: {
|
||||
title: 'Random Joke settings',
|
||||
type: 'object',
|
||||
properties: {
|
||||
defaultCategory: {
|
||||
title: 'Category',
|
||||
type: 'string',
|
||||
enum: ['any', 'programming', 'dad'],
|
||||
default: 'any',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -6993,16 +6993,23 @@ __metadata:
|
||||
"@material-ui/core": ^4.12.2
|
||||
"@material-ui/icons": ^4.9.1
|
||||
"@material-ui/lab": 4.0.0-alpha.61
|
||||
"@rjsf/material-ui": ^3.2.1
|
||||
"@rjsf/utils": 5.6.0
|
||||
"@testing-library/dom": ^8.0.0
|
||||
"@testing-library/jest-dom": ^5.10.1
|
||||
"@testing-library/react": ^12.1.3
|
||||
"@testing-library/user-event": ^14.0.0
|
||||
"@types/node": ^16.11.26
|
||||
"@types/react": ^16.13.1 || ^17.0.0
|
||||
"@types/react-grid-layout": ^1.3.2
|
||||
cross-fetch: ^3.1.5
|
||||
lodash: ^4.17.21
|
||||
msw: ^1.0.0
|
||||
object-hash: ^3.0.0
|
||||
react-grid-layout: ^1.3.4
|
||||
react-resizable: ^3.0.4
|
||||
react-use: ^17.2.4
|
||||
zod: ~3.21.4
|
||||
peerDependencies:
|
||||
react: ^16.13.1 || ^17.0.0
|
||||
react-dom: ^16.13.1 || ^17.0.0
|
||||
@@ -16528,6 +16535,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/react-grid-layout@npm:^1.3.2":
|
||||
version: 1.3.2
|
||||
resolution: "@types/react-grid-layout@npm:1.3.2"
|
||||
dependencies:
|
||||
"@types/react": "*"
|
||||
checksum: 190492acb69186c651bb99f19028dcd4c65129eae7de6efd41f51b6a6711af490e7b99ad7156b8731b11ecf2ec7c22bcf13c782bfe65be4b4e5c3362b97095bf
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/react-helmet@npm:^6.1.0":
|
||||
version: 6.1.6
|
||||
resolution: "@types/react-helmet@npm:6.1.6"
|
||||
@@ -20097,10 +20113,10 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"clsx@npm:^1.0.2, clsx@npm:^1.0.4":
|
||||
version: 1.1.1
|
||||
resolution: "clsx@npm:1.1.1"
|
||||
checksum: ff052650329773b9b245177305fc4c4dc3129f7b2be84af4f58dc5defa99538c61d4207be7419405a5f8f3d92007c954f4daba5a7b74e563d5de71c28c830063
|
||||
"clsx@npm:^1.0.2, clsx@npm:^1.0.4, clsx@npm:^1.1.1":
|
||||
version: 1.2.1
|
||||
resolution: "clsx@npm:1.2.1"
|
||||
checksum: 30befca8019b2eb7dbad38cff6266cf543091dae2825c856a62a8ccf2c3ab9c2907c4d12b288b73101196767f66812365400a227581484a05f968b0307cfaf12
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -29579,7 +29595,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"lodash.isequal@npm:^4.5.0":
|
||||
"lodash.isequal@npm:^4.0.0, lodash.isequal@npm:^4.5.0":
|
||||
version: 4.5.0
|
||||
resolution: "lodash.isequal@npm:4.5.0"
|
||||
checksum: da27515dc5230eb1140ba65ff8de3613649620e8656b19a6270afe4866b7bd461d9ba2ac8a48dcc57f7adac4ee80e1de9f965d89d4d81a0ad52bb3eec2609644
|
||||
@@ -34059,7 +34075,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"prop-types@npm:^15.0.0, prop-types@npm:^15.5.10, prop-types@npm:^15.5.7, prop-types@npm:^15.5.8, prop-types@npm:^15.6.0, prop-types@npm:^15.6.2, prop-types@npm:^15.7.2, prop-types@npm:^15.8.1":
|
||||
"prop-types@npm:15.x, prop-types@npm:^15.0.0, prop-types@npm:^15.5.10, prop-types@npm:^15.5.7, prop-types@npm:^15.5.8, prop-types@npm:^15.6.0, prop-types@npm:^15.6.2, prop-types@npm:^15.7.2, prop-types@npm:^15.8.1":
|
||||
version: 15.8.1
|
||||
resolution: "prop-types@npm:15.8.1"
|
||||
dependencies:
|
||||
@@ -34640,6 +34656,19 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-draggable@npm:^4.0.0, react-draggable@npm:^4.0.3":
|
||||
version: 4.4.5
|
||||
resolution: "react-draggable@npm:4.4.5"
|
||||
dependencies:
|
||||
clsx: ^1.1.1
|
||||
prop-types: ^15.8.1
|
||||
peerDependencies:
|
||||
react: ">= 16.3.0"
|
||||
react-dom: ">= 16.3.0"
|
||||
checksum: 21c3775db086e13020967627c20acd41d1ddbc7c7d7fca51491a5bbb54a0aa7e1730a4bc9af17141eb50a4954e547a5e25b2368f5f54b70db6f2686a897bacf2
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-error-boundary@npm:^3.1.0":
|
||||
version: 3.1.3
|
||||
resolution: "react-error-boundary@npm:3.1.3"
|
||||
@@ -34688,6 +34717,22 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-grid-layout@npm:^1.3.4":
|
||||
version: 1.3.4
|
||||
resolution: "react-grid-layout@npm:1.3.4"
|
||||
dependencies:
|
||||
clsx: ^1.1.1
|
||||
lodash.isequal: ^4.0.0
|
||||
prop-types: ^15.8.1
|
||||
react-draggable: ^4.0.0
|
||||
react-resizable: ^3.0.4
|
||||
peerDependencies:
|
||||
react: ">= 16.3.0"
|
||||
react-dom: ">= 16.3.0"
|
||||
checksum: f56c8c452acd9588edf1dc6996a4ea14d9f669d77f6b2ebd50146eaeeb9325c83f5ca44b66bac2b8c24f9cb2ec7ed49396350435991255c3b31e21b8a2e3d243
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-helmet@npm:6.1.0":
|
||||
version: 6.1.0
|
||||
resolution: "react-helmet@npm:6.1.0"
|
||||
@@ -34856,6 +34901,18 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-resizable@npm:^3.0.4":
|
||||
version: 3.0.4
|
||||
resolution: "react-resizable@npm:3.0.4"
|
||||
dependencies:
|
||||
prop-types: 15.x
|
||||
react-draggable: ^4.0.3
|
||||
peerDependencies:
|
||||
react: ">= 16.3"
|
||||
checksum: cbf86ad04be0f073102489ad25a2ba101779f0f00e580d48e1be73c4057c36a4e8ee8308b020ad3dd91555bb24082ceeef674d5035a38d33a2d43aed192db7fa
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-resize-detector@npm:^8.0.4":
|
||||
version: 8.0.4
|
||||
resolution: "react-resize-detector@npm:8.0.4"
|
||||
@@ -40997,7 +41054,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"zod@npm:^3.21.4":
|
||||
"zod@npm:^3.21.4, zod@npm:~3.21.4":
|
||||
version: 3.21.4
|
||||
resolution: "zod@npm:3.21.4"
|
||||
checksum: f185ba87342ff16f7a06686767c2b2a7af41110c7edf7c1974095d8db7a73792696bcb4a00853de0d2edeb34a5b2ea6a55871bc864227dace682a0a28de33e1f
|
||||
|
||||
Reference in New Issue
Block a user