diff --git a/app/portainer/components/Header/HeaderContent.module.css b/app/portainer/components/PageHeader/HeaderContent.module.css
similarity index 100%
rename from app/portainer/components/Header/HeaderContent.module.css
rename to app/portainer/components/PageHeader/HeaderContent.module.css
diff --git a/app/portainer/components/PageHeader/HeaderContent.test.tsx b/app/portainer/components/PageHeader/HeaderContent.test.tsx
new file mode 100644
index 000000000..c1bf7fab1
--- /dev/null
+++ b/app/portainer/components/PageHeader/HeaderContent.test.tsx
@@ -0,0 +1,35 @@
+import { UserContext } from '@/portainer/hooks/useUser';
+import { UserViewModel } from '@/portainer/models/user';
+import { render } from '@/react-tools/test-utils';
+
+import { HeaderContainer } from './HeaderContainer';
+import { HeaderContent } from './HeaderContent';
+
+test('should not render without a wrapping HeaderContainer', async () => {
+ function renderComponent() {
+ return render(
);
+ }
+
+ expect(renderComponent).toThrowErrorMatchingSnapshot();
+});
+
+test('should display a HeaderContent', async () => {
+ const username = 'username';
+ const user = new UserViewModel({ Username: username });
+ const userProviderState = { user };
+ const content = 'content';
+
+ const { queryByText } = render(
+
+
+ {content}
+
+
+ );
+
+ const contentElement = queryByText(content);
+ expect(contentElement).toBeVisible();
+
+ expect(queryByText('my account')).toBeVisible();
+ expect(queryByText('log out')).toBeVisible();
+});
diff --git a/app/portainer/components/Header/HeaderContent.tsx b/app/portainer/components/PageHeader/HeaderContent.tsx
similarity index 96%
rename from app/portainer/components/Header/HeaderContent.tsx
rename to app/portainer/components/PageHeader/HeaderContent.tsx
index c8cc535d3..18b6d9142 100644
--- a/app/portainer/components/Header/HeaderContent.tsx
+++ b/app/portainer/components/PageHeader/HeaderContent.tsx
@@ -6,7 +6,7 @@ import { useUser } from '@/portainer/hooks/useUser';
import controller from './HeaderContent.controller';
import styles from './HeaderContent.module.css';
-import { useHeaderContext } from './Header';
+import { useHeaderContext } from './HeaderContainer';
export function HeaderContent({ children }: PropsWithChildren
) {
useHeaderContext();
diff --git a/app/portainer/components/Header/HeaderTitle.controller.js b/app/portainer/components/PageHeader/HeaderTitle.controller.js
similarity index 100%
rename from app/portainer/components/Header/HeaderTitle.controller.js
rename to app/portainer/components/PageHeader/HeaderTitle.controller.js
diff --git a/app/portainer/components/Header/HeaderTitle.html b/app/portainer/components/PageHeader/HeaderTitle.html
similarity index 100%
rename from app/portainer/components/Header/HeaderTitle.html
rename to app/portainer/components/PageHeader/HeaderTitle.html
diff --git a/app/portainer/components/PageHeader/HeaderTitle.test.tsx b/app/portainer/components/PageHeader/HeaderTitle.test.tsx
new file mode 100644
index 000000000..e021f1f44
--- /dev/null
+++ b/app/portainer/components/PageHeader/HeaderTitle.test.tsx
@@ -0,0 +1,34 @@
+import { UserContext } from '@/portainer/hooks/useUser';
+import { UserViewModel } from '@/portainer/models/user';
+import { render } from '@/react-tools/test-utils';
+
+import { HeaderContainer } from './HeaderContainer';
+import { HeaderTitle } from './HeaderTitle';
+
+test('should not render without a wrapping HeaderContainer', async () => {
+ const title = 'title';
+ function renderComponent() {
+ return render();
+ }
+
+ expect(renderComponent).toThrowErrorMatchingSnapshot();
+});
+
+test('should display a HeaderTitle', async () => {
+ const username = 'username';
+ const user = new UserViewModel({ Username: username });
+
+ const title = 'title';
+ const { queryByText } = render(
+
+
+
+
+
+ );
+
+ const heading = queryByText(title);
+ expect(heading).toBeVisible();
+
+ expect(queryByText(username)).toBeVisible();
+});
diff --git a/app/portainer/components/Header/HeaderTitle.tsx b/app/portainer/components/PageHeader/HeaderTitle.tsx
similarity index 93%
rename from app/portainer/components/Header/HeaderTitle.tsx
rename to app/portainer/components/PageHeader/HeaderTitle.tsx
index 8ece359fa..8a99f8820 100644
--- a/app/portainer/components/Header/HeaderTitle.tsx
+++ b/app/portainer/components/PageHeader/HeaderTitle.tsx
@@ -2,7 +2,7 @@ import { PropsWithChildren } from 'react';
import { useUser } from '@/portainer/hooks/useUser';
-import { useHeaderContext } from './Header';
+import { useHeaderContext } from './HeaderContainer';
import controller from './HeaderTitle.controller';
interface Props {
diff --git a/app/portainer/components/PageHeader/PageHeader.stories.tsx b/app/portainer/components/PageHeader/PageHeader.stories.tsx
new file mode 100644
index 000000000..b36e448e9
--- /dev/null
+++ b/app/portainer/components/PageHeader/PageHeader.stories.tsx
@@ -0,0 +1,42 @@
+import { Meta, Story } from '@storybook/react';
+import { useMemo } from 'react';
+
+import { UserContext } from '@/portainer/hooks/useUser';
+import { UserViewModel } from '@/portainer/models/user';
+
+import { PageHeader } from './PageHeader';
+
+export default {
+ component: PageHeader,
+ title: 'Components/PageHeader',
+} as Meta;
+
+interface StoryProps {
+ title: string;
+}
+
+function Template({ title }: StoryProps) {
+ const state = useMemo(
+ () => ({ user: new UserViewModel({ Username: 'test' }) }),
+ []
+ );
+
+ return (
+
+
+
+ );
+}
+
+export const Primary: Story = Template.bind({});
+Primary.args = {
+ title: 'Container details',
+};
diff --git a/app/portainer/components/PageHeader/PageHeader.test.tsx b/app/portainer/components/PageHeader/PageHeader.test.tsx
new file mode 100644
index 000000000..65674006a
--- /dev/null
+++ b/app/portainer/components/PageHeader/PageHeader.test.tsx
@@ -0,0 +1,22 @@
+import { UserContext } from '@/portainer/hooks/useUser';
+import { UserViewModel } from '@/portainer/models/user';
+import { render } from '@/react-tools/test-utils';
+
+import { PageHeader } from './PageHeader';
+
+test('should display a PageHeader', async () => {
+ const username = 'username';
+ const user = new UserViewModel({ Username: username });
+
+ const title = 'title';
+ const { queryByText } = render(
+
+
+
+ );
+
+ const heading = queryByText(title);
+ expect(heading).toBeVisible();
+
+ expect(queryByText(username)).toBeVisible();
+});
diff --git a/app/portainer/components/PageHeader/PageHeader.tsx b/app/portainer/components/PageHeader/PageHeader.tsx
new file mode 100644
index 000000000..8818a5e3d
--- /dev/null
+++ b/app/portainer/components/PageHeader/PageHeader.tsx
@@ -0,0 +1,33 @@
+import { useRouter } from '@uirouter/react';
+
+import { Button } from '../Button';
+
+import { Breadcrumbs } from './Breadcrumbs';
+import { Crumb } from './Breadcrumbs/Breadcrumbs';
+import { HeaderContainer } from './HeaderContainer';
+import { HeaderContent } from './HeaderContent';
+import { HeaderTitle } from './HeaderTitle';
+
+interface Props {
+ reload?: boolean;
+ breadcrumbs?: Crumb[];
+ title: string;
+}
+
+export function PageHeader({ title, breadcrumbs = [], reload }: Props) {
+ const router = useRouter();
+ return (
+
+
+ {reload && (
+
+ )}
+
+
+
+
+
+ );
+}
diff --git a/app/portainer/components/PageHeader/__snapshots__/HeaderContent.test.tsx.snap b/app/portainer/components/PageHeader/__snapshots__/HeaderContent.test.tsx.snap
new file mode 100644
index 000000000..db9beee67
--- /dev/null
+++ b/app/portainer/components/PageHeader/__snapshots__/HeaderContent.test.tsx.snap
@@ -0,0 +1,3 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should not render without a wrapping HeaderContainer 1`] = `"Should be nested inside a HeaderContainer component"`;
diff --git a/app/portainer/components/PageHeader/__snapshots__/HeaderTitle.test.tsx.snap b/app/portainer/components/PageHeader/__snapshots__/HeaderTitle.test.tsx.snap
new file mode 100644
index 000000000..db9beee67
--- /dev/null
+++ b/app/portainer/components/PageHeader/__snapshots__/HeaderTitle.test.tsx.snap
@@ -0,0 +1,3 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should not render without a wrapping HeaderContainer 1`] = `"Should be nested inside a HeaderContainer component"`;
diff --git a/app/portainer/components/Header/index.ts b/app/portainer/components/PageHeader/index.ts
similarity index 62%
rename from app/portainer/components/Header/index.ts
rename to app/portainer/components/PageHeader/index.ts
index 3dc1997ee..73071d4f9 100644
--- a/app/portainer/components/Header/index.ts
+++ b/app/portainer/components/PageHeader/index.ts
@@ -1,10 +1,12 @@
import angular from 'angular';
-import { Header, HeaderAngular } from './Header';
+import { Breadcrumbs } from './Breadcrumbs';
+import { PageHeader } from './PageHeader';
+import { HeaderContainer, HeaderAngular } from './HeaderContainer';
import { HeaderContent, HeaderContentAngular } from './HeaderContent';
import { HeaderTitle, HeaderTitleAngular } from './HeaderTitle';
-export { Header, HeaderTitle, HeaderContent };
+export { PageHeader, Breadcrumbs, HeaderContainer, HeaderContent, HeaderTitle };
export default angular
.module('portainer.app.components.header', [])
diff --git a/app/portainer/components/index.js b/app/portainer/components/index.js
index e4882a8d7..3f9889f8c 100644
--- a/app/portainer/components/index.js
+++ b/app/portainer/components/index.js
@@ -8,7 +8,7 @@ import porAccessManagementModule from './accessManagement';
import formComponentsModule from './form-components';
import widgetModule from './widget';
import boxSelectorModule from './BoxSelector';
-import headerModule from './Header';
+import headerModule from './PageHeader';
import { ReactExampleAngular } from './ReactExample';
import { TooltipAngular } from './Tip/Tooltip';
diff --git a/package.json b/package.json
index b49d4aeb2..41be9df38 100644
--- a/package.json
+++ b/package.json
@@ -42,7 +42,7 @@
"lint:pr": "make lint-pr",
"test": "yarn test:client; yarn test:server",
"test:server": "cd api && go test ./...",
- "test:client": "jest",
+ "test:client": "jest --silent",
"storybook": "start-storybook -p 6006",
"build-storybook": "build-storybook -o ./dist/storybook",
"analyze-webpack": "webpack --config ./webpack/webpack.analyze.js"
@@ -239,4 +239,4 @@
"pre-commit": "lint-staged"
}
}
-}
+}
\ No newline at end of file