From ea7a179f2ace28ae12a3df0c180367e0cf09073e Mon Sep 17 00:00:00 2001 From: Joey Orlando Date: Fri, 1 Dec 2023 10:18:27 -0500 Subject: [PATCH] Plugins: Add grafana/user/profile/tab plugin extension point (#77863) * add grafana/user/profile/settings plugin extension point * changes to support plugins having their own settings tabs * WIP * add comment * add unit tests * allow setting open tab based on tab query param * update name of extension point * add some more unit tests * address PR comments * PR comments --- .../src/types/pluginExtensions.ts | 1 + .../src/selectors/components.ts | 2 + .../profile/UserProfileEditPage.test.tsx | 144 +++++++++++++++++- .../features/profile/UserProfileEditPage.tsx | 119 +++++++++++++-- public/locales/de-DE/grafana.json | 3 + public/locales/en-US/grafana.json | 3 + public/locales/es-ES/grafana.json | 3 + public/locales/fr-FR/grafana.json | 3 + public/locales/pseudo-LOCALE/grafana.json | 3 + public/locales/zh-Hans/grafana.json | 3 + 10 files changed, 271 insertions(+), 13 deletions(-) diff --git a/packages/grafana-data/src/types/pluginExtensions.ts b/packages/grafana-data/src/types/pluginExtensions.ts index 6305a789fab..a29337bbe8d 100644 --- a/packages/grafana-data/src/types/pluginExtensions.ts +++ b/packages/grafana-data/src/types/pluginExtensions.ts @@ -121,6 +121,7 @@ export enum PluginExtensionPoints { DashboardPanelMenu = 'grafana/dashboard/panel/menu', DataSourceConfig = 'grafana/datasources/config', ExploreToolbarAction = 'grafana/explore/toolbar/action', + UserProfileTab = 'grafana/user/profile/tab', } export type PluginExtensionPanelContext = { diff --git a/packages/grafana-e2e-selectors/src/selectors/components.ts b/packages/grafana-e2e-selectors/src/selectors/components.ts index 84d8fd3a27b..210ab059ed2 100644 --- a/packages/grafana-e2e-selectors/src/selectors/components.ts +++ b/packages/grafana-e2e-selectors/src/selectors/components.ts @@ -421,6 +421,8 @@ export const Components = { preferencesSaveButton: 'data-testid-shared-prefs-save', orgsTable: 'data-testid-user-orgs-table', sessionsTable: 'data-testid-user-sessions-table', + extensionPointTabs: 'data-testid-extension-point-tabs', + extensionPointTab: (tabId: string) => `data-testid-extension-point-tab-${tabId}`, }, FileUpload: { inputField: 'data-testid-file-upload-input-field', diff --git a/public/app/features/profile/UserProfileEditPage.test.tsx b/public/app/features/profile/UserProfileEditPage.test.tsx index 18a744eb2c0..1f69b45c495 100644 --- a/public/app/features/profile/UserProfileEditPage.test.tsx +++ b/public/app/features/profile/UserProfileEditPage.test.tsx @@ -2,8 +2,10 @@ import { render, screen, waitFor, within } from '@testing-library/react'; import userEvent, { PointerEventsCheckLevel } from '@testing-library/user-event'; import React from 'react'; -import { OrgRole } from '@grafana/data'; +import { OrgRole, PluginExtensionComponent, PluginExtensionTypes } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; +import { setPluginExtensionGetter, GetPluginExtensions } from '@grafana/runtime'; +import * as useQueryParams from 'app/core/hooks/useQueryParams'; import { TestProvider } from '../../../test/helpers/TestProvider'; import { backendSrv } from '../../core/services/backend_srv'; @@ -13,6 +15,13 @@ 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: () => [{}], +})); + const defaultProps: Props = { ...initialUserState, user: { @@ -91,10 +100,69 @@ function getSelectors() { within(sessionsTable()).getByRole('row', { name: /now January 1, 2021 localhost chrome on mac os x 11/i, }), + /** + * using queryByTestId instead of getByTestId because the tabs are not always rendered + * and getByTestId throws an TestingLibraryElementError error if the element is not found + * whereas queryByTestId returns null if the element is not found. There are some test cases + * where we'd explicitly like to assert that the tabs are not rendered. + */ + extensionPointTabs: () => screen.queryByTestId(selectors.components.UserProfile.extensionPointTabs), + /** + * here lets use getByTestId because a specific tab should always be rendered within the tabs container + */ + extensionPointTab: (tabId: string) => + within(screen.getByTestId(selectors.components.UserProfile.extensionPointTabs)).getByTestId( + selectors.components.UserProfile.extensionPointTab(tabId) + ), }; } -async function getTestContext(overrides: Partial = {}) { +enum ExtensionPointComponentId { + One = '1', + Two = '2', + Three = '3', +} + +enum ExtensionPointComponentTabs { + One = '1', + Two = '2', +} + +const _createTabName = (tab: ExtensionPointComponentTabs) => `Tab ${tab}`; +const _createTabContent = (tabId: ExtensionPointComponentId) => `this is settings for component ${tabId}`; + +const generalTabName = 'General'; +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', +}); + +const PluginExtensionPointComponent1 = _createPluginExtensionPointComponent( + ExtensionPointComponentId.One, + ExtensionPointComponentTabs.One +); +const PluginExtensionPointComponent2 = _createPluginExtensionPointComponent( + ExtensionPointComponentId.Two, + ExtensionPointComponentTabs.One +); +const PluginExtensionPointComponent3 = _createPluginExtensionPointComponent( + ExtensionPointComponentId.Three, + ExtensionPointComponentTabs.Two +); + +async function getTestContext(overrides: Partial = {}) { + const extensions = overrides.extensions || []; + jest.clearAllMocks(); const putSpy = jest.spyOn(backendSrv, 'put'); const getSpy = jest @@ -102,6 +170,10 @@ async function getTestContext(overrides: Partial = {}) { .mockResolvedValue({ timezone: 'UTC', homeDashboardUID: 'home-dashboard', theme: 'dark' }); const searchSpy = jest.spyOn(backendSrv, 'search').mockResolvedValue([]); + const getter: GetPluginExtensions = jest.fn().mockReturnValue({ extensions }); + + setPluginExtensionGetter(getter); + const props = { ...defaultProps, ...overrides }; const { rerender } = render( @@ -254,5 +326,73 @@ describe('UserProfileEditPage', () => { expect(props.revokeUserSession).toHaveBeenCalledWith(0); }); }); + + describe('and a plugin registers a component against the user profile settings extension point', () => { + const extensions = [ + PluginExtensionPointComponent1, + PluginExtensionPointComponent2, + PluginExtensionPointComponent3, + ]; + + it('should not show tabs when no components are registered', async () => { + await getTestContext(); + const { extensionPointTabs } = getSelectors(); + expect(extensionPointTabs()).not.toBeInTheDocument(); + }); + + it('should group registered components into tabs', async () => { + await getTestContext({ extensions }); + const { extensionPointTabs, extensionPointTab } = getSelectors(); + + const _assertTab = (tabId: string, isDefault = false) => { + const tab = extensionPointTab(tabId); + expect(tab).toBeInTheDocument(); + expect(tab).toHaveAttribute('aria-selected', isDefault.toString()); + }; + + expect(extensionPointTabs()).toBeInTheDocument(); + _assertTab(generalTabName.toLowerCase(), true); + _assertTab(tabOneName.toLowerCase()); + _assertTab(tabTwoName.toLowerCase()); + }); + + 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 }); + const { extensionPointTab } = getSelectors(); + + /** + * Tab one has two extension components registered against it, they'll both be registered in the same tab + * Tab two only has one extension component registered against it. + */ + const tabOneContent1 = _createTabContent(ExtensionPointComponentId.One); + const tabOneContent2 = _createTabContent(ExtensionPointComponentId.Two); + const tabTwoContent = _createTabContent(ExtensionPointComponentId.Three); + + // "General" should be the default content + 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.queryByText(tabTwoContent)).toBeNull(); + + mockUpdateQueryParams.mockClear(); + await userEvent.click(extensionPointTab(tabTwoName.toLowerCase())); + + expect(mockUpdateQueryParams).toHaveBeenCalledTimes(1); + expect(mockUpdateQueryParams).toHaveBeenCalledWith({ tab: tabTwoName.toLowerCase() }); + expect(screen.queryByText(tabOneContent1)).toBeNull(); + expect(screen.queryByText(tabOneContent2)).toBeNull(); + expect(screen.queryByText(tabTwoContent)).not.toBeNull(); + }); + }); }); }); diff --git a/public/app/features/profile/UserProfileEditPage.tsx b/public/app/features/profile/UserProfileEditPage.tsx index 82ceabbab43..9b074206c89 100644 --- a/public/app/features/profile/UserProfileEditPage.tsx +++ b/public/app/features/profile/UserProfileEditPage.tsx @@ -1,10 +1,15 @@ -import React from 'react'; +import React, { useMemo, useState } from 'react'; import { connect, ConnectedProps } from 'react-redux'; import { useMount } from 'react-use'; -import { VerticalGroup } from '@grafana/ui'; +import { PluginExtensionComponent, PluginExtensionPoints } from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; +import { getPluginComponentExtensions } from '@grafana/runtime'; +import { Tab, TabsBar, TabContent, VerticalGroup } 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'; @@ -13,6 +18,14 @@ 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) { @@ -55,19 +68,103 @@ 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 extensionComponents = useMemo(() => { + const { extensions } = getPluginComponentExtensions({ + extensionPointId: PluginExtensionPoints.UserProfileTab, + context: {}, + }); + + return extensions; + }, []); + + const groupedExtensionComponents = extensionComponents.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 = extensionComponents.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; + })} + + +
+ ); + return ( - - - - - - - - - + {showTabs ? : } ); } diff --git a/public/locales/de-DE/grafana.json b/public/locales/de-DE/grafana.json index 7a6e234a877..1cbf1a4fcd6 100644 --- a/public/locales/de-DE/grafana.json +++ b/public/locales/de-DE/grafana.json @@ -1298,6 +1298,9 @@ "name-error": "Name ist erforderlich", "name-label": "Name", "username-label": "Benutzername" + }, + "tabs": { + "general": "" } }, "user-session": { diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 37ea6dd0e06..09ec0cf674f 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -1298,6 +1298,9 @@ "name-error": "Name is required", "name-label": "Name", "username-label": "Username" + }, + "tabs": { + "general": "General" } }, "user-session": { diff --git a/public/locales/es-ES/grafana.json b/public/locales/es-ES/grafana.json index 4f631fc0cf6..097bc39f59d 100644 --- a/public/locales/es-ES/grafana.json +++ b/public/locales/es-ES/grafana.json @@ -1304,6 +1304,9 @@ "name-error": "El nombre es obligatorio", "name-label": "Nombre", "username-label": "Nombre de usuario" + }, + "tabs": { + "general": "" } }, "user-session": { diff --git a/public/locales/fr-FR/grafana.json b/public/locales/fr-FR/grafana.json index 87c722bd604..b4b24f26fb3 100644 --- a/public/locales/fr-FR/grafana.json +++ b/public/locales/fr-FR/grafana.json @@ -1304,6 +1304,9 @@ "name-error": "Un nom est obligatoire", "name-label": "Nom", "username-label": "Nom d’utilisateur" + }, + "tabs": { + "general": "" } }, "user-session": { diff --git a/public/locales/pseudo-LOCALE/grafana.json b/public/locales/pseudo-LOCALE/grafana.json index a988b6137dd..afbd2dc4898 100644 --- a/public/locales/pseudo-LOCALE/grafana.json +++ b/public/locales/pseudo-LOCALE/grafana.json @@ -1298,6 +1298,9 @@ "name-error": "Ńämę įş řęqūįřęđ", "name-label": "Ńämę", "username-label": "Ůşęřʼnämę" + }, + "tabs": { + "general": "Ğęʼnęřäľ" } }, "user-session": { diff --git a/public/locales/zh-Hans/grafana.json b/public/locales/zh-Hans/grafana.json index ac1c9879c42..0d63eb4a5a4 100644 --- a/public/locales/zh-Hans/grafana.json +++ b/public/locales/zh-Hans/grafana.json @@ -1292,6 +1292,9 @@ "name-error": "姓名是必填项", "name-label": "姓名", "username-label": "用户名" + }, + "tabs": { + "general": "" } }, "user-session": {