From ccc1477c7dbcba57f10e0063b1316274552d6e52 Mon Sep 17 00:00:00 2001 From: Marcus Andersson Date: Fri, 28 Feb 2025 07:28:01 +0100 Subject: [PATCH] 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 * Changed name. * rename again. --------- Co-authored-by: Levente Balogh --- packages/grafana-data/src/index.ts | 1 + .../src/types/pluginExtensions.ts | 4 + .../pluginExtensions/usePluginComponents.ts | 4 +- .../extensions/usePluginComponents.tsx | 11 +- .../profile/UserProfileEditPage.test.tsx | 75 +++++------ .../features/profile/UserProfileEditPage.tsx | 119 +++--------------- .../features/profile/UserProfileEditTabs.tsx | 111 ++++++++++++++++ 7 files changed, 176 insertions(+), 149 deletions(-) create mode 100644 public/app/features/profile/UserProfileEditTabs.tsx diff --git a/packages/grafana-data/src/index.ts b/packages/grafana-data/src/index.ts index 67c8483ed11..699ab07e244 100644 --- a/packages/grafana-data/src/index.ts +++ b/packages/grafana-data/src/index.ts @@ -550,6 +550,7 @@ export { type PluginExtensionLink, type PluginExtensionComponent, type PluginExtensionComponentMeta, + type ComponentTypeWithExtensionMeta, type PluginExtensionConfig, type PluginExtensionFunction, type PluginExtensionLinkConfig, diff --git a/packages/grafana-data/src/types/pluginExtensions.ts b/packages/grafana-data/src/types/pluginExtensions.ts index 331f6db43da..4bd286d4e3b 100644 --- a/packages/grafana-data/src/types/pluginExtensions.ts +++ b/packages/grafana-data/src/types/pluginExtensions.ts @@ -39,6 +39,10 @@ export type PluginExtensionComponent = PluginExtensionBase & { component: React.ComponentType; }; +export type ComponentTypeWithExtensionMeta = React.ComponentType & { + meta: PluginExtensionComponentMeta; +}; + export type PluginExtensionFunction void> = PluginExtensionBase & { type: PluginExtensionTypes.function; fn: Signature; diff --git a/packages/grafana-runtime/src/services/pluginExtensions/usePluginComponents.ts b/packages/grafana-runtime/src/services/pluginExtensions/usePluginComponents.ts index 649c54d0414..6417062ab90 100644 --- a/packages/grafana-runtime/src/services/pluginExtensions/usePluginComponents.ts +++ b/packages/grafana-runtime/src/services/pluginExtensions/usePluginComponents.ts @@ -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 = { - components: Array & { meta: PluginExtensionComponentMeta }>; + components: Array>; isLoading: boolean; }; diff --git a/public/app/features/plugins/extensions/usePluginComponents.tsx b/public/app/features/plugins/extensions/usePluginComponents.tsx index c3e406d5d23..e453b306d84 100644 --- a/public/app/features/plugins/extensions/usePluginComponents.tsx +++ b/public/app/features/plugins/extensions/usePluginComponents.tsx @@ -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({ 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 & { meta: PluginExtensionComponentMeta }> = []; + const components: Array> = []; const extensionsByPlugin: Record = {}; const pluginId = pluginContext?.meta.id ?? ''; const pointLog = log.child({ @@ -84,7 +89,7 @@ export function usePluginComponents({ export function createComponentWithMeta( registryItem: AddedComponentRegistryItem, extensionPointId: string -): React.ComponentType & { meta: PluginExtensionComponentMeta } { +): ComponentTypeWithExtensionMeta { const { component: Component, ...config } = registryItem; function ComponentWithMeta(props: Props) { return ; diff --git a/public/app/features/profile/UserProfileEditPage.test.tsx b/public/app/features/profile/UserProfileEditPage.test.tsx index 9f0adc7cee1..83152dee2e1 100644 --- a/public/app/features/profile/UserProfileEditPage.test.tsx +++ b/public/app/features/profile/UserProfileEditPage.test.tsx @@ -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: () =>

{_createTabContent(id)}

, - pluginId: 'grafana-plugin', -}); +): ComponentTypeWithExtensionMeta => + createComponentWithMeta<{}>( + { + title: _createTabName(tab), + description: '', // description isn't used here.. + component: () =>

{_createTabContent(id)}

, + pluginId: 'grafana-plugin', + }, + id + ); const PluginExtensionPointComponent1 = _createPluginExtensionPointComponent( ExtensionPointComponentId.One, @@ -164,8 +160,8 @@ const PluginExtensionPointComponent3 = _createPluginExtensionPointComponent( ExtensionPointComponentTabs.Two ); -async function getTestContext(overrides: Partial = {}) { - const extensions = overrides.extensions || []; +async function getTestContext(overrides: Partial = {}) { + const components = overrides.components || []; jest.clearAllMocks(); const putSpy = jest.spyOn(backendSrv, 'put'); @@ -174,18 +170,12 @@ async function getTestContext(overrides: Partial = 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( - - - - ); + const { rerender } = render(); 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(); }); }); }); diff --git a/public/app/features/profile/UserProfileEditPage.tsx b/public/app/features/profile/UserProfileEditPage.tsx index 4de0ebee3d4..c5f84818cb2 100644 --- a/public/app/features/profile/UserProfileEditPage.tsx +++ b/public/app/features/profile/UserProfileEditPage.tsx @@ -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( - typeof tabQueryParam === 'string' ? tabQueryParam : GENERAL_SETTINGS_TAB - ); - useMount(() => initUserProfilePage()); - const { extensions } = usePluginComponentExtensions({ extensionPointId: PluginExtensionPoints.UserProfileTab }); - - const groupedExtensionComponents = extensions.reduce>((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 = () => ( - - - - - - - - - - ); - - const UserProfileWithTabs = () => ( -
- - - {tabs.map(({ id, title }) => { - return ( - { - setActiveTab(id); - updateQueryParams({ [TAB_QUERY_PARAM]: id }); - }} - data-testid={selectors.components.UserProfile.extensionPointTab(id)} - /> - ); - })} - - - {activeTab === GENERAL_SETTINGS_TAB && } - {Object.entries(groupedExtensionComponents).map(([title, pluginExtensionComponents]) => { - const tabId = convertExtensionComponentTitleToTabId(title); - - if (activeTab === tabId) { - return ( - - {pluginExtensionComponents.map(({ component: Component }, index) => ( - - ))} - - ); - } - return null; - })} - - -
- ); + const { components, isLoading } = usePluginComponents({ + extensionPointId: PluginExtensionPoints.UserProfileTab, + }); return ( - {showTabs ? : } + + + + + + + + + + + + + ); } diff --git a/public/app/features/profile/UserProfileEditTabs.tsx b/public/app/features/profile/UserProfileEditTabs.tsx new file mode 100644 index 00000000000..035e0cf01e3 --- /dev/null +++ b/public/app/features/profile/UserProfileEditTabs.tsx @@ -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 ( +
+ + + {Object.values(tabsById).map(({ tabId, title }) => { + return ( + setActiveTab(tabId)} + data-testid={selectors.components.UserProfile.extensionPointTab(tabId)} + /> + ); + })} + + + {Boolean(activeTab) && ( + + {activeTab?.components.map((Component, index) => )} + + )} + + +
+ ); +} + +type TabInfo = { + title: string; + tabId: string; + components: ComponentType[]; +}; + +function useTabInfoById(components: Props['components'], general: React.ReactNode): Record { + return useMemo(() => { + const tabs: Record = { + [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): [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(); +}