diff --git a/public/locales/en.json b/public/locales/en.json index c9f3294b..aa053a98 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -5,6 +5,10 @@ "learnMore": "Learn more in our documentation", "signInButton": "Sign in" }, + "Entities": { + "ManagedControlPlane": "Managed Control Plane", + "Project": "Project" + }, "ComponentList": { "tableComponentHeader": "Name", "tableVersionHeader": "Version" @@ -87,6 +91,11 @@ "subtitleMessage": "Get started by creating your first Managed Control Plane.", "helpButton": "Help" }, + "NotFoundBanner": { + "titleMessage": "{{entityType}} not found", + "subtitleMessage": "Sorry, we couldn’t find what you are looking for.
The link may be incorrect or the {{entityType}} might have been removed.", + "navigateHome": "Back to Homepage" + }, "IntelligentBreadcrumbs": { "homeLabel": "Home" }, @@ -151,7 +160,7 @@ "EditMembers": { "addButton": "Add" }, - "ControlPlaneListView": { + "ProjectsPage": { "header": "Your instances of ManagedControlPlane", "projectHeader": "Project:" }, @@ -161,7 +170,7 @@ "deleteProject": "Delete project", "deleteConfirmationDialog": "Project deleted" }, - "ControlPlaneView": { + "McpPage": { "accessError": "Managed Control Plane does not have access information yet", "componentsTitle": "Components", "crossplaneTitle": "Crossplane", diff --git a/src/AppRouter.tsx b/src/AppRouter.tsx index b65c4ff4..b621a018 100644 --- a/src/AppRouter.tsx +++ b/src/AppRouter.tsx @@ -1,10 +1,10 @@ import { HashRouter as Router, Navigate, Route } from 'react-router-dom'; -import ControlPlaneView from './views/ControlPlanes/ControlPlaneView.tsx'; import ProjectListView from './views/ProjectList'; -import ControlPlaneListView from './views/ControlPlanes/ControlPlaneListView.tsx'; import GlobalProviderOutlet from './components/Core/ApiConfigWrapper.tsx'; import { ShellBarComponent } from './components/Core/ShellBar.tsx'; import { SentryRoutes } from './mount.ts'; +import ProjectPage from './spaces/onboarding/pages/ProjectPage.tsx'; +import McpPage from './spaces/mcp/pages/McpPage.tsx'; function AppRouter() { return ( @@ -14,13 +14,14 @@ function AppRouter() { }> } /> - } /> + } /> } + element={} /> } /> + } /> diff --git a/src/components/ControlPlanes/List/ControlPlaneListAllWorkspaces.tsx b/src/components/ControlPlanes/List/ControlPlaneListAllWorkspaces.tsx index 92a2b0ca..8e764abf 100644 --- a/src/components/ControlPlanes/List/ControlPlaneListAllWorkspaces.tsx +++ b/src/components/ControlPlanes/List/ControlPlaneListAllWorkspaces.tsx @@ -1,45 +1,31 @@ import { Button, FlexBox, IllustratedMessage } from '@ui5/webcomponents-react'; -import IllustratedError from '../../Shared/IllustratedError.tsx'; import '@ui5/webcomponents-fiori/dist/illustrations/NoData.js'; import '@ui5/webcomponents-fiori/dist/illustrations/EmptyList.js'; import '@ui5/webcomponents-icons/dist/delete'; -import Loading from '../../Shared/Loading.tsx'; import ButtonDesign from '@ui5/webcomponents/dist/types/ButtonDesign.js'; import { ControlPlaneListWorkspaceGridTile } from './ControlPlaneListWorkspaceGridTile.tsx'; -import { useApiResource } from '../../../lib/api/useApiResource.ts'; -import { ListWorkspaces } from '../../../lib/api/types/crate/listWorkspaces.ts'; +import { ListWorkspacesType } from '../../../lib/api/types/crate/listWorkspaces.ts'; import { useLink } from '../../../lib/shared/useLink.ts'; import { useTranslation } from 'react-i18next'; interface Props { projectName: string; + workspaces: ListWorkspacesType[]; } -export default function ControlPlaneListAllWorkspaces({ projectName }: Props) { +export default function ControlPlaneListAllWorkspaces({ projectName, workspaces }: Props) { const { workspaceCreationGuide } = useLink(); - const { data: allWorkspaces, error } = useApiResource( - ListWorkspaces(projectName), - ); const { t } = useTranslation(); - if (!allWorkspaces) { - return ; - } - if (error) { - return ; - } - return ( <> - {allWorkspaces.length === 0 ? ( + {workspaces.length === 0 ? ( } + illustrationName={IllustrationMessageType.NoData} + />, + ); + + cy.get('button').contains('Button as subtitle').should('be.visible'); + }); + it('renders help button with correct text and icon', () => { cy.mount( ', () => { ); cy.get('ui5-button').contains('Need Help?').should('be.visible'); - cy.get('ui5-button').should( - 'have.attr', - 'icon', - 'sap-icon://question-mark', - ); + cy.get('ui5-button').should('have.attr', 'icon', 'sap-icon://question-mark'); }); it('renders a link with correct attributes', () => { diff --git a/src/components/Ui/IllustratedBanner/IllustratedBanner.tsx b/src/components/Ui/IllustratedBanner/IllustratedBanner.tsx index b203cb33..d89b45bd 100644 --- a/src/components/Ui/IllustratedBanner/IllustratedBanner.tsx +++ b/src/components/Ui/IllustratedBanner/IllustratedBanner.tsx @@ -1,43 +1,50 @@ import IllustrationMessageDesign from '@ui5/webcomponents-fiori/dist/types/IllustrationMessageDesign.js'; import IllustrationMessageType from '@ui5/webcomponents-fiori/dist/types/IllustrationMessageType.js'; -import { FlexBox, IllustratedMessage, Button } from '@ui5/webcomponents-react'; +import { FlexBox, IllustratedMessage, Button, UI5WCSlotsNode } from '@ui5/webcomponents-react'; import ButtonDesign from '@ui5/webcomponents/dist/types/ButtonDesign.js'; import '@ui5/webcomponents-fiori/dist/illustrations/AllIllustrations.js'; +import { ReactElement } from 'react'; type InfoBannerProps = { title: string; - subtitle: string; + subtitle: string | UI5WCSlotsNode; illustrationName: IllustrationMessageType; // e.g. 'NoData', 'SimpleError', etc. help?: { link: string; buttonText: string; buttonIcon?: string; }; - button?: React.ReactElement; + button?: ReactElement; }; export const IllustratedBanner = ({ title, - subtitle, + subtitle: subtitleProp, illustrationName, help, button, }: InfoBannerProps) => { + let subtitleText, subtitleNode; + if (typeof subtitleProp === 'string') { + subtitleText = subtitleProp; + } else { + subtitleNode = subtitleProp; + } + return ( {help && ( diff --git a/src/components/Ui/NotFoundBanner/NotFoundBanner.cy.tsx b/src/components/Ui/NotFoundBanner/NotFoundBanner.cy.tsx new file mode 100644 index 00000000..771ea4df --- /dev/null +++ b/src/components/Ui/NotFoundBanner/NotFoundBanner.cy.tsx @@ -0,0 +1,18 @@ +import '@ui5/webcomponents-fiori/dist/illustrations/AllIllustrations.js'; +import { NotFoundBanner } from './NotFoundBanner.tsx'; +import { MemoryRouter } from 'react-router-dom'; + +describe('', () => { + it('renders title and subtitle interpolating the entityType', () => { + cy.mount( + + + , + ); + + cy.contains('%entityType% not found').should('be.visible'); + cy.contains('Sorry, we couldn’t find what you are looking for').should('be.visible'); + + cy.get('ui5-button').contains('Back to Homepage'); + }); +}); diff --git a/src/components/Ui/NotFoundBanner/NotFoundBanner.module.css b/src/components/Ui/NotFoundBanner/NotFoundBanner.module.css new file mode 100644 index 00000000..f017a659 --- /dev/null +++ b/src/components/Ui/NotFoundBanner/NotFoundBanner.module.css @@ -0,0 +1,9 @@ +.subtitleContainer { + display: flex; + flex-direction: column; +} + +.button { + margin-inline: auto; + margin-block: 2rem; +} \ No newline at end of file diff --git a/src/components/Ui/NotFoundBanner/NotFoundBanner.tsx b/src/components/Ui/NotFoundBanner/NotFoundBanner.tsx new file mode 100644 index 00000000..8e2e7741 --- /dev/null +++ b/src/components/Ui/NotFoundBanner/NotFoundBanner.tsx @@ -0,0 +1,32 @@ +import { IllustratedBanner } from '../IllustratedBanner/IllustratedBanner.tsx'; +import IllustrationMessageType from '@ui5/webcomponents-fiori/dist/types/IllustrationMessageType.js'; +import { Trans, useTranslation } from 'react-i18next'; + +import styles from './NotFoundBanner.module.css'; +import { Button } from '@ui5/webcomponents-react'; +import { useNavigate } from 'react-router-dom'; + +export interface NotFoundBannerProps { + entityType: string; +} +export function NotFoundBanner({ entityType }: NotFoundBannerProps) { + const { t } = useTranslation(); + const navigate = useNavigate(); + + return ( + + + + + + + } + /> + ); +} diff --git a/src/components/Yaml/YamlViewer.tsx b/src/components/Yaml/YamlViewer.tsx index dd7a1db1..3828ddca 100644 --- a/src/components/Yaml/YamlViewer.tsx +++ b/src/components/Yaml/YamlViewer.tsx @@ -1,20 +1,17 @@ import { FC } from 'react'; import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; -import { - materialLight, - materialDark, -} from 'react-syntax-highlighter/dist/esm/styles/prism'; +import { materialLight, materialDark } from 'react-syntax-highlighter/dist/esm/styles/prism'; import { Button, FlexBox } from '@ui5/webcomponents-react'; import styles from './YamlViewer.module.css'; import { useToast } from '../../context/ToastContext.tsx'; import { useTranslation } from 'react-i18next'; -import { useThemeMode } from '../../lib/useThemeMode.ts'; +import { useIsDarkModePreferred } from '../../hooks/useIsDarkModePreferred.ts'; type YamlViewerProps = { yamlString: string; filename: string }; const YamlViewer: FC = ({ yamlString, filename }) => { const toast = useToast(); const { t } = useTranslation(); - const { isDarkMode } = useThemeMode(); + const isDarkModePreferred = useIsDarkModePreferred(); const copyToClipboard = () => { navigator.clipboard.writeText(yamlString); toast.show(t('yaml.copiedToClipboard')); @@ -33,13 +30,7 @@ const YamlViewer: FC = ({ yamlString, filename }) => { return (
- + @@ -49,7 +40,7 @@ const YamlViewer: FC = ({ yamlString, filename }) => { void) { + const mediaQueryList = window.matchMedia(mediaQuery); + mediaQueryList.addEventListener('change', callback); + + return () => { + mediaQueryList.removeEventListener('change', callback); + }; + } + + return function useMediaQuery() { + return useSyncExternalStore(subscribe, getSnapshot); + }; +} + +export const useIsDarkModePreferred = makeMediaQueryStore('(prefers-color-scheme: dark)'); diff --git a/src/lib/api/error.spec.ts b/src/lib/api/error.spec.ts new file mode 100644 index 00000000..53338c82 --- /dev/null +++ b/src/lib/api/error.spec.ts @@ -0,0 +1,34 @@ +import { describe, it, expect } from 'vitest'; +import { isNotFoundError, APIError } from './error'; + +describe('error', () => { + describe('isNotFoundError', () => { + it('should return true if error.status is 404', () => { + expect(isNotFoundError(new APIError('', 404))).toBe(true); + expect(isNotFoundError(new APIError('not found', 404))).toBe(true); + }); + + it('should return true if error.status is 403', () => { + expect(isNotFoundError(new APIError('', 403))).toBe(true); + expect(isNotFoundError(new APIError('not found', 403))).toBe(true); + }); + + it('should return false if error is undefined', () => { + expect(isNotFoundError(undefined)).toBe(false); + }); + + it('should return false if error is null', () => { + expect(isNotFoundError(null)).toBe(false); + }); + + it('should return false if error has no status field', () => { + expect(isNotFoundError({} as APIError)).toBe(false); + }); + + it('should return false if error.status is not 404 or 403', () => { + expect(isNotFoundError(new APIError('', 500))).toBe(false); + expect(isNotFoundError(new APIError('', 400))).toBe(false); + expect(isNotFoundError(new APIError('', 401))).toBe(false); + }); + }); +}); diff --git a/src/lib/api/error.ts b/src/lib/api/error.ts index 97788900..3b35a067 100644 --- a/src/lib/api/error.ts +++ b/src/lib/api/error.ts @@ -21,3 +21,7 @@ export class ValidationError extends Error { Object.setPrototypeOf(this, ValidationError.prototype); } } + +export function isNotFoundError(error?: APIError | null): boolean { + return !!error && (error.status === 404 || error.status === 403); +} diff --git a/src/lib/api/types/crate/controlPlanes.ts b/src/lib/api/types/crate/controlPlanes.ts index 33ea8498..0df87bcf 100644 --- a/src/lib/api/types/crate/controlPlanes.ts +++ b/src/lib/api/types/crate/controlPlanes.ts @@ -10,13 +10,13 @@ export interface Metadata { export interface ControlPlaneType { metadata: Metadata; spec: - | { - authentication: { - enableSystemIdentityProvider?: boolean; - }; - components: ControlPlaneComponentsType; - } - | undefined; + | { + authentication: { + enableSystemIdentityProvider?: boolean; + }; + components: ControlPlaneComponentsType; + } + | undefined; status: ControlPlaneStatusType | undefined; } @@ -36,13 +36,13 @@ export interface ControlPlaneStatusType { status: ReadyStatus; conditions: ControlPlaneStatusCondition[]; access: - | { - key: string | undefined; - name: string | undefined; - namespace: string | undefined; - kubeconfig: string | undefined; - } - | undefined; + | { + key: string | undefined; + name: string | undefined; + namespace: string | undefined; + kubeconfig: string | undefined; + } + | undefined; } export interface ControlPlaneStatusCondition { @@ -73,9 +73,9 @@ export const ListControlPlanes = ( }; export const ControlPlane = ( - projectName: string, - workspaceName: string, - controlPlaneName: string, + projectName?: string, + workspaceName?: string, + controlPlaneName?: string, ): Resource => { return { path: `/apis/core.openmcp.cloud/v1alpha1/namespaces/project-${projectName}--ws-${workspaceName}/managedcontrolplanes/${controlPlaneName}`, diff --git a/src/lib/api/types/crate/listWorkspaces.ts b/src/lib/api/types/crate/listWorkspaces.ts index 116c281c..ffbbe59c 100644 --- a/src/lib/api/types/crate/listWorkspaces.ts +++ b/src/lib/api/types/crate/listWorkspaces.ts @@ -19,9 +19,7 @@ export function isWorkspaceReady(workspace: ListWorkspacesType): boolean { return workspace.status != null && workspace.status.namespace != null; } -export const ListWorkspaces = ( - projectName: string, -): Resource => { +export const ListWorkspaces = (projectName?: string): Resource => { return { path: `/apis/core.openmcp.cloud/v1alpha1/namespaces/project-${projectName}/workspaces`, jq: '[.items[] | {metadata: .metadata | {name, namespace, annotations, deletionTimestamp}, status: .status, spec: .spec | {members: [.members[] | {name, roles}]}}]', diff --git a/src/lib/useThemeMode.ts b/src/lib/useThemeMode.ts deleted file mode 100644 index 5ed9c0e2..00000000 --- a/src/lib/useThemeMode.ts +++ /dev/null @@ -1,7 +0,0 @@ -export const useThemeMode = (): { - isDarkMode: boolean; - mode: 'dark' | 'light'; -} => { - const isDarkMode = window.matchMedia('(prefers-color-scheme: dark)').matches; - return { isDarkMode: isDarkMode, mode: isDarkMode ? 'dark' : 'light' }; -}; diff --git a/src/main.tsx b/src/main.tsx index 378b9cab..6c8c823c 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -7,7 +7,7 @@ import { ToastProvider } from './context/ToastContext.tsx'; import { CopyButtonProvider } from './context/CopyButtonContext.tsx'; import { FrontendConfigProvider } from './context/FrontendConfigContext.tsx'; import '@ui5/webcomponents-react/dist/Assets'; //used for loading themes -import { DarkModeSystemSwitcher } from './components/Core/DarkModeSystemSwitcher.tsx'; +import { ThemeManager } from './components/ThemeManager/ThemeManager.tsx'; import '.././i18n.ts'; import './utils/i18n/timeAgo'; import { ErrorBoundary, FallbackProps } from 'react-error-boundary'; @@ -49,7 +49,7 @@ export function createApp() { - + diff --git a/src/views/ControlPlanes/ControlPlaneView.tsx b/src/spaces/mcp/pages/McpPage.tsx similarity index 57% rename from src/views/ControlPlanes/ControlPlaneView.tsx rename to src/spaces/mcp/pages/McpPage.tsx index e4e0a99e..1d5152c8 100644 --- a/src/views/ControlPlanes/ControlPlaneView.tsx +++ b/src/spaces/mcp/pages/McpPage.tsx @@ -1,63 +1,50 @@ -import { - ObjectPage, - ObjectPageSection, - ObjectPageTitle, - Panel, - Title, -} from '@ui5/webcomponents-react'; +import { BusyIndicator, ObjectPage, ObjectPageSection, ObjectPageTitle, Panel, Title } from '@ui5/webcomponents-react'; import { useParams } from 'react-router-dom'; -import CopyKubeconfigButton from '../../components/ControlPlanes/CopyKubeconfigButton.tsx'; +import CopyKubeconfigButton from '../../../components/ControlPlanes/CopyKubeconfigButton.tsx'; import '@ui5/webcomponents-fiori/dist/illustrations/SimpleBalloon'; import '@ui5/webcomponents-fiori/dist/illustrations/SimpleError'; // thorws error sometimes if not imported import '@ui5/webcomponents-fiori/dist/illustrations/BeforeSearch'; -import IllustratedError from '../../components/Shared/IllustratedError.tsx'; -import IntelligentBreadcrumbs from '../../components/Core/IntelligentBreadcrumbs.tsx'; +import IllustratedError from '../../../components/Shared/IllustratedError.tsx'; +import IntelligentBreadcrumbs from '../../../components/Core/IntelligentBreadcrumbs.tsx'; -import FluxList from '../../components/ControlPlane/FluxList.tsx'; -import { ControlPlane as ControlPlaneResource } from '../../lib/api/types/crate/controlPlanes.ts'; +import FluxList from '../../../components/ControlPlane/FluxList.tsx'; +import { ControlPlane as ControlPlaneResource } from '../../../lib/api/types/crate/controlPlanes.ts'; import { useTranslation } from 'react-i18next'; -import { - McpContextProvider, - WithinManagedControlPlane, -} from '../../lib/shared/McpContext.tsx'; -import { ManagedResources } from '../../components/ControlPlane/ManagedResources.tsx'; -import { ProvidersConfig } from '../../components/ControlPlane/ProvidersConfig.tsx'; -import { Providers } from '../../components/ControlPlane/Providers.tsx'; -import ComponentList from '../../components/ControlPlane/ComponentList.tsx'; -import MCPHealthPopoverButton from '../../components/ControlPlane/MCPHealthPopoverButton.tsx'; -import useResource from '../../lib/api/useApiResource'; +import { McpContextProvider, WithinManagedControlPlane } from '../../../lib/shared/McpContext.tsx'; +import { ManagedResources } from '../../../components/ControlPlane/ManagedResources.tsx'; +import { ProvidersConfig } from '../../../components/ControlPlane/ProvidersConfig.tsx'; +import { Providers } from '../../../components/ControlPlane/Providers.tsx'; +import ComponentList from '../../../components/ControlPlane/ComponentList.tsx'; +import MCPHealthPopoverButton from '../../../components/ControlPlane/MCPHealthPopoverButton.tsx'; +import useResource from '../../../lib/api/useApiResource.ts'; -import { YamlViewButtonWithLoader } from '../../components/Yaml/YamlViewButtonWithLoader.tsx'; -import { Landscapers } from '../../components/ControlPlane/Landscapers.tsx'; -import { AuthProviderMcp } from '../../spaces/mcp/auth/AuthContextMcp.tsx'; +import { YamlViewButtonWithLoader } from '../../../components/Yaml/YamlViewButtonWithLoader.tsx'; +import { Landscapers } from '../../../components/ControlPlane/Landscapers.tsx'; +import { AuthProviderMcp } from '../auth/AuthContextMcp.tsx'; +import { isNotFoundError } from '../../../lib/api/error.ts'; +import { NotFoundBanner } from '../../../components/Ui/NotFoundBanner/NotFoundBanner.tsx'; -export default function ControlPlaneView() { - const { projectName, workspaceName, controlPlaneName, contextName } = - useParams(); +export default function McpPage() { + const { projectName, workspaceName, controlPlaneName, contextName } = useParams(); const { t } = useTranslation(); - const { data: mcp, error } = useResource( - ControlPlaneResource( - projectName ?? '', - workspaceName ?? '', - controlPlaneName ?? '', - ), - ); + const { + data: mcp, + error, + isLoading, + } = useResource(ControlPlaneResource(projectName, workspaceName, controlPlaneName)); - if (!projectName || !workspaceName || !controlPlaneName) { - return <>; + if (isLoading) { + return ; } - if (error) { - return ; + if (!projectName || !workspaceName || !controlPlaneName || !contextName || isNotFoundError(error)) { + return ; } - if ( - !mcp?.status?.access?.key || - !mcp?.status?.access?.name || - !mcp?.status?.access?.namespace - ) { - return ; + + if (error || !mcp) { + return ; } return ( @@ -66,7 +53,7 @@ export default function ControlPlaneView() { project: projectName, workspace: workspaceName, name: controlPlaneName, - context: contextName!, + context: contextName, }} > @@ -107,17 +94,13 @@ export default function ControlPlaneView() { - {t('ControlPlaneView.componentsTitle')} - - } + header={{t('McpPage.componentsTitle')}} noAnimation > @@ -126,17 +109,13 @@ export default function ControlPlaneView() { - {t('ControlPlaneView.crossplaneTitle')} - - } + header={{t('McpPage.crossplaneTitle')}} noAnimation >
@@ -153,17 +132,13 @@ export default function ControlPlaneView() { - {t('ControlPlaneView.landscapersTitle')} - - } + header={{t('McpPage.landscapersTitle')}} noAnimation > @@ -172,15 +147,13 @@ export default function ControlPlaneView() { {t('ControlPlaneView.gitOpsTitle')} - } + header={{t('McpPage.gitOpsTitle')}} noAnimation > diff --git a/src/spaces/onboarding/auth/AuthContextOnboarding.tsx b/src/spaces/onboarding/auth/AuthContextOnboarding.tsx index 9091014a..9f05eb07 100644 --- a/src/spaces/onboarding/auth/AuthContextOnboarding.tsx +++ b/src/spaces/onboarding/auth/AuthContextOnboarding.tsx @@ -12,9 +12,7 @@ interface AuthContextOnboardingType { logout: () => Promise; } -const AuthContextOnboarding = createContext( - null, -); +const AuthContextOnboarding = createContext(null); export function AuthProviderOnboarding({ children }: { children: ReactNode }) { const [isAuthenticated, setIsAuthenticated] = useState(false); @@ -40,22 +38,16 @@ export function AuthProviderOnboarding({ children }: { children: ReactNode }) { } catch (_) { /* safe to ignore */ } - throw new Error( - errorBody?.message || - `Authentication check failed with status: ${response.status}`, - ); + throw new Error(errorBody?.message || `Authentication check failed with status: ${response.status}`); } const body = await response.json(); const validationResult = MeResponseSchema.safeParse(body); if (!validationResult.success) { - throw new Error( - `Auth API response validation failed: ${validationResult.error.flatten()}`, - ); + throw new Error(`Auth API response validation failed: ${validationResult.error.flatten()}`); } - const { isAuthenticated: apiIsAuthenticated, user: apiUser } = - validationResult.data; + const { isAuthenticated: apiIsAuthenticated, user: apiUser } = validationResult.data; setUser(apiUser); setIsAuthenticated(apiIsAuthenticated); @@ -75,10 +67,10 @@ export function AuthProviderOnboarding({ children }: { children: ReactNode }) { const login = () => { sessionStorage.setItem(AUTH_FLOW_SESSION_KEY, 'onboarding'); - - window.location.replace( - `/api/auth/onboarding/login?redirectTo=${encodeURIComponent(window.location.hash)}`, - ); + // The query parameters and hash fragments need to be preserved, e.g. /?sap-theme=sap_horizon#/mcp/projects + const { search, hash } = window.location; + const redirectTo = (search ? `/${search}` : '') + hash; + window.location.replace(`/api/auth/onboarding/login?redirectTo=${encodeURIComponent(redirectTo)}`); }; const logout = async () => { @@ -94,9 +86,7 @@ export function AuthProviderOnboarding({ children }: { children: ReactNode }) { } catch (_) { /* safe to ignore */ } - throw new Error( - errorBody?.message || `Logout failed with status: ${response.status}`, - ); + throw new Error(errorBody?.message || `Logout failed with status: ${response.status}`); } await refreshAuthStatus(); @@ -106,9 +96,7 @@ export function AuthProviderOnboarding({ children }: { children: ReactNode }) { }; return ( - + {children} ); @@ -117,9 +105,7 @@ export function AuthProviderOnboarding({ children }: { children: ReactNode }) { export const useAuthOnboarding = () => { const context = use(AuthContextOnboarding); if (!context) { - throw new Error( - 'useAuthOnboarding must be used within an AuthProviderOnboarding.', - ); + throw new Error('useAuthOnboarding must be used within an AuthProviderOnboarding.'); } return context; }; diff --git a/src/spaces/onboarding/pages/ProjectPage.tsx b/src/spaces/onboarding/pages/ProjectPage.tsx new file mode 100644 index 00000000..22b7cac6 --- /dev/null +++ b/src/spaces/onboarding/pages/ProjectPage.tsx @@ -0,0 +1,65 @@ +import { ObjectPage, ObjectPageTitle, Title } from '@ui5/webcomponents-react'; +import ProjectChooser from '../../../components/Projects/ProjectChooser.tsx'; +import { useParams } from 'react-router-dom'; +import ControlPlaneListAllWorkspaces from '../../../components/ControlPlanes/List/ControlPlaneListAllWorkspaces.tsx'; +import IntelligentBreadcrumbs from '../../../components/Core/IntelligentBreadcrumbs.tsx'; +import { ControlPlaneListToolbar } from '../../../components/ControlPlanes/List/ControlPlaneListToolbar.tsx'; +import { Trans, useTranslation } from 'react-i18next'; +import useApiResource from '../../../lib/api/useApiResource.ts'; +import { ListWorkspaces } from '../../../lib/api/types/crate/listWorkspaces.ts'; +import Loading from '../../../components/Shared/Loading.tsx'; +import { isNotFoundError } from '../../../lib/api/error.ts'; +import { NotFoundBanner } from '../../../components/Ui/NotFoundBanner/NotFoundBanner.tsx'; +import IllustratedError from '../../../components/Shared/IllustratedError.tsx'; + +export default function ProjectPage() { + const { projectName } = useParams(); + const { data: workspaces, error, isLoading } = useApiResource(ListWorkspaces(projectName)); + const { t } = useTranslation(); + + if (isLoading) { + return ; + } + + if (isNotFoundError(error)) { + return ; + } + + if (error || !workspaces || !projectName) { + return ; + } + + return ( + <> + + }} /> + + } + subHeader={ +
+

{t('ProjectsPage.projectHeader')}

+ +
+ } + breadcrumbs={} + actionsBar={} + /> + } + //TODO: project chooser should be part of the breadcrumb section if possible? + > + +
+ + ); +} diff --git a/src/views/ControlPlanes/ControlPlaneListView.tsx b/src/views/ControlPlanes/ControlPlaneListView.tsx deleted file mode 100644 index 4fe4ea43..00000000 --- a/src/views/ControlPlanes/ControlPlaneListView.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { ObjectPage, ObjectPageTitle, Title } from '@ui5/webcomponents-react'; -import ProjectChooser from '../../components/Projects/ProjectChooser.tsx'; -import { useParams } from 'react-router-dom'; -import ControlPlaneListAllWorkspaces from '../../components/ControlPlanes/List/ControlPlaneListAllWorkspaces.tsx'; -import IntelligentBreadcrumbs from '../../components/Core/IntelligentBreadcrumbs.tsx'; -import { ControlPlaneListToolbar } from '../../components/ControlPlanes/List/ControlPlaneListToolbar.tsx'; -import { Trans, useTranslation } from 'react-i18next'; - -export default function ControlPlaneListView() { - const { projectName } = useParams(); - const { t } = useTranslation(); - - return ( - <> - - }} - /> - - } - subHeader={ -
-

- {t('ControlPlaneListView.projectHeader')} -

- -
- } - breadcrumbs={} - actionsBar={ - - } - /> - } - //TODO: project chooser should be part of the breadcrumb section if possible? - > - -
- - ); -}