Skip to content

Commit 9c04b57

Browse files
committed
Add 404 pages
1 parent f4e27de commit 9c04b57

File tree

15 files changed

+271
-180
lines changed

15 files changed

+271
-180
lines changed

public/locales/en.json

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@
55
"learnMore": "Learn more in our documentation",
66
"signInButton": "Sign in"
77
},
8+
"Entities": {
9+
"ManagedControlPlane": "Managed Control Plane",
10+
"Project": "Project"
11+
},
812
"ComponentList": {
913
"tableComponentHeader": "Name",
1014
"tableVersionHeader": "Version"
@@ -87,6 +91,11 @@
8791
"subtitleMessage": "Get started by creating your first Managed Control Plane.",
8892
"helpButton": "Help"
8993
},
94+
"NotFoundBanner": {
95+
"titleMessage": "{{entityType}} not found",
96+
"subtitleMessage": "Sorry, we couldn’t find what you are looking for.<br />The link may be incorrect or the {{entityType}} might have been removed.",
97+
"navigateHome": "Back to Homepage"
98+
},
9099
"IntelligentBreadcrumbs": {
91100
"homeLabel": "Home"
92101
},
@@ -151,7 +160,7 @@
151160
"EditMembers": {
152161
"addButton": "Add"
153162
},
154-
"ControlPlaneListView": {
163+
"ProjectsPage": {
155164
"header": "Your instances of <span>ManagedControlPlane</span>",
156165
"projectHeader": "Project:"
157166
},
@@ -161,7 +170,7 @@
161170
"deleteProject": "Delete project",
162171
"deleteConfirmationDialog": "Project deleted"
163172
},
164-
"ControlPlaneView": {
173+
"McpPage": {
165174
"accessError": "Managed Control Plane does not have access information yet",
166175
"componentsTitle": "Components",
167176
"crossplaneTitle": "Crossplane",

src/AppRouter.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import { HashRouter as Router, Navigate, Route } from 'react-router-dom';
2-
import ControlPlaneView from './views/ControlPlanes/ControlPlaneView.tsx';
32
import ProjectListView from './views/ProjectList';
4-
import ControlPlaneListView from './views/ControlPlanes/ControlPlaneListView.tsx';
53
import GlobalProviderOutlet from './components/Core/ApiConfigWrapper.tsx';
64
import { ShellBarComponent } from './components/Core/ShellBar.tsx';
75
import { SentryRoutes } from './mount.ts';
6+
import ProjectPage from './spaces/onboarding/pages/ProjectPage.tsx';
7+
import McpPage from './spaces/mcp/pages/McpPage.tsx';
88

99
function AppRouter() {
1010
return (
@@ -14,13 +14,14 @@ function AppRouter() {
1414
<SentryRoutes>
1515
<Route path="/mcp" element={<GlobalProviderOutlet />}>
1616
<Route path="projects" element={<ProjectListView />} />
17-
<Route path="projects/:projectName" element={<ControlPlaneListView />} />
17+
<Route path="projects/:projectName" element={<ProjectPage />} />
1818
<Route
1919
path="projects/:projectName/workspaces/:workspaceName/mcps/:controlPlaneName/context/:contextName"
20-
element={<ControlPlaneView />}
20+
element={<McpPage />}
2121
/>
2222
</Route>
2323
<Route path="/" element={<Navigate to="/mcp/projects" />} />
24+
<Route path="*" element={<Navigate to="/" />} />
2425
</SentryRoutes>
2526
</Router>
2627
</>

src/components/ControlPlanes/List/ControlPlaneListAllWorkspaces.tsx

Lines changed: 6 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,31 @@
11
import { Button, FlexBox, IllustratedMessage } from '@ui5/webcomponents-react';
2-
import IllustratedError from '../../Shared/IllustratedError.tsx';
32
import '@ui5/webcomponents-fiori/dist/illustrations/NoData.js';
43
import '@ui5/webcomponents-fiori/dist/illustrations/EmptyList.js';
54
import '@ui5/webcomponents-icons/dist/delete';
6-
import Loading from '../../Shared/Loading.tsx';
75
import ButtonDesign from '@ui5/webcomponents/dist/types/ButtonDesign.js';
86
import { ControlPlaneListWorkspaceGridTile } from './ControlPlaneListWorkspaceGridTile.tsx';
9-
import { useApiResource } from '../../../lib/api/useApiResource.ts';
10-
import { ListWorkspaces } from '../../../lib/api/types/crate/listWorkspaces.ts';
7+
import { ListWorkspacesType } from '../../../lib/api/types/crate/listWorkspaces.ts';
118
import { useLink } from '../../../lib/shared/useLink.ts';
129
import { useTranslation } from 'react-i18next';
1310

1411
interface Props {
1512
projectName: string;
13+
workspaces: ListWorkspacesType[];
1614
}
1715

18-
export default function ControlPlaneListAllWorkspaces({ projectName }: Props) {
16+
export default function ControlPlaneListAllWorkspaces({ projectName, workspaces }: Props) {
1917
const { workspaceCreationGuide } = useLink();
20-
const { data: allWorkspaces, error } = useApiResource(
21-
ListWorkspaces(projectName),
22-
);
2318

2419
const { t } = useTranslation();
2520

26-
if (!allWorkspaces) {
27-
return <Loading />;
28-
}
29-
if (error) {
30-
return <IllustratedError details={error.message} />;
31-
}
32-
3321
return (
3422
<>
35-
{allWorkspaces.length === 0 ? (
23+
{workspaces.length === 0 ? (
3624
<FlexBox direction="Column" alignItems="Center">
3725
<IllustratedMessage
3826
name="EmptyList"
3927
titleText={t('ControlPlaneListAllWorkspaces.emptyListTitleMessage')}
40-
subtitleText={t(
41-
'ControlPlaneListAllWorkspaces.emptyListSubtitleMessage',
42-
)}
28+
subtitleText={t('ControlPlaneListAllWorkspaces.emptyListSubtitleMessage')}
4329
/>
4430
<Button
4531
design={ButtonDesign.Emphasized}
@@ -52,7 +38,7 @@ export default function ControlPlaneListAllWorkspaces({ projectName }: Props) {
5238
</Button>
5339
</FlexBox>
5440
) : (
55-
allWorkspaces.map((workspace) => (
41+
workspaces.map((workspace) => (
5642
<ControlPlaneListWorkspaceGridTile
5743
key={`${projectName}-${workspace.metadata.name}`}
5844
projectName={projectName}

src/components/Ui/IllustratedBanner/IllustratedBanner.cy.tsx

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,18 @@ describe('<IllustratedBanner />', () => {
1616
cy.contains('Test subtitle').should('be.visible');
1717
});
1818

19+
it('renders subtitle nodes', () => {
20+
cy.mount(
21+
<IllustratedBanner
22+
title="Test title"
23+
subtitle={<button>Button as subtitle</button>}
24+
illustrationName={IllustrationMessageType.NoData}
25+
/>,
26+
);
27+
28+
cy.get('button').contains('Button as subtitle').should('be.visible');
29+
});
30+
1931
it('renders help button with correct text and icon', () => {
2032
cy.mount(
2133
<IllustratedBanner
@@ -30,11 +42,7 @@ describe('<IllustratedBanner />', () => {
3042
);
3143

3244
cy.get('ui5-button').contains('Need Help?').should('be.visible');
33-
cy.get('ui5-button').should(
34-
'have.attr',
35-
'icon',
36-
'sap-icon://question-mark',
37-
);
45+
cy.get('ui5-button').should('have.attr', 'icon', 'sap-icon://question-mark');
3846
});
3947

4048
it('renders a link with correct attributes', () => {

src/components/Ui/IllustratedBanner/IllustratedBanner.tsx

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,50 @@
11
import IllustrationMessageDesign from '@ui5/webcomponents-fiori/dist/types/IllustrationMessageDesign.js';
22
import IllustrationMessageType from '@ui5/webcomponents-fiori/dist/types/IllustrationMessageType.js';
3-
import { FlexBox, IllustratedMessage, Button } from '@ui5/webcomponents-react';
3+
import { FlexBox, IllustratedMessage, Button, UI5WCSlotsNode } from '@ui5/webcomponents-react';
44
import ButtonDesign from '@ui5/webcomponents/dist/types/ButtonDesign.js';
55
import '@ui5/webcomponents-fiori/dist/illustrations/AllIllustrations.js';
6+
import { ReactElement } from 'react';
67

78
type InfoBannerProps = {
89
title: string;
9-
subtitle: string;
10+
subtitle: string | UI5WCSlotsNode;
1011
illustrationName: IllustrationMessageType; // e.g. 'NoData', 'SimpleError', etc.
1112
help?: {
1213
link: string;
1314
buttonText: string;
1415
buttonIcon?: string;
1516
};
16-
button?: React.ReactElement;
17+
button?: ReactElement;
1718
};
1819

1920
export const IllustratedBanner = ({
2021
title,
21-
subtitle,
22+
subtitle: subtitleProp,
2223
illustrationName,
2324
help,
2425
button,
2526
}: InfoBannerProps) => {
27+
let subtitleText, subtitleNode;
28+
if (typeof subtitleProp === 'string') {
29+
subtitleText = subtitleProp;
30+
} else {
31+
subtitleNode = subtitleProp;
32+
}
33+
2634
return (
2735
<FlexBox direction="Column" alignItems="Center">
2836
<IllustratedMessage
2937
design={IllustrationMessageDesign.Scene}
3038
name={illustrationName}
3139
titleText={title}
32-
subtitleText={subtitle}
40+
subtitleText={subtitleText}
41+
subtitle={subtitleNode}
3342
/>
3443
{help && (
3544
<a href={help.link} target="_blank" rel="noreferrer">
3645
<Button
3746
design={ButtonDesign.Transparent}
38-
icon={
39-
help.buttonIcon ? help.buttonIcon : 'sap-icon://question-mark'
40-
}
47+
icon={help.buttonIcon ? help.buttonIcon : 'sap-icon://question-mark'}
4148
>
4249
{help.buttonText}
4350
</Button>
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import '@ui5/webcomponents-fiori/dist/illustrations/AllIllustrations.js';
2+
import { NotFoundBanner } from './NotFoundBanner.tsx';
3+
import { MemoryRouter } from 'react-router-dom';
4+
5+
describe('<NotFoundBanner />', () => {
6+
it('renders title and subtitle interpolating the entityType', () => {
7+
cy.mount(
8+
<MemoryRouter>
9+
<NotFoundBanner entityType="%entityType%" />
10+
</MemoryRouter>,
11+
);
12+
13+
cy.contains('%entityType% not found').should('be.visible');
14+
cy.contains('Sorry, we couldn’t find what you are looking for').should('be.visible');
15+
16+
cy.get('ui5-button').contains('Back to Homepage');
17+
});
18+
});
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
.subtitleContainer {
2+
display: flex;
3+
flex-direction: column;
4+
}
5+
6+
.button {
7+
margin-inline: auto;
8+
margin-block: 2rem;
9+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { IllustratedBanner } from '../IllustratedBanner/IllustratedBanner.tsx';
2+
import IllustrationMessageType from '@ui5/webcomponents-fiori/dist/types/IllustrationMessageType.js';
3+
import { Trans, useTranslation } from 'react-i18next';
4+
5+
import styles from './NotFoundBanner.module.css';
6+
import { Button } from '@ui5/webcomponents-react';
7+
import { useNavigate } from 'react-router-dom';
8+
9+
export interface NotFoundBannerProps {
10+
entityType: string;
11+
}
12+
export function NotFoundBanner({ entityType }: NotFoundBannerProps) {
13+
const { t } = useTranslation();
14+
const navigate = useNavigate();
15+
16+
return (
17+
<IllustratedBanner
18+
illustrationName={IllustrationMessageType.PageNotFound}
19+
title={t('NotFoundBanner.titleMessage', { entityType })}
20+
subtitle={
21+
<div className={styles.subtitleContainer}>
22+
<span>
23+
<Trans i18nKey="NotFoundBanner.subtitleMessage" values={{ entityType }} />
24+
</span>
25+
<Button className={styles.button} onClick={() => navigate('/')}>
26+
{t('NotFoundBanner.navigateHome')}
27+
</Button>
28+
</div>
29+
}
30+
/>
31+
);
32+
}

src/lib/api/error.spec.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { isNotFoundError, APIError } from './error';
3+
4+
describe('error', () => {
5+
describe('isNotFoundError', () => {
6+
it('should return true if error.status is 404', () => {
7+
expect(isNotFoundError(new APIError('', 404))).toBe(true);
8+
expect(isNotFoundError(new APIError('not found', 404))).toBe(true);
9+
});
10+
11+
it('should return true if error.status is 403', () => {
12+
expect(isNotFoundError(new APIError('', 403))).toBe(true);
13+
expect(isNotFoundError(new APIError('not found', 403))).toBe(true);
14+
});
15+
16+
it('should return false if error is undefined', () => {
17+
expect(isNotFoundError(undefined)).toBe(false);
18+
});
19+
20+
it('should return false if error is null', () => {
21+
expect(isNotFoundError(null)).toBe(false);
22+
});
23+
24+
it('should return false if error has no status field', () => {
25+
expect(isNotFoundError({} as APIError)).toBe(false);
26+
});
27+
28+
it('should return false if error.status is not 404 or 403', () => {
29+
expect(isNotFoundError(new APIError('', 500))).toBe(false);
30+
expect(isNotFoundError(new APIError('', 400))).toBe(false);
31+
expect(isNotFoundError(new APIError('', 401))).toBe(false);
32+
});
33+
});
34+
});

src/lib/api/error.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,7 @@ export class ValidationError extends Error {
2121
Object.setPrototypeOf(this, ValidationError.prototype);
2222
}
2323
}
24+
25+
export function isNotFoundError(error?: APIError | null): boolean {
26+
return !!error && (error.status === 404 || error.status === 403);
27+
}

0 commit comments

Comments
 (0)