Skip to content
Open
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -206,4 +206,98 @@ describe('SharedPreferences', () => {
await userEvent.click(screen.getByText('Save'));
expect(mockReload).toHaveBeenCalled();
});

describe('ThemeChangedEvent subscription', () => {
let mockEventBus: {
subscribe: jest.Mock;
unsubscribe: jest.Mock;
};

beforeEach(() => {
mockEventBus = {
subscribe: jest.fn(),
unsubscribe: jest.fn(),
};
});

it('subscribes to ThemeChangedEvent on mount', async () => {
const { getAppEvents } = await import('@grafana/runtime');
(getAppEvents as jest.Mock) = jest.fn().mockReturnValue(mockEventBus);

render(<SharedPreferences {...props} />);
await waitFor(() => expect(mockPrefsLoad).toHaveBeenCalled());

expect(mockEventBus.subscribe).toHaveBeenCalledWith(
expect.anything(), // ThemeChangedEvent
expect.any(Function)
);
});

it('updates theme state when ThemeChangedEvent is received with isDark=true', async () => {
let eventHandler: ((evt: any) => void) | undefined;
mockEventBus.subscribe.mockImplementation((_event, handler) => {
eventHandler = handler;
return { unsubscribe: mockEventBus.unsubscribe };
});

const { getAppEvents } = await import('@grafana/runtime');
(getAppEvents as jest.Mock) = jest.fn().mockReturnValue(mockEventBus);

render(<SharedPreferences {...props} />);
await waitFor(() => expect(mockPrefsLoad).toHaveBeenCalled());

// Simulate ThemeChangedEvent with dark theme
eventHandler?.({ payload: { isDark: true } });

await waitFor(() => {
const themeSelect = screen.getByRole('combobox', { name: 'Interface theme' });
expect(themeSelect).toHaveValue('Dark');
});
});

it('updates theme state when ThemeChangedEvent is received with isDark=false', async () => {
mockPrefsLoad.mockResolvedValueOnce({ ...mockPreferences, theme: 'dark' });

let eventHandler: ((evt: any) => void) | undefined;
mockEventBus.subscribe.mockImplementation((_event, handler) => {
eventHandler = handler;
return { unsubscribe: mockEventBus.unsubscribe };
});

const { getAppEvents } = await import('@grafana/runtime');
(getAppEvents as jest.Mock) = jest.fn().mockReturnValue(mockEventBus);

render(<SharedPreferences {...props} />);
await waitFor(() => expect(mockPrefsLoad).toHaveBeenCalled());

// Verify initial theme is dark
await waitFor(() => {
const themeSelect = screen.getByRole('combobox', { name: 'Interface theme' });
expect(themeSelect).toHaveValue('Dark');
});

// Simulate ThemeChangedEvent with light theme
eventHandler?.({ payload: { isDark: false } });

await waitFor(() => {
const themeSelect = screen.getByRole('combobox', { name: 'Interface theme' });
expect(themeSelect).toHaveValue('Light');
});
});

it('unsubscribes from ThemeChangedEvent on unmount', async () => {
const unsubscribeMock = jest.fn();
mockEventBus.subscribe.mockReturnValue({ unsubscribe: unsubscribeMock });

const { getAppEvents } = await import('@grafana/runtime');
(getAppEvents as jest.Mock) = jest.fn().mockReturnValue(mockEventBus);

const { unmount } = render(<SharedPreferences {...props} />);
await waitFor(() => expect(mockPrefsLoad).toHaveBeenCalled());

unmount();

expect(unsubscribeMock).toHaveBeenCalled();
});
});
});
45 changes: 43 additions & 2 deletions public/app/core/components/SharedPreferences/SharedPreferences.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
import { css } from '@emotion/css';
import { PureComponent } from 'react';
import * as React from 'react';
import type { Unsubscribable } from 'rxjs';

import { FeatureState, getBuiltInThemes, ThemeRegistryItem } from '@grafana/data';
import {
FeatureState,
getBuiltInThemes,
ThemeRegistryItem,
type GrafanaTheme2,
type BusEventWithPayload,
} from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { config, reportInteraction } from '@grafana/runtime';
import { config, reportInteraction, getAppEvents, ThemeChangedEvent } from '@grafana/runtime';
import { Preferences as UserPreferencesDTO } from '@grafana/schema/src/raw/preferences/x/preferences_types.gen';
import {
Button,
Expand All @@ -26,6 +33,7 @@ import { t, Trans } from 'app/core/internationalization';
import { LANGUAGES, PSEUDO_LOCALE } from 'app/core/internationalization/constants';
import { PreferencesService } from 'app/core/services/PreferencesService';
import { changeTheme } from 'app/core/services/theme';

export interface Props {
resourceUri: string;
disabled?: boolean;
Expand Down Expand Up @@ -67,6 +75,7 @@ function getLanguageOptions(): ComboboxOption[] {
export class SharedPreferences extends PureComponent<Props, State> {
service: PreferencesService;
themeOptions: ComboboxOption[];
private themeChangedSub?: Unsubscribable;

constructor(props: Props) {
super(props);
Expand Down Expand Up @@ -121,6 +130,33 @@ export class SharedPreferences extends PureComponent<Props, State> {
queryHistory: prefs.queryHistory,
navbar: prefs.navbar,
});

// Subscribe to theme changes to keep the dropdown in sync with the actual theme.
// This ensures the dropdown reflects theme changes from any source (system preferences,
// other UI components, etc.), not just from this component's dropdown selection.
const eventBus = getAppEvents();
if (eventBus && typeof eventBus.subscribe === 'function') {
this.themeChangedSub = eventBus.subscribe(ThemeChangedEvent, (evt: BusEventWithPayload<GrafanaTheme2>) => {
try {
const newTheme = evt.payload;
const mode = newTheme.colors.mode;

if (this.state.theme !== mode) {
this.setState({ theme: mode });
}
} catch (err) {
console.warn('[SharedPreferences] Failed to sync theme from ThemeChangedEvent:', err);
}
});
}
}

componentWillUnmount() {
try {
this.themeChangedSub?.unsubscribe();
} catch (err) {
console.warn('[SharedPreferences] Failed to unsubscribe ThemeChangedEvent:', err);
}
}

onSubmitForm = async (event: React.FormEvent<HTMLFormElement>) => {
Expand All @@ -135,7 +171,9 @@ export class SharedPreferences extends PureComponent<Props, State> {
};

onThemeChanged = (value: ComboboxOption<string>) => {
// Update state immediately so the form has the correct value when saving
this.setState({ theme: value.value });

reportInteraction('grafana_preferences_theme_changed', {
toTheme: value.value,
preferenceType: this.props.preferenceType,
Expand All @@ -144,6 +182,9 @@ export class SharedPreferences extends PureComponent<Props, State> {
if (value.value) {
changeTheme(value.value, true);
}
// Note: setState is called twice - once here for immediate form state update,
// and again via ThemeChangedEvent subscription after CSS loads. This is intentional
// to ensure form correctness while also maintaining sync with rendered theme.
};

onTimeZoneChanged = (timezone?: string) => {
Expand Down
24 changes: 17 additions & 7 deletions public/app/core/services/theme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,28 +12,38 @@ export async function changeTheme(themeId: string, runtimeOnly?: boolean) {

const newTheme = getThemeById(themeId);

appEvents.publish(new ThemeChangedEvent(newTheme));

// Add css file for new theme
if (oldTheme.colors.mode !== newTheme.colors.mode) {
const newCssLink = document.createElement('link');
newCssLink.rel = 'stylesheet';
newCssLink.href = config.bootData.assets[newTheme.colors.mode];
newCssLink.onload = () => {
// Remove old css file

// Publish ThemeChangedEvent AFTER CSS loads to ensure UI components (like theme dropdown)
// update synchronously with the actual rendered theme. This prevents visual lag where
// the dropdown updates immediately but the page content updates later.
const publishThemeChange = () => {
appEvents.publish(new ThemeChangedEvent(newTheme));

// Remove old css file only after new one is loaded to prevent flickering
const bodyLinks = document.getElementsByTagName('link');
for (let i = 0; i < bodyLinks.length; i++) {
const link = bodyLinks[i];

if (link.href && link.href.includes(`build/grafana.${oldTheme.colors.mode}`)) {
// Remove existing link once the new css has loaded to avoid flickering
// If we add new css at the same time we remove current one the page will be rendered without css
// As the new css file is loading
link.remove();
}
}
};

newCssLink.onload = publishThemeChange;
// Ensure event is published even if CSS fails to load (network error, ad blocker, etc.)
// to prevent UI from getting stuck in inconsistent state
newCssLink.onerror = publishThemeChange;

document.head.insertBefore(newCssLink, document.head.firstChild);
} else {
// Same mode (e.g., light -> light with different variant), publish event immediately
appEvents.publish(new ThemeChangedEvent(newTheme));
}

if (runtimeOnly) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,11 @@ import { useAsync } from 'react-use';
import { GrafanaTheme2, ScopedVars } from '@grafana/data';
import { sanitize, sanitizeUrl } from '@grafana/data/src/text/sanitize';
import { selectors } from '@grafana/e2e-selectors';
import { config } from '@grafana/runtime';
import { DashboardLink } from '@grafana/schema';
import { Dropdown, Icon, Button, Menu, ScrollContainer, useStyles2 } from '@grafana/ui';
import { ButtonLinkProps, LinkButton } from '@grafana/ui/src/components/Button';
import { getBackendSrv } from 'app/core/services/backend_srv';
import { DashboardSearchItem } from 'app/features/search/types';
import { isPmmAdmin } from 'app/percona/shared/helpers/permissions';

import { getLinkSrv } from '../../../panel/panellinks/link_srv';

Expand All @@ -29,22 +27,7 @@ interface DashboardLinksMenuProps {

function DashboardLinksMenu({ dashboardUID, link }: DashboardLinksMenuProps) {
const styles = useStyles2(getStyles);
let resolvedLinks = useResolvedLinks({ dashboardUID, link });

// @PERCONA
// TODO: PMM-7736 remove it ASAP after migration transition period is finished
if (link.title === 'PMM') {
if (isPmmAdmin(config.bootData.user)) {
resolvedLinks = [
{ uid: '1000', url: '/graph/add-instance', title: 'PMM Add Instance' },
{ uid: '1001', url: '/graph/advisors/insights', title: 'PMM Advisors' },
{ uid: '1002', url: '/graph/inventory', title: 'PMM Inventory' },
{ uid: '1003', url: '/graph/settings', title: 'PMM Settings' },
];
} else {
return <></>;
}
}
const resolvedLinks = useResolvedLinks({ dashboardUID, link });

if (!resolvedLinks || resolveLinks.length === 0) {
return null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,13 @@ const AddBackupPage: FC = () => {
);

return (
<Page navId="backup-add-edit" layout={PageLayoutType.Custom}>
<Page
navId="backup-add-edit"
pageNav={{
text: modalTitle,
}}
layout={PageLayoutType.Custom}
>
<Overlay isPending={pending}>
<Form
initialValues={initialValues}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import { PerconaBootstrapperProps } from './PerconaBootstrapper.types';
import PerconaNavigation from './PerconaNavigation/PerconaNavigation';
import PerconaTourBootstrapper from './PerconaTour';
import PerconaUpdateVersion from './PerconaUpdateVersion/PerconaUpdateVersion';
import { isPmmNavEnabled } from '../../helpers/plugin';

// This component is only responsible for populating the store with Percona's settings initially
export const PerconaBootstrapper = ({ onReady }: PerconaBootstrapperProps) => {
Expand Down Expand Up @@ -117,10 +118,11 @@ export const PerconaBootstrapper = ({ onReady }: PerconaBootstrapperProps) => {
<>
{isSignedIn && <Telemetry />}
<PerconaNavigation />
<PerconaTourBootstrapper />
{!isPmmNavEnabled() && <PerconaTourBootstrapper />}
{updateAvailable && showUpdateModal && !isLoadingUpdates ? (
<PerconaUpdateVersion />
) : (
!isPmmNavEnabled() &&
isSignedIn &&
showTour && (
<Modal onDismiss={dismissModal} isOpen={modalIsOpen} title={Messages.title}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,14 @@ export const WEIGHTS = {
config: -900,
};

export const PMM_BACKUP_ADD_EDIT: NavModelItem = {
id: 'backup-add-edit',
text: 'Create backup',
url: `${config.appSubUrl}/backup/new`,
hideFromBreadcrumbs: true,
isCreateAction: true,
};

export const PMM_BACKUP_PAGE: NavModelItem = {
id: 'backup',
icon: 'history',
Expand Down Expand Up @@ -36,6 +44,7 @@ export const PMM_BACKUP_PAGE: NavModelItem = {
text: 'Storage Locations',
url: `${config.appSubUrl}/backup/locations`,
},
PMM_BACKUP_ADD_EDIT,
],
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,13 @@ import {
} from 'app/percona/shared/core/reducers/updates';
import { setSnoozedVersion } from 'app/percona/shared/core/reducers/user/user';
import { getPerconaSettings, getPerconaUser, getUpdatesInfo } from 'app/percona/shared/core/selectors';
import { isPmmNavEnabled } from 'app/percona/shared/helpers/plugin';
import { useAppDispatch } from 'app/store/store';
import { useSelector } from 'app/types';

import { Messages } from './PerconaUpdateVersion.constants';
import { getStyles } from './PerconaUpdateVersion.styles';
import { locationService } from '@grafana/runtime';

const PerconaUpdateVersion = () => {
const { updateAvailable, installed, latest, changeLogs, showUpdateModal, latestNewsUrl } =
Expand Down Expand Up @@ -52,7 +54,12 @@ const PerconaUpdateVersion = () => {

const onUpdateClick = () => {
dispatch(setShowUpdateModal(false));
window.location.assign(PMM_UPDATES_LINK.url!);

if (isPmmNavEnabled()) {
locationService.push(PMM_UPDATES_LINK.url!);
} else {
window.location.assign(PMM_UPDATES_LINK.url!);
}
};

return (
Expand Down
3 changes: 3 additions & 0 deletions public/app/percona/shared/helpers/plugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { config } from '@grafana/runtime';

export const isPmmNavEnabled = () => !!config.apps['pmm-compat-app']?.preload;
8 changes: 7 additions & 1 deletion public/app/plugins/panel/pmm-update/UpdatePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@ import { Button, Spinner } from '@grafana/ui';
import { PMM_UPDATES_LINK } from 'app/percona/shared/components/PerconaBootstrapper/PerconaNavigation';
import { checkUpdatesAction } from 'app/percona/shared/core/reducers/updates';
import { getPerconaUser, getPerconaSettings, getUpdatesInfo } from 'app/percona/shared/core/selectors';
import { isPmmNavEnabled } from 'app/percona/shared/helpers/plugin';
import { useAppDispatch } from 'app/store/store';
import { useSelector } from 'app/types';

import { Messages } from './UpdatePanel.messages';
import { styles } from './UpdatePanel.styles';
import { formatDateWithTime } from './UpdatePanel.utils';
import { AvailableUpdate, CurrentVersion, InfoBox, LastCheck } from './components';
import { locationService } from '@grafana/runtime';

export const UpdatePanel: FC = () => {
const isOnline = navigator.onLine;
Expand Down Expand Up @@ -38,7 +40,11 @@ export const UpdatePanel: FC = () => {
};

const handleOpenUpdates = () => {
window.location.assign(PMM_UPDATES_LINK.url!);
if (isPmmNavEnabled()) {
locationService.push(PMM_UPDATES_LINK.url!);
} else {
window.location.assign(PMM_UPDATES_LINK.url!);
}
};

return (
Expand Down
Loading