diff --git a/app/assets/css/app.css b/app/assets/css/app.css
index 95cdaeb73..e84e4cd53 100644
--- a/app/assets/css/app.css
+++ b/app/assets/css/app.css
@@ -307,7 +307,6 @@ a[ng-click] {
.custom-header-ico {
max-width: 32px;
max-height: 32px;
- margin-right: 2px;
}
.btn-responsive {
diff --git a/app/portainer/components/index.js b/app/portainer/components/index.js
index a6da97272..a437ec0e2 100644
--- a/app/portainer/components/index.js
+++ b/app/portainer/components/index.js
@@ -4,11 +4,12 @@ import sidebarModule from './sidebar';
import gitFormModule from './forms/git-form';
import porAccessManagementModule from './accessManagement';
import formComponentsModule from './form-components';
+import widgetModule from './widget';
import { ReactExampleAngular } from './ReactExample';
import { TooltipAngular } from './Tooltip';
export default angular
- .module('portainer.app.components', [sidebarModule, gitFormModule, porAccessManagementModule, formComponentsModule])
+ .module('portainer.app.components', [widgetModule, sidebarModule, gitFormModule, porAccessManagementModule, formComponentsModule])
.component('portainerTooltip', TooltipAngular)
.component('reactExample', ReactExampleAngular).name;
diff --git a/app/portainer/components/loading.js b/app/portainer/components/loading.js
deleted file mode 100644
index b112eaa06..000000000
--- a/app/portainer/components/loading.js
+++ /dev/null
@@ -1,7 +0,0 @@
-angular.module('portainer.app').directive('rdLoading', function rdLoading() {
- var directive = {
- restrict: 'AE',
- template: '
',
- };
- return directive;
-});
diff --git a/app/portainer/components/widget-body.js b/app/portainer/components/widget-body.js
deleted file mode 100644
index d4ba7be93..000000000
--- a/app/portainer/components/widget-body.js
+++ /dev/null
@@ -1,13 +0,0 @@
-angular.module('portainer.app').directive('rdWidgetBody', function rdWidgetBody() {
- var directive = {
- requires: '^rdWidget',
- scope: {
- loading: '@?',
- classes: '@?',
- },
- transclude: true,
- template: '',
- restrict: 'E',
- };
- return directive;
-});
diff --git a/app/portainer/components/widget-custom-header.js b/app/portainer/components/widget-custom-header.js
deleted file mode 100644
index d6c32e5a1..000000000
--- a/app/portainer/components/widget-custom-header.js
+++ /dev/null
@@ -1,14 +0,0 @@
-angular.module('portainer.app').directive('rdWidgetCustomHeader', function rdWidgetCustomHeader() {
- var directive = {
- requires: '^rdWidget',
- scope: {
- titleText: '=',
- icon: '=',
- },
- transclude: true,
- template:
- '',
- restrict: 'E',
- };
- return directive;
-});
diff --git a/app/portainer/components/widget-footer.js b/app/portainer/components/widget-footer.js
deleted file mode 100644
index cba9ff444..000000000
--- a/app/portainer/components/widget-footer.js
+++ /dev/null
@@ -1,9 +0,0 @@
-angular.module('portainer.app').directive('rdWidgetFooter', function rdWidgetFooter() {
- var directive = {
- requires: '^rdWidget',
- transclude: true,
- template: '',
- restrict: 'E',
- };
- return directive;
-});
diff --git a/app/portainer/components/widget-header.js b/app/portainer/components/widget-header.js
deleted file mode 100644
index 9eb50e500..000000000
--- a/app/portainer/components/widget-header.js
+++ /dev/null
@@ -1,26 +0,0 @@
-angular.module('portainer.app').directive('rdWidgetHeader', function rdWidgetTitle() {
- var directive = {
- requires: '^rdWidget',
- scope: {
- titleText: '@',
- icon: '@',
- classes: '@?',
- },
- transclude: {
- title: '?headerTitle',
- },
- template: `
-
-`,
- restrict: 'E',
- };
- return directive;
-});
diff --git a/app/portainer/components/widget-taskbar.js b/app/portainer/components/widget-taskbar.js
deleted file mode 100644
index 3f34d30c0..000000000
--- a/app/portainer/components/widget-taskbar.js
+++ /dev/null
@@ -1,12 +0,0 @@
-angular.module('portainer.app').directive('rdWidgetTaskbar', function rdWidgetTaskbar() {
- var directive = {
- requires: '^rdWidget',
- scope: {
- classes: '@?',
- },
- transclude: true,
- template: '',
- restrict: 'E',
- };
- return directive;
-});
diff --git a/app/portainer/components/widget.js b/app/portainer/components/widget.js
deleted file mode 100644
index e81f53153..000000000
--- a/app/portainer/components/widget.js
+++ /dev/null
@@ -1,11 +0,0 @@
-angular.module('portainer.app').directive('rdWidget', function rdWidget() {
- var directive = {
- scope: {
- ngModel: '=',
- },
- transclude: true,
- template: '',
- restrict: 'EA',
- };
- return directive;
-});
diff --git a/app/portainer/components/widget/Loading.tsx b/app/portainer/components/widget/Loading.tsx
new file mode 100644
index 000000000..d7f04d072
--- /dev/null
+++ b/app/portainer/components/widget/Loading.tsx
@@ -0,0 +1,12 @@
+import { react2angular } from '@/react-tools/react2angular';
+
+export function Loading() {
+ return (
+
+ );
+}
+
+export const LoadingAngular = react2angular(Loading, []);
diff --git a/app/portainer/components/widget/Widget.stories.tsx b/app/portainer/components/widget/Widget.stories.tsx
new file mode 100644
index 000000000..534c4a105
--- /dev/null
+++ b/app/portainer/components/widget/Widget.stories.tsx
@@ -0,0 +1,95 @@
+import type { Meta } from '@storybook/react';
+
+import { Widget } from './Widget';
+import { WidgetBody } from './WidgetBody';
+import { WidgetTitle } from './WidgetTitle';
+import { WidgetFooter } from './WidgetFooter';
+import { WidgetTaskbar } from './WidgetTaskbar';
+
+interface WidgetProps {
+ loading: boolean;
+ title: string;
+ icon: string;
+ bodyText: string;
+ footerText: string;
+}
+
+const meta: Meta = {
+ title: 'Widget',
+ component: Widget,
+ args: {
+ loading: false,
+ title: 'Title',
+ icon: 'fa-rocket',
+ bodyText: 'Body',
+ footerText: 'Footer',
+ },
+};
+
+export default meta;
+
+export function Default({
+ loading,
+ bodyText,
+ footerText,
+ icon,
+ title,
+}: WidgetProps) {
+ return (
+
+
+ {bodyText}
+ {footerText}
+
+ );
+}
+
+export function WidgetWithCustomImage({
+ loading,
+ bodyText,
+ footerText,
+ icon,
+ title,
+}: WidgetProps) {
+ return (
+
+
+ }
+ />
+ {bodyText}
+ {footerText}
+
+ );
+}
+
+WidgetWithCustomImage.args = {
+ icon: 'https://via.placeholder.com/150',
+};
+
+export function WidgetWithTaskBar({
+ loading,
+ bodyText,
+ footerText,
+ icon,
+ title,
+}: WidgetProps) {
+ return (
+
+
+
+
+
+ {bodyText}
+ {footerText}
+
+ );
+}
diff --git a/app/portainer/components/widget/Widget.tsx b/app/portainer/components/widget/Widget.tsx
new file mode 100644
index 000000000..ee069851f
--- /dev/null
+++ b/app/portainer/components/widget/Widget.tsx
@@ -0,0 +1,24 @@
+import { createContext, PropsWithChildren, useContext } from 'react';
+
+const Context = createContext(null);
+
+export function useWidgetContext() {
+ const context = useContext(Context);
+
+ if (context == null) {
+ throw new Error('Should be inside a Widget component');
+ }
+}
+
+export const rdWidget = {
+ transclude: true,
+ template: ``,
+};
+
+export function Widget({ children }: PropsWithChildren) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/app/portainer/components/widget/WidgetBody.tsx b/app/portainer/components/widget/WidgetBody.tsx
new file mode 100644
index 000000000..4926ddf48
--- /dev/null
+++ b/app/portainer/components/widget/WidgetBody.tsx
@@ -0,0 +1,39 @@
+import clsx from 'clsx';
+import { PropsWithChildren } from 'react';
+
+import { useWidgetContext } from './Widget';
+import { Loading } from './Loading';
+
+export const rdWidgetBody = {
+ requires: '^rdWidget',
+ bindings: {
+ loading: '@?',
+ classes: '@?',
+ },
+ transclude: true,
+ template: `
+
+ `,
+};
+
+interface Props {
+ loading?: boolean;
+ className?: string;
+}
+
+export function WidgetBody({
+ loading,
+ className,
+ children,
+}: PropsWithChildren) {
+ useWidgetContext();
+
+ return (
+
+ {loading ?
:
{children}
}
+
+ );
+}
diff --git a/app/portainer/components/widget/WidgetCustomHeader.tsx b/app/portainer/components/widget/WidgetCustomHeader.tsx
new file mode 100644
index 000000000..f124fc2bb
--- /dev/null
+++ b/app/portainer/components/widget/WidgetCustomHeader.tsx
@@ -0,0 +1,22 @@
+export const rdWidgetCustomHeader = {
+ requires: '^rdWidget',
+ bindings: {
+ titleText: '=',
+ icon: '=',
+ },
+ transclude: true,
+ template: `
+
+ `,
+};
+
+// a react component wasn't created because WidgetTitle were adjusted to support a custom image
diff --git a/app/portainer/components/widget/WidgetFooter.tsx b/app/portainer/components/widget/WidgetFooter.tsx
new file mode 100644
index 000000000..8f4f2e71b
--- /dev/null
+++ b/app/portainer/components/widget/WidgetFooter.tsx
@@ -0,0 +1,17 @@
+import { PropsWithChildren } from 'react';
+
+import { useWidgetContext } from './Widget';
+
+export const rdWidgetFooter = {
+ requires: '^rdWidget',
+ transclude: true,
+ template: `
+
+ `,
+};
+
+export function WidgetFooter({ children }: PropsWithChildren) {
+ useWidgetContext();
+
+ return {children}
;
+}
diff --git a/app/portainer/components/widget/WidgetTaskbar.tsx b/app/portainer/components/widget/WidgetTaskbar.tsx
new file mode 100644
index 000000000..f24341acf
--- /dev/null
+++ b/app/portainer/components/widget/WidgetTaskbar.tsx
@@ -0,0 +1,37 @@
+import { PropsWithChildren } from 'react';
+
+import { useWidgetContext } from './Widget';
+
+export const rdWidgetTaskbar = {
+ requires: '^rdWidget',
+ bindings: {
+ classes: '@?',
+ },
+ transclude: true,
+ template: `
+
+ `,
+};
+
+interface Props {
+ className?: string;
+}
+
+export function WidgetTaskbar({
+ children,
+ className,
+}: PropsWithChildren) {
+ useWidgetContext();
+
+ return (
+
+ );
+}
diff --git a/app/portainer/components/widget/WidgetTitle.tsx b/app/portainer/components/widget/WidgetTitle.tsx
new file mode 100644
index 000000000..30f9b7661
--- /dev/null
+++ b/app/portainer/components/widget/WidgetTitle.tsx
@@ -0,0 +1,54 @@
+import clsx from 'clsx';
+import { PropsWithChildren, ReactNode } from 'react';
+
+import { useWidgetContext } from './Widget';
+
+export const rdWidgetTitle = {
+ requires: '^rdWidget',
+ bindings: {
+ titleText: '@',
+ icon: '@',
+ classes: '@?',
+ },
+ transclude: {
+ title: '?headerTitle',
+ },
+ template: `
+
+`,
+};
+
+interface Props {
+ title: ReactNode;
+ icon: ReactNode;
+ className?: string;
+}
+
+export function WidgetTitle({
+ title,
+ icon,
+ className,
+ children,
+}: PropsWithChildren) {
+ useWidgetContext();
+
+ return (
+
+
+
+ {typeof icon === 'string' ? : icon}
+ {title}
+
+ {children}
+
+
+ );
+}
diff --git a/app/portainer/components/widget/index.ts b/app/portainer/components/widget/index.ts
new file mode 100644
index 000000000..2b32cb47c
--- /dev/null
+++ b/app/portainer/components/widget/index.ts
@@ -0,0 +1,21 @@
+import angular from 'angular';
+
+import { LoadingAngular } from './Loading';
+import { rdWidget, Widget } from './Widget';
+import { rdWidgetBody, WidgetBody } from './WidgetBody';
+import { rdWidgetCustomHeader } from './WidgetCustomHeader';
+import { rdWidgetFooter, WidgetFooter } from './WidgetFooter';
+import { rdWidgetTitle, WidgetTitle } from './WidgetTitle';
+import { rdWidgetTaskbar, WidgetTaskbar } from './WidgetTaskbar';
+
+export { Widget, WidgetBody, WidgetFooter, WidgetTitle, WidgetTaskbar };
+
+export default angular
+ .module('portainer.shared.components.widget', [])
+ .component('rdLoading', LoadingAngular)
+ .component('rdWidget', rdWidget)
+ .component('rdWidgetBody', rdWidgetBody)
+ .component('rdWidgetCustomHeader', rdWidgetCustomHeader)
+ .component('rdWidgetFooter', rdWidgetFooter)
+ .component('rdWidgetHeader', rdWidgetTitle)
+ .component('rdWidgetTaskbar', rdWidgetTaskbar).name;