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
hackathon-2023-12-gg-grafana
Joey Orlando 1 year ago committed by GitHub
parent 517746dc83
commit ea7a179f2a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 1
      packages/grafana-data/src/types/pluginExtensions.ts
  2. 2
      packages/grafana-e2e-selectors/src/selectors/components.ts
  3. 144
      public/app/features/profile/UserProfileEditPage.test.tsx
  4. 119
      public/app/features/profile/UserProfileEditPage.tsx
  5. 3
      public/locales/de-DE/grafana.json
  6. 3
      public/locales/en-US/grafana.json
  7. 3
      public/locales/es-ES/grafana.json
  8. 3
      public/locales/fr-FR/grafana.json
  9. 3
      public/locales/pseudo-LOCALE/grafana.json
  10. 3
      public/locales/zh-Hans/grafana.json

@ -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 = {

@ -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',

@ -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<Props> = {}) {
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: () => <p>{_createTabContent(id)}</p>,
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<Props & { extensions: PluginExtensionComponent[] }> = {}) {
const extensions = overrides.extensions || [];
jest.clearAllMocks();
const putSpy = jest.spyOn(backendSrv, 'put');
const getSpy = jest
@ -102,6 +170,10 @@ async function getTestContext(overrides: Partial<Props> = {}) {
.mockResolvedValue({ timezone: 'UTC', homeDashboardUID: 'home-dashboard', theme: 'dark' });
const searchSpy = jest.spyOn(backendSrv, 'search').mockResolvedValue([]);
const getter: GetPluginExtensions<PluginExtensionComponent> = jest.fn().mockReturnValue({ extensions });
setPluginExtensionGetter(getter);
const props = { ...defaultProps, ...overrides };
const { rerender } = render(
<TestProvider>
@ -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();
});
});
});
});

@ -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<string>(
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<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 = 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 = () => (
<VerticalGroup spacing="md">
<UserProfileEditForm updateProfile={updateUserProfile} isSavingUser={isUpdating} user={user} />
<SharedPreferences resourceUri="user" preferenceType="user" />
<UserTeams isLoading={teamsAreLoading} teams={teams} />
<UserOrganizations isLoading={orgsAreLoading} setUserOrg={changeUserOrg} orgs={orgs} user={user} />
<UserSessions isLoading={sessionsAreLoading} revokeUserSession={revokeUserSession} sessions={sessions} />
</VerticalGroup>
);
const UserProfileWithTabs = () => (
<div data-testid={selectors.components.UserProfile.extensionPointTabs}>
<VerticalGroup spacing="md">
<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 (
<React.Fragment key={tabId}>
{pluginExtensionComponents.map(({ component: Component }, index) => (
<Component key={`${tabId}-${index}`} />
))}
</React.Fragment>
);
}
return null;
})}
</TabContent>
</VerticalGroup>
</div>
);
return (
<Page navId="profile/settings">
<Page.Contents isLoading={!user}>
<VerticalGroup spacing="md">
<UserProfileEditForm updateProfile={updateUserProfile} isSavingUser={isUpdating} user={user} />
<SharedPreferences resourceUri="user" preferenceType="user" />
<UserTeams isLoading={teamsAreLoading} teams={teams} />
<UserOrganizations isLoading={orgsAreLoading} setUserOrg={changeUserOrg} orgs={orgs} user={user} />
<UserSessions isLoading={sessionsAreLoading} revokeUserSession={revokeUserSession} sessions={sessions} />
</VerticalGroup>
</Page.Contents>
<Page.Contents isLoading={!user}>{showTabs ? <UserProfileWithTabs /> : <UserProfile />}</Page.Contents>
</Page>
);
}

@ -1298,6 +1298,9 @@
"name-error": "Name ist erforderlich",
"name-label": "Name",
"username-label": "Benutzername"
},
"tabs": {
"general": ""
}
},
"user-session": {

@ -1298,6 +1298,9 @@
"name-error": "Name is required",
"name-label": "Name",
"username-label": "Username"
},
"tabs": {
"general": "General"
}
},
"user-session": {

@ -1304,6 +1304,9 @@
"name-error": "El nombre es obligatorio",
"name-label": "Nombre",
"username-label": "Nombre de usuario"
},
"tabs": {
"general": ""
}
},
"user-session": {

@ -1304,6 +1304,9 @@
"name-error": "Un nom est obligatoire",
"name-label": "Nom",
"username-label": "Nom d’utilisateur"
},
"tabs": {
"general": ""
}
},
"user-session": {

@ -1298,6 +1298,9 @@
"name-error": "Ńämę įş řęqūįřęđ",
"name-label": "Ńämę",
"username-label": "Ůşęřʼnämę"
},
"tabs": {
"general": "Ğęʼnęřäľ"
}
},
"user-session": {

@ -1292,6 +1292,9 @@
"name-error": "姓名是必填项",
"name-label": "姓名",
"username-label": "用户名"
},
"tabs": {
"general": ""
}
},
"user-session": {

Loading…
Cancel
Save