PluginExtensions: Migrate edit profile page to use new plugin components API (#101346)

* wip.

* Refactored page a bit.

* Fixed tests.

* Update public/app/features/profile/UserProfileEditTabs.tsx

Co-authored-by: Levente Balogh <balogh.levente.hu@gmail.com>

* Changed name.

* rename again.

---------

Co-authored-by: Levente Balogh <balogh.levente.hu@gmail.com>
This commit is contained in:
Marcus Andersson
2025-02-28 07:28:01 +01:00
committed by GitHub
co-authored by Levente Balogh
parent 806c043e45
commit ccc1477c7d
7 changed files with 176 additions and 149 deletions
+1
View File
@@ -550,6 +550,7 @@ export {
type PluginExtensionLink,
type PluginExtensionComponent,
type PluginExtensionComponentMeta,
type ComponentTypeWithExtensionMeta,
type PluginExtensionConfig,
type PluginExtensionFunction,
type PluginExtensionLinkConfig,
@@ -39,6 +39,10 @@ export type PluginExtensionComponent<Props = {}> = PluginExtensionBase & {
component: React.ComponentType<Props>;
};
export type ComponentTypeWithExtensionMeta<Props = {}> = React.ComponentType<Props> & {
meta: PluginExtensionComponentMeta;
};
export type PluginExtensionFunction<Signature = () => void> = PluginExtensionBase & {
type: PluginExtensionTypes.function;
fn: Signature;
@@ -1,4 +1,4 @@
import { PluginExtensionComponentMeta } from '@grafana/data';
import { type ComponentTypeWithExtensionMeta } from '@grafana/data';
export type UsePluginComponentsOptions = {
extensionPointId: string;
@@ -6,7 +6,7 @@ export type UsePluginComponentsOptions = {
};
export type UsePluginComponentsResult<Props = {}> = {
components: Array<React.ComponentType<Props> & { meta: PluginExtensionComponentMeta }>;
components: Array<ComponentTypeWithExtensionMeta<Props>>;
isLoading: boolean;
};
@@ -1,7 +1,12 @@
import { useMemo } from 'react';
import { useObservable } from 'react-use';
import { PluginExtensionComponentMeta, PluginExtensionTypes, usePluginContext } from '@grafana/data';
import {
type ComponentTypeWithExtensionMeta,
type PluginExtensionComponentMeta,
PluginExtensionTypes,
usePluginContext,
} from '@grafana/data';
import { UsePluginComponentsOptions, UsePluginComponentsResult } from '@grafana/runtime';
import { useAddedComponentsRegistry } from './ExtensionRegistriesContext';
@@ -25,7 +30,7 @@ export function usePluginComponents<Props extends object = {}>({
return useMemo(() => {
// For backwards compatibility we don't enable restrictions in production or when the hook is used in core Grafana.
const enableRestrictions = isGrafanaDevMode() && pluginContext;
const components: Array<React.ComponentType<Props> & { meta: PluginExtensionComponentMeta }> = [];
const components: Array<ComponentTypeWithExtensionMeta<Props>> = [];
const extensionsByPlugin: Record<string, number> = {};
const pluginId = pluginContext?.meta.id ?? '';
const pointLog = log.child({
@@ -84,7 +89,7 @@ export function usePluginComponents<Props extends object = {}>({
export function createComponentWithMeta<Props extends JSX.IntrinsicAttributes>(
registryItem: AddedComponentRegistryItem<Props>,
extensionPointId: string
): React.ComponentType<Props> & { meta: PluginExtensionComponentMeta } {
): ComponentTypeWithExtensionMeta<Props> {
const { component: Component, ...config } = registryItem;
function ComponentWithMeta(props: Props) {
return <Component {...props} />;
@@ -1,25 +1,18 @@
import { render, screen, waitFor, within } from '@testing-library/react';
import { screen, waitFor, within } from '@testing-library/react';
import userEvent, { PointerEventsCheckLevel } from '@testing-library/user-event';
import { render } from 'test/test-utils';
import { OrgRole, PluginExtensionComponent, PluginExtensionTypes } from '@grafana/data';
import { type ComponentTypeWithExtensionMeta, OrgRole } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { setPluginExtensionsHook, UsePluginExtensions } from '@grafana/runtime';
import * as useQueryParams from 'app/core/hooks/useQueryParams';
import { setPluginComponentsHook, usePluginComponents } from '@grafana/runtime';
import { TestProvider } from '../../../test/helpers/TestProvider';
import { backendSrv } from '../../core/services/backend_srv';
import { createComponentWithMeta } from '../plugins/extensions/usePluginComponents';
import { getMockTeam } from '../teams/__mocks__/teamMocks';
import { Props, UserProfileEditPage } from './UserProfileEditPage';
import { initialUserState } from './state/reducers';
const mockUseQueryParams = useQueryParams as { useQueryParams: typeof useQueryParams.useQueryParams };
jest.mock('app/core/hooks/useQueryParams', () => ({
__esModule: true,
useQueryParams: () => [{}],
}));
jest.mock('app/features/dashboard/api/dashboard_api', () => ({
getDashboardAPI: () => ({
getDashboardDTO: jest.fn().mockResolvedValue({}),
@@ -136,20 +129,23 @@ const _createTabName = (tab: ExtensionPointComponentTabs) => tab;
const _createTabContent = (tabId: ExtensionPointComponentId) => `this is settings for component ${tabId}`;
const generalTabName = 'General';
const generalTestId = 'user-profile-edit-page';
const tabOneName = _createTabName(ExtensionPointComponentTabs.One);
const tabTwoName = _createTabName(ExtensionPointComponentTabs.Two);
const _createPluginExtensionPointComponent = (
id: ExtensionPointComponentId,
tab: ExtensionPointComponentTabs
): PluginExtensionComponent => ({
id,
type: PluginExtensionTypes.component,
title: _createTabName(tab),
description: '', // description isn't used here..
component: () => <p>{_createTabContent(id)}</p>,
pluginId: 'grafana-plugin',
});
): ComponentTypeWithExtensionMeta =>
createComponentWithMeta<{}>(
{
title: _createTabName(tab),
description: '', // description isn't used here..
component: () => <p>{_createTabContent(id)}</p>,
pluginId: 'grafana-plugin',
},
id
);
const PluginExtensionPointComponent1 = _createPluginExtensionPointComponent(
ExtensionPointComponentId.One,
@@ -164,8 +160,8 @@ const PluginExtensionPointComponent3 = _createPluginExtensionPointComponent(
ExtensionPointComponentTabs.Two
);
async function getTestContext(overrides: Partial<Props & { extensions: PluginExtensionComponent[] }> = {}) {
const extensions = overrides.extensions || [];
async function getTestContext(overrides: Partial<Props & { components: ComponentTypeWithExtensionMeta[] }> = {}) {
const components = overrides.components || [];
jest.clearAllMocks();
const putSpy = jest.spyOn(backendSrv, 'put');
@@ -174,18 +170,12 @@ async function getTestContext(overrides: Partial<Props & { extensions: PluginExt
.mockResolvedValue({ timezone: 'UTC', homeDashboardUID: 'home-dashboard', theme: 'dark' });
const searchSpy = jest.spyOn(backendSrv, 'search').mockResolvedValue([]);
const getter: UsePluginExtensions<PluginExtensionComponent> = jest
.fn()
.mockReturnValue({ extensions, isLoading: false });
const getter: typeof usePluginComponents = jest.fn().mockReturnValue({ components, isLoading: false });
setPluginExtensionsHook(getter);
setPluginComponentsHook(getter);
const props = { ...defaultProps, ...overrides };
const { rerender } = render(
<TestProvider>
<UserProfileEditPage {...props} />
</TestProvider>
);
const { rerender } = render(<UserProfileEditPage {...props} />);
await waitFor(() => expect(props.initUserProfilePage).toHaveBeenCalledTimes(1));
@@ -334,7 +324,7 @@ describe('UserProfileEditPage', () => {
});
describe('and a plugin registers a component against the user profile settings extension point', () => {
const extensions = [
const components = [
PluginExtensionPointComponent1,
PluginExtensionPointComponent2,
PluginExtensionPointComponent3,
@@ -347,7 +337,7 @@ describe('UserProfileEditPage', () => {
});
it('should group registered components into tabs', async () => {
await getTestContext({ extensions });
await getTestContext({ components });
const { extensionPointTabs, extensionPointTab } = getSelectors();
const _assertTab = (tabId: string, isDefault = false) => {
@@ -363,10 +353,7 @@ describe('UserProfileEditPage', () => {
});
it('should change the active tab when a tab is clicked and update the "tab" query param', async () => {
const mockUpdateQueryParams = jest.fn();
mockUseQueryParams.useQueryParams = () => [{}, mockUpdateQueryParams];
await getTestContext({ extensions });
await getTestContext({ components });
const { extensionPointTab } = getSelectors();
/**
@@ -378,26 +365,24 @@ describe('UserProfileEditPage', () => {
const tabTwoContent = _createTabContent(ExtensionPointComponentId.Three);
// "General" should be the default content
expect(screen.queryByTestId(generalTestId)).toBeInTheDocument();
expect(screen.queryByText(tabOneContent1)).toBeNull();
expect(screen.queryByText(tabOneContent2)).toBeNull();
expect(screen.queryByText(tabTwoContent)).toBeNull();
await userEvent.click(extensionPointTab(tabOneName.toLowerCase()));
expect(mockUpdateQueryParams).toHaveBeenCalledTimes(1);
expect(mockUpdateQueryParams).toHaveBeenCalledWith({ tab: tabOneName.toLowerCase() });
expect(screen.queryByText(tabOneContent1)).not.toBeNull();
expect(screen.queryByText(tabOneContent2)).not.toBeNull();
expect(screen.queryByTestId(generalTestId)).toBeNull();
expect(screen.queryByText(tabOneContent1)).toBeInTheDocument();
expect(screen.queryByText(tabOneContent2)).toBeInTheDocument();
expect(screen.queryByText(tabTwoContent)).toBeNull();
mockUpdateQueryParams.mockClear();
await userEvent.click(extensionPointTab(tabTwoName.toLowerCase()));
expect(mockUpdateQueryParams).toHaveBeenCalledTimes(1);
expect(mockUpdateQueryParams).toHaveBeenCalledWith({ tab: tabTwoName.toLowerCase() });
expect(screen.queryByTestId(generalTestId)).toBeNull();
expect(screen.queryByText(tabOneContent1)).toBeNull();
expect(screen.queryByText(tabOneContent2)).toBeNull();
expect(screen.queryByText(tabTwoContent)).not.toBeNull();
expect(screen.queryByText(tabTwoContent)).toBeInTheDocument();
});
});
});
@@ -1,31 +1,20 @@
import { Fragment, useState } from 'react';
import { connect, ConnectedProps } from 'react-redux';
import { useMount } from 'react-use';
import { PluginExtensionComponent, PluginExtensionPoints } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { usePluginComponentExtensions } from '@grafana/runtime';
import { Tab, TabsBar, TabContent, Stack } from '@grafana/ui';
import { PluginExtensionPoints } from '@grafana/data';
import { usePluginComponents } from '@grafana/runtime';
import { Stack } from '@grafana/ui';
import { Page } from 'app/core/components/Page/Page';
import SharedPreferences from 'app/core/components/SharedPreferences/SharedPreferences';
import { useQueryParams } from 'app/core/hooks/useQueryParams';
import { t } from 'app/core/internationalization';
import { StoreState } from 'app/types';
import UserOrganizations from './UserOrganizations';
import UserProfileEditForm from './UserProfileEditForm';
import { UserProfileEditTabs } from './UserProfileEditTabs';
import UserSessions from './UserSessions';
import { UserTeams } from './UserTeams';
import { changeUserOrg, initUserProfilePage, revokeUserSession, updateUserProfile } from './state/actions';
const TAB_QUERY_PARAM = 'tab';
const GENERAL_SETTINGS_TAB = 'general';
type TabInfo = {
id: string;
title: string;
};
export interface OwnProps {}
function mapStateToProps(state: StoreState) {
@@ -68,95 +57,27 @@ export function UserProfileEditPage({
changeUserOrg,
updateUserProfile,
}: Props) {
const [queryParams, updateQueryParams] = useQueryParams();
const tabQueryParam = queryParams[TAB_QUERY_PARAM];
const [activeTab, setActiveTab] = useState<string>(
typeof tabQueryParam === 'string' ? tabQueryParam : GENERAL_SETTINGS_TAB
);
useMount(() => initUserProfilePage());
const { extensions } = usePluginComponentExtensions({ extensionPointId: PluginExtensionPoints.UserProfileTab });
const groupedExtensionComponents = extensions.reduce<Record<string, PluginExtensionComponent[]>>((acc, extension) => {
const { title } = extension;
if (acc[title]) {
acc[title].push(extension);
} else {
acc[title] = [extension];
}
return acc;
}, {});
const convertExtensionComponentTitleToTabId = (title: string) => title.toLowerCase();
const showTabs = extensions.length > 0;
const tabs: TabInfo[] = [
{
id: GENERAL_SETTINGS_TAB,
title: t('user-profile.tabs.general', 'General'),
},
...Object.keys(groupedExtensionComponents).map((title) => ({
id: convertExtensionComponentTitleToTabId(title),
title,
})),
];
const UserProfile = () => (
<Stack direction="column" gap={2}>
<UserProfileEditForm updateProfile={updateUserProfile} isSavingUser={isUpdating} user={user} />
<SharedPreferences resourceUri="user" preferenceType="user" />
<Stack direction="column" gap={6}>
<UserTeams isLoading={teamsAreLoading} teams={teams} />
<UserOrganizations isLoading={orgsAreLoading} setUserOrg={changeUserOrg} orgs={orgs} user={user} />
<UserSessions isLoading={sessionsAreLoading} revokeUserSession={revokeUserSession} sessions={sessions} />
</Stack>
</Stack>
);
const UserProfileWithTabs = () => (
<div data-testid={selectors.components.UserProfile.extensionPointTabs}>
<Stack direction="column" gap={2}>
<TabsBar>
{tabs.map(({ id, title }) => {
return (
<Tab
key={id}
label={title}
active={activeTab === id}
onChangeTab={() => {
setActiveTab(id);
updateQueryParams({ [TAB_QUERY_PARAM]: id });
}}
data-testid={selectors.components.UserProfile.extensionPointTab(id)}
/>
);
})}
</TabsBar>
<TabContent>
{activeTab === GENERAL_SETTINGS_TAB && <UserProfile />}
{Object.entries(groupedExtensionComponents).map(([title, pluginExtensionComponents]) => {
const tabId = convertExtensionComponentTitleToTabId(title);
if (activeTab === tabId) {
return (
<Fragment key={tabId}>
{pluginExtensionComponents.map(({ component: Component }, index) => (
<Component key={`${tabId}-${index}`} />
))}
</Fragment>
);
}
return null;
})}
</TabContent>
</Stack>
</div>
);
const { components, isLoading } = usePluginComponents({
extensionPointId: PluginExtensionPoints.UserProfileTab,
});
return (
<Page navId="profile/settings">
<Page.Contents isLoading={!user}>{showTabs ? <UserProfileWithTabs /> : <UserProfile />}</Page.Contents>
<Page.Contents isLoading={!user || isLoading}>
<UserProfileEditTabs components={components}>
<Stack direction="column" gap={2} data-testid="user-profile-edit-page">
<UserProfileEditForm updateProfile={updateUserProfile} isSavingUser={isUpdating} user={user} />
<SharedPreferences resourceUri="user" preferenceType="user" />
<Stack direction="column" gap={6}>
<UserTeams isLoading={teamsAreLoading} teams={teams} />
<UserOrganizations isLoading={orgsAreLoading} setUserOrg={changeUserOrg} orgs={orgs} user={user} />
<UserSessions isLoading={sessionsAreLoading} revokeUserSession={revokeUserSession} sessions={sessions} />
</Stack>
</Stack>
</UserProfileEditTabs>
</Page.Contents>
</Page>
);
}
@@ -0,0 +1,111 @@
import React, { type ComponentType, Fragment, type ReactElement, useCallback, useMemo } from 'react';
import { type ComponentTypeWithExtensionMeta, type UrlQueryValue } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { Stack, Tab, TabContent, TabsBar } from '@grafana/ui';
import { useQueryParams } from 'app/core/hooks/useQueryParams';
import { t } from 'app/core/internationalization';
const TAB_QUERY_PARAM = 'tab';
const GENERAL_SETTINGS_TAB = 'general';
type Props = {
children?: React.ReactNode;
components: ComponentTypeWithExtensionMeta[];
};
export function UserProfileEditTabs(props: Props): ReactElement {
const { children, components } = props;
const tabsById = useTabInfoById(components, children);
const [activeTab, setActiveTab] = useActiveTab(tabsById);
const showTabs = components.length > 0;
if (showTabs === false) {
return <>{children}</>;
}
return (
<div data-testid={selectors.components.UserProfile.extensionPointTabs}>
<Stack direction="column" gap={2}>
<TabsBar>
{Object.values(tabsById).map(({ tabId, title }) => {
return (
<Tab
key={tabId}
label={title}
active={activeTab?.tabId === tabId}
onChangeTab={() => setActiveTab(tabId)}
data-testid={selectors.components.UserProfile.extensionPointTab(tabId)}
/>
);
})}
</TabsBar>
<TabContent>
{Boolean(activeTab) && (
<Fragment key={activeTab?.tabId}>
{activeTab?.components.map((Component, index) => <Component key={`${activeTab?.tabId}-${index}`} />)}
</Fragment>
)}
</TabContent>
</Stack>
</div>
);
}
type TabInfo = {
title: string;
tabId: string;
components: ComponentType[];
};
function useTabInfoById(components: Props['components'], general: React.ReactNode): Record<string, TabInfo> {
return useMemo(() => {
const tabs: Record<string, TabInfo> = {
[GENERAL_SETTINGS_TAB]: {
title: t('user-profile.tabs.general', 'General'),
tabId: GENERAL_SETTINGS_TAB,
components: [() => <>{general}</>],
},
};
return components.reduce((acc, component) => {
const { title } = component.meta;
const tabId = convertTitleToTabId(title);
if (!acc[tabId]) {
acc[tabId] = {
title,
tabId,
components: [],
};
}
acc[tabId].components.push(component);
return acc;
}, tabs);
}, [components, general]);
}
function useActiveTab(tabs: Record<string, TabInfo>): [TabInfo | undefined, (tabId: string) => void] {
const [queryParams, updateQueryParams] = useQueryParams();
const activeTabId = convertQueryParamToTabId(queryParams[TAB_QUERY_PARAM]);
const activeTab = tabs[activeTabId];
const setActiveTab = useCallback(
(tabId: string) => updateQueryParams({ [TAB_QUERY_PARAM]: tabId }),
[updateQueryParams]
);
return [activeTab, setActiveTab];
}
function convertQueryParamToTabId(queryParam: UrlQueryValue) {
if (typeof queryParam !== 'string') {
return GENERAL_SETTINGS_TAB;
}
return convertTitleToTabId(queryParam);
}
function convertTitleToTabId(title: string) {
return title.toLowerCase();
}