;
+}
+
+export const WidgetSettingsOverlay = (props: WidgetSettingsOverlayProps) => {
+ const { id, widget, settings, handleRemove, handleSettingsSave } = props;
+ const [settingsDialogOpen, setSettingsDialogOpen] = React.useState(false);
+ const styles = useStyles();
+
+ return (
+
+ {widget.settingsSchema && (
+
+ )}
+
+ {widget.settingsSchema && (
+
+
+ setSettingsDialogOpen(true)}
+ >
+
+
+
+
+ )}
+
+
+ handleRemove(id)}>
+
+
+
+
+
+
+ );
+};
diff --git a/plugins/home/src/components/CustomHomepage/index.ts b/plugins/home/src/components/CustomHomepage/index.ts
new file mode 100644
index 0000000000..a710d1371f
--- /dev/null
+++ b/plugins/home/src/components/CustomHomepage/index.ts
@@ -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';
diff --git a/plugins/home/src/components/CustomHomepage/types.ts b/plugins/home/src/components/CustomHomepage/types.ts
new file mode 100644
index 0000000000..8cdcc337b3
--- /dev/null
+++ b/plugins/home/src/components/CustomHomepage/types.ts
@@ -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 = z.any();
+const ReactElementSchema: z.ZodType = z.any();
+const LayoutSchema: z.ZodType = 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;
+
+const GridWidgetSchema = z.object({
+ id: z.string(),
+ layout: LayoutSchema,
+ settings: z.record(z.string(), z.any()),
+});
+
+export type GridWidget = z.infer;
+
+export const CustomHomepageGridStateV1Schema = z.object({
+ version: z.literal(1),
+ pages: z.record(z.string(), z.array(GridWidgetSchema)),
+});
+
+export type CustomHomepageGridStateV1 = z.infer<
+ typeof CustomHomepageGridStateV1Schema
+>;
diff --git a/plugins/home/src/components/index.ts b/plugins/home/src/components/index.ts
index aae0cbe26d..e06013d3fa 100644
--- a/plugins/home/src/components/index.ts
+++ b/plugins/home/src/components/index.ts
@@ -16,3 +16,4 @@
export { HomepageCompositionRoot } from './HomepageCompositionRoot';
export { SettingsModal } from './SettingsModal';
+export * from './CustomHomepage';
diff --git a/plugins/home/src/extensions.tsx b/plugins/home/src/extensions.tsx
index af5dbee3ed..380ff6713a 100644
--- a/plugins/home/src/extensions.tsx
+++ b/plugins/home/src/extensions.tsx
@@ -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 = 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(options: {
title: string;
components: () => Promise;
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) => {
- const { Renderer, title: overrideTitle, ...childProps } = props;
- const app = useApp();
- const { Progress } = app.getComponents();
- const [settingsOpen, setSettingsOpen] = React.useState(false);
-
- if (Renderer) {
- return (
- }>
-
-
- );
- }
-
- const cardProps = {
- title: overrideTitle ?? title,
- ...(Settings
- ? {
- action: (
- setSettingsOpen(true)}>
- Settings
-
- ),
- }
- : {}),
- ...(Actions
- ? {
- actions: ,
- }
- : {}),
- };
-
- const innerContent = (
-
- {Settings && (
- setSettingsOpen(false)}
- >
-
-
- )}
-
-
- );
-
+ components().then(componentParts => {
+ return (props: CardExtensionProps) => {
return (
- }>
- {ContextProvider ? (
-
- {innerContent}
-
- ) : (
- innerContent
- )}
-
+
);
};
- return CardExtension;
}),
},
});
}
+
+type CardExtensionComponentProps = CardExtensionProps &
+ ComponentParts & {
+ title: string;
+ isCustomizable?: boolean;
+ overrideTitle?: string;
+ };
+
+function CardExtension(props: CardExtensionComponentProps) {
+ 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 (
+ }>
+
+
+ );
+ }
+
+ const cardProps = {
+ title: title,
+ ...(Settings && !isCustomizable
+ ? {
+ action: (
+ setSettingsOpen(true)}>
+ Settings
+
+ ),
+ }
+ : {}),
+ ...(Actions
+ ? {
+ actions: ,
+ }
+ : {}),
+ };
+
+ const innerContent = (
+
+ {Settings && !isCustomizable && (
+ setSettingsOpen(false)}
+ >
+
+
+ )}
+
+
+ );
+
+ return (
+ }>
+ {ContextProvider ? (
+ {innerContent}
+ ) : (
+ innerContent
+ )}
+
+ );
+}
diff --git a/plugins/home/src/index.ts b/plugins/home/src/index.ts
index 83eb72b613..daa781f02c 100644
--- a/plugins/home/src/index.ts
+++ b/plugins/home/src/index.ts
@@ -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';
diff --git a/plugins/home/src/plugin.ts b/plugins/home/src/plugin.ts
index 5ae72ae510..339e6dba2b 100644
--- a/plugins/home/src/plugin.ts
+++ b/plugins/home/src/plugin.ts
@@ -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',
+ },
+ },
+ },
+ },
}),
);
diff --git a/yarn.lock b/yarn.lock
index 048565f9cb..fd73238e32 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -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