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:
co-authored by
Levente Balogh
parent
806c043e45
commit
ccc1477c7d
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user