Skip to content

Commit ee7ad6e

Browse files
feat: Support themes (#200)
1 parent 6e6025a commit ee7ad6e

File tree

11 files changed

+123
-46
lines changed

11 files changed

+123
-46
lines changed
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { describe, it, expect, afterAll } from 'vitest';
2+
import { getRedirectSuffix } from './getRedirectSuffix';
3+
4+
const originalLocation = globalThis.location;
5+
6+
function mockLocation(search: string, hash: string) {
7+
Object.defineProperty(globalThis, 'location', {
8+
value: { ...originalLocation, search, hash },
9+
writable: true,
10+
configurable: true,
11+
});
12+
}
13+
14+
// Restore the real object once all tests have finished
15+
afterAll(() => {
16+
Object.defineProperty(globalThis, 'location', {
17+
value: originalLocation,
18+
writable: true,
19+
configurable: true,
20+
});
21+
});
22+
23+
describe('getRedirectSuffix()', () => {
24+
it('returns "/{search}{hash}" when both parts are present', () => {
25+
mockLocation('?sap-theme=sap_horizon', '#/mcp/projects');
26+
expect(getRedirectSuffix()).toBe('/?sap-theme=sap_horizon#/mcp/projects');
27+
});
28+
29+
it('returns "/{search}" when only the query string exists', () => {
30+
mockLocation('?query=foo', '');
31+
expect(getRedirectSuffix()).toBe('/?query=foo');
32+
});
33+
34+
it('returns "{hash}" when only the hash fragment exists', () => {
35+
mockLocation('', '#/dashboard');
36+
expect(getRedirectSuffix()).toBe('#/dashboard');
37+
});
38+
39+
it('returns an empty string when neither search nor hash exist', () => {
40+
mockLocation('', '');
41+
expect(getRedirectSuffix()).toBe('');
42+
});
43+
});
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
/**
2+
* Generates the part of the URL (query string and hash fragments) that must be kept when redirecting the user.
3+
*
4+
* @example
5+
* ```ts
6+
* // Current URL: https://example.com/?sap-theme=sap_horizon#/mcp/projects
7+
*
8+
* const redirectTo = getRedirectSuffix();
9+
* // redirectTo -> "/?sap-theme=sap_horizon#/mcp/projects"
10+
* ```
11+
*/
12+
export function getRedirectSuffix() {
13+
const { search, hash } = globalThis.location;
14+
return (search ? `/${search}` : '') + hash;
15+
}

src/components/Core/DarkModeSystemSwitcher.tsx

Lines changed: 0 additions & 28 deletions
This file was deleted.

src/components/ThemeManager.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { useEffect } from 'react';
2+
import { setTheme } from '@ui5/webcomponents-base/dist/config/Theme.js';
3+
import { useTheme } from '../hooks/useTheme.ts';
4+
5+
export function ThemeManager() {
6+
const { theme } = useTheme();
7+
8+
useEffect(() => {
9+
void setTheme(theme);
10+
}, [theme]);
11+
12+
return null;
13+
}

src/components/Yaml/YamlViewer.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,12 @@ import { Button, FlexBox } from '@ui5/webcomponents-react';
66
import styles from './YamlViewer.module.css';
77
import { useToast } from '../../context/ToastContext.tsx';
88
import { useTranslation } from 'react-i18next';
9-
import { useThemeMode } from '../../lib/useThemeMode.ts';
9+
import { useTheme } from '../../hooks/useTheme.ts';
1010
type YamlViewerProps = { yamlString: string; filename: string };
1111
const YamlViewer: FC<YamlViewerProps> = ({ yamlString, filename }) => {
1212
const toast = useToast();
1313
const { t } = useTranslation();
14-
const { isDarkMode } = useThemeMode();
14+
const { isDarkTheme } = useTheme();
1515
const copyToClipboard = () => {
1616
navigator.clipboard.writeText(yamlString);
1717
toast.show(t('yaml.copiedToClipboard'));
@@ -40,7 +40,7 @@ const YamlViewer: FC<YamlViewerProps> = ({ yamlString, filename }) => {
4040
</FlexBox>
4141
<SyntaxHighlighter
4242
language="yaml"
43-
style={isDarkMode ? materialDark : materialLight}
43+
style={isDarkTheme ? materialDark : materialLight}
4444
showLineNumbers
4545
lineNumberStyle={{
4646
paddingRight: '20px',

src/hooks/useTheme.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { useSyncExternalStore } from 'react';
2+
import { Theme } from '@ui5/webcomponents-react';
3+
4+
const DEFAULT_THEME_LIGHT = Theme.sap_horizon;
5+
const DEFAULT_THEME_DARK = Theme.sap_horizon_dark;
6+
const DARK_SAP_THEMES = new Set<string>([
7+
Theme.sap_fiori_3_dark,
8+
Theme.sap_fiori_3_hcb,
9+
Theme.sap_horizon_dark,
10+
Theme.sap_horizon_hcb,
11+
]);
12+
13+
function useSystemDarkModePreference() {
14+
return useSyncExternalStore(
15+
(callback) => {
16+
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
17+
mediaQuery.addEventListener('change', callback);
18+
return () => mediaQuery.removeEventListener('change', callback);
19+
},
20+
() => window.matchMedia('(prefers-color-scheme: dark)').matches,
21+
);
22+
}
23+
24+
export function useTheme() {
25+
const systemPrefersDark = useSystemDarkModePreference();
26+
const themeFromUrl = new URL(window.location.href).searchParams.get('sap-theme');
27+
28+
// Theme from URL takes precedence over system settings
29+
const theme = themeFromUrl || (systemPrefersDark ? DEFAULT_THEME_DARK : DEFAULT_THEME_LIGHT);
30+
31+
// For well-defined SAP themes, return if they are light or dark – unknown themes will fall back to light
32+
const isDarkTheme = DARK_SAP_THEMES.has(theme);
33+
34+
return {
35+
theme,
36+
isDarkTheme,
37+
};
38+
}

src/lib/api/fetch.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { APIError } from './error';
22
import { ApiConfig } from './types/apiConfig';
3+
import { AUTH_FLOW_SESSION_KEY } from '../../common/auth/AuthCallbackHandler.tsx';
4+
import { getRedirectSuffix } from '../../common/auth/getRedirectSuffix.ts';
35

46
const useCrateClusterHeader = 'X-use-crate';
57
const projectNameHeader = 'X-project';
@@ -48,8 +50,9 @@ export const fetchApiServer = async (
4850

4951
if (!res.ok) {
5052
if (res.status === 401) {
51-
// Unauthorized, redirect to the login page
52-
window.location.replace('/api/auth/onboarding/login');
53+
// Unauthorized (token expired), redirect to the login page
54+
sessionStorage.setItem(AUTH_FLOW_SESSION_KEY, 'onboarding');
55+
window.location.replace(`/api/auth/onboarding/login?redirectTo=${encodeURIComponent(getRedirectSuffix())}`);
5356
}
5457
const error = new APIError('An error occurred while fetching the data.', res.status);
5558
error.info = await res.json();

src/lib/useThemeMode.ts

Lines changed: 0 additions & 7 deletions
This file was deleted.

src/main.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { ToastProvider } from './context/ToastContext.tsx';
77
import { CopyButtonProvider } from './context/CopyButtonContext.tsx';
88
import { FrontendConfigProvider } from './context/FrontendConfigContext.tsx';
99
import '@ui5/webcomponents-react/dist/Assets'; //used for loading themes
10-
import { DarkModeSystemSwitcher } from './components/Core/DarkModeSystemSwitcher.tsx';
10+
import { ThemeManager } from './components/ThemeManager.tsx';
1111
import '.././i18n.ts';
1212
import './utils/i18n/timeAgo';
1313
import { ErrorBoundary, FallbackProps } from 'react-error-boundary';
@@ -49,7 +49,7 @@ export function createApp() {
4949
<ApolloClientProvider>
5050
<App />
5151
</ApolloClientProvider>
52-
<DarkModeSystemSwitcher />
52+
<ThemeManager />
5353
</SWRConfig>
5454
</CopyButtonProvider>
5555
</ToastProvider>

src/spaces/mcp/auth/AuthContextMcp.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { createContext, useState, useEffect, ReactNode, use } from 'react';
22
import { MeResponseSchema } from './auth.schemas';
33
import { AUTH_FLOW_SESSION_KEY } from '../../../common/auth/AuthCallbackHandler.tsx';
4+
import { getRedirectSuffix } from '../../../common/auth/getRedirectSuffix.ts';
45

56
interface AuthContextMcpType {
67
isLoading: boolean;
@@ -55,8 +56,7 @@ export function AuthProviderMcp({ children }: { children: ReactNode }) {
5556

5657
const login = () => {
5758
sessionStorage.setItem(AUTH_FLOW_SESSION_KEY, 'mcp');
58-
59-
window.location.replace(`/api/auth/mcp/login?redirectTo=${encodeURIComponent(window.location.hash)}`);
59+
window.location.replace(`/api/auth/mcp/login?redirectTo=${encodeURIComponent(getRedirectSuffix())}`);
6060
};
6161

6262
return <AuthContextMcp value={{ isLoading, isAuthenticated, error, login }}>{children}</AuthContextMcp>;

0 commit comments

Comments
 (0)