frontend-plugin-api: intial page and plugin header actions
Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
* Copyright 2025 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { ReactNode } from 'react';
|
||||
import { createApiRef } from '../system';
|
||||
|
||||
/**
|
||||
* A single header action with its node ID for ordering.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface HeaderAction {
|
||||
/** The extension node ID, used for ordering across sources */
|
||||
nodeId: string;
|
||||
/** The rendered header action element */
|
||||
element: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* API for retrieving plugin-scoped header actions.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* Plugin-level header actions are provided via {@link @backstage/frontend-plugin-api#PluginHeaderActionBlueprint}
|
||||
* and automatically scoped to the providing plugin. The `nodeId` field on each
|
||||
* action is used together with `AppTreeApi` to determine the display order
|
||||
* across both page-level and plugin-level actions.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type HeaderActionsApi = {
|
||||
/**
|
||||
* Returns the header actions for a given plugin.
|
||||
*/
|
||||
getHeaderActions(pluginId: string): HeaderAction[];
|
||||
};
|
||||
|
||||
/**
|
||||
* The `ApiRef` of {@link HeaderActionsApi}.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const headerActionsApiRef = createApiRef<HeaderActionsApi>({
|
||||
id: 'core.header-actions',
|
||||
});
|
||||
@@ -49,3 +49,4 @@ export * from './RouteResolutionApi';
|
||||
export * from './StorageApi';
|
||||
export * from './AnalyticsApi';
|
||||
export * from './TranslationApi';
|
||||
export * from './HeaderActionsApi';
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { ReactNode } from 'react';
|
||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { IconElement } from '../icons/types';
|
||||
import { RouteRef } from '../routing';
|
||||
@@ -23,6 +24,38 @@ import {
|
||||
createExtensionInput,
|
||||
} from '../wiring';
|
||||
import { ExtensionBoundary, PageLayout, PageTab } from '../components';
|
||||
import { useApi } from '../apis/system';
|
||||
import { headerActionsApiRef } from '../apis/definitions/HeaderActionsApi';
|
||||
import { appTreeApiRef } from '../apis/definitions/AppTreeApi';
|
||||
import { HeaderAction } from '../apis/definitions/HeaderActionsApi';
|
||||
|
||||
/**
|
||||
* Hook that collects page-level and plugin-level header actions, then
|
||||
* sorts them by their position in the global app tree node list.
|
||||
*/
|
||||
function useHeaderActions(
|
||||
pageActions: HeaderAction[],
|
||||
pluginId: string,
|
||||
): ReactNode {
|
||||
const headerActionsApi = useApi(headerActionsApiRef);
|
||||
const appTreeApi = useApi(appTreeApiRef);
|
||||
|
||||
const pluginActions = headerActionsApi.getHeaderActions(pluginId);
|
||||
const allActions = [...pageActions, ...pluginActions];
|
||||
|
||||
if (allActions.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Build a position index from the global node ordering
|
||||
const nodeKeys = [...appTreeApi.getTree().tree.nodes.keys()];
|
||||
const orderIndex = new Map(nodeKeys.map((id, i) => [id, i]));
|
||||
allActions.sort(
|
||||
(a, b) => (orderIndex.get(a.nodeId) ?? 0) - (orderIndex.get(b.nodeId) ?? 0),
|
||||
);
|
||||
|
||||
return <>{allActions.map(a => a.element)}</>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates extensions that are routable React page components.
|
||||
@@ -39,6 +72,7 @@ export const PageBlueprint = createExtensionBlueprint({
|
||||
coreExtensionData.reactElement,
|
||||
coreExtensionData.title.optional(),
|
||||
]),
|
||||
headerActions: createExtensionInput([coreExtensionData.reactElement]),
|
||||
},
|
||||
output: [
|
||||
coreExtensionData.routePath,
|
||||
@@ -73,15 +107,24 @@ export const PageBlueprint = createExtensionBlueprint({
|
||||
node.spec.plugin.title ??
|
||||
node.spec.plugin.pluginId;
|
||||
const icon = params.icon ?? node.spec.plugin.icon;
|
||||
const pluginId = node.spec.plugin.pluginId;
|
||||
|
||||
const pageActions: HeaderAction[] = inputs.headerActions.map(action => ({
|
||||
nodeId: action.node.spec.id,
|
||||
element: action.get(coreExtensionData.reactElement),
|
||||
}));
|
||||
|
||||
yield coreExtensionData.routePath(config.path ?? params.path);
|
||||
if (params.loader) {
|
||||
const loader = params.loader;
|
||||
const PageContent = () => (
|
||||
<PageLayout title={title} icon={icon}>
|
||||
{ExtensionBoundary.lazy(node, loader)}
|
||||
</PageLayout>
|
||||
);
|
||||
const PageContent = () => {
|
||||
const headerActions = useHeaderActions(pageActions, pluginId);
|
||||
return (
|
||||
<PageLayout title={title} icon={icon} headerActions={headerActions}>
|
||||
{ExtensionBoundary.lazy(node, loader)}
|
||||
</PageLayout>
|
||||
);
|
||||
};
|
||||
yield coreExtensionData.reactElement(<PageContent />);
|
||||
} else if (inputs.pages.length > 0) {
|
||||
// Parent page with sub-pages - render header with tabs
|
||||
@@ -98,9 +141,15 @@ export const PageBlueprint = createExtensionBlueprint({
|
||||
|
||||
const PageContent = () => {
|
||||
const firstPagePath = inputs.pages[0]?.get(coreExtensionData.routePath);
|
||||
const headerActions = useHeaderActions(pageActions, pluginId);
|
||||
|
||||
return (
|
||||
<PageLayout title={title} icon={icon} tabs={tabs}>
|
||||
<PageLayout
|
||||
title={title}
|
||||
icon={icon}
|
||||
tabs={tabs}
|
||||
headerActions={headerActions}
|
||||
>
|
||||
<Routes>
|
||||
{firstPagePath && (
|
||||
<Route
|
||||
@@ -122,9 +171,17 @@ export const PageBlueprint = createExtensionBlueprint({
|
||||
|
||||
yield coreExtensionData.reactElement(<PageContent />);
|
||||
} else {
|
||||
yield coreExtensionData.reactElement(
|
||||
<PageLayout title={title} icon={icon} />,
|
||||
);
|
||||
const PageContent = () => {
|
||||
const headerActions = useHeaderActions(pageActions, pluginId);
|
||||
return (
|
||||
<PageLayout
|
||||
title={title}
|
||||
icon={icon}
|
||||
headerActions={headerActions}
|
||||
/>
|
||||
);
|
||||
};
|
||||
yield coreExtensionData.reactElement(<PageContent />);
|
||||
}
|
||||
if (params.routeRef) {
|
||||
yield coreExtensionData.routeRef(params.routeRef);
|
||||
|
||||
+4
-4
@@ -18,13 +18,13 @@ import { coreExtensionData, createExtensionBlueprint } from '../wiring';
|
||||
import { ExtensionBoundary } from '../components';
|
||||
|
||||
/**
|
||||
* Createx extensions that are routable React page components.
|
||||
* Creates extensions that render header actions in a page layout.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const HeaderActionBlueprint = createExtensionBlueprint({
|
||||
kind: 'header-action',
|
||||
attachTo: { id: 'app/routes', input: 'headerActions' },
|
||||
export const PageHeaderActionBlueprint = createExtensionBlueprint({
|
||||
kind: 'page-header-action',
|
||||
attachTo: { relative: { kind: 'page' }, input: 'headerActions' },
|
||||
output: [coreExtensionData.reactElement],
|
||||
|
||||
*factory(params: { loader: () => Promise<JSX.Element> }, { node }) {
|
||||
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* Copyright 2025 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
createExtensionBlueprint,
|
||||
createExtensionBlueprintParams,
|
||||
createExtensionDataRef,
|
||||
} from '../wiring';
|
||||
|
||||
const actionDataRef = createExtensionDataRef<
|
||||
() => Promise<JSX.Element>
|
||||
>().with({ id: 'core.plugin-header-action.loader' });
|
||||
|
||||
/**
|
||||
* Creates extensions that provide plugin-scoped header actions.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* These actions are automatically scoped to the plugin that provides them
|
||||
* and will appear in the header of all pages belonging to that plugin.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const PluginHeaderActionBlueprint = createExtensionBlueprint({
|
||||
kind: 'plugin-header-action',
|
||||
attachTo: { id: 'api:app/header-actions', input: 'actions' },
|
||||
output: [actionDataRef],
|
||||
dataRefs: {
|
||||
action: actionDataRef,
|
||||
},
|
||||
defineParams(params: { loader: () => Promise<JSX.Element> }) {
|
||||
return createExtensionBlueprintParams(params);
|
||||
},
|
||||
*factory(params) {
|
||||
yield actionDataRef(params.loader);
|
||||
},
|
||||
});
|
||||
@@ -23,4 +23,5 @@ export { AppRootElementBlueprint } from './AppRootElementBlueprint';
|
||||
export { NavItemBlueprint } from './NavItemBlueprint';
|
||||
export { PageBlueprint } from './PageBlueprint';
|
||||
export { SubPageBlueprint } from './SubPageBlueprint';
|
||||
export { HeaderActionBlueprint } from './HeaderActionBlueprint';
|
||||
export { PageHeaderActionBlueprint } from './PageHeaderActionBlueprint';
|
||||
export { PluginHeaderActionBlueprint } from './PluginHeaderActionBlueprint';
|
||||
|
||||
@@ -36,6 +36,7 @@ export interface PageTab {
|
||||
export interface PageLayoutProps {
|
||||
title?: string;
|
||||
icon?: IconElement;
|
||||
headerActions?: ReactNode;
|
||||
tabs?: PageTab[];
|
||||
children?: ReactNode;
|
||||
}
|
||||
@@ -44,7 +45,7 @@ export interface PageLayoutProps {
|
||||
* Default implementation of PageLayout using plain HTML elements
|
||||
*/
|
||||
function DefaultPageLayout(props: PageLayoutProps): JSX.Element {
|
||||
const { title, icon, tabs, children } = props;
|
||||
const { title, icon, headerActions, tabs, children } = props;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -77,6 +78,9 @@ function DefaultPageLayout(props: PageLayoutProps): JSX.Element {
|
||||
>
|
||||
{icon}
|
||||
{title}
|
||||
{headerActions && (
|
||||
<div style={{ marginLeft: 'auto' }}>{headerActions}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{tabs && tabs.length > 0 && (
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
/*
|
||||
* Copyright 2025 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { DefaultHeaderActionsApi } from './DefaultHeaderActionsApi';
|
||||
|
||||
describe('DefaultHeaderActionsApi', () => {
|
||||
it('should return actions for a specific plugin', async () => {
|
||||
const api = DefaultHeaderActionsApi.fromActions([
|
||||
{
|
||||
loader: async () => <button>Action A</button>,
|
||||
pluginId: 'plugin-a',
|
||||
nodeId: 'plugin-header-action:plugin-a/action-a',
|
||||
},
|
||||
{
|
||||
loader: async () => <button>Action B</button>,
|
||||
pluginId: 'plugin-b',
|
||||
nodeId: 'plugin-header-action:plugin-b/action-b',
|
||||
},
|
||||
]);
|
||||
|
||||
const actionsA = api.getHeaderActions('plugin-a');
|
||||
const actionsB = api.getHeaderActions('plugin-b');
|
||||
|
||||
expect(actionsA).toHaveLength(1);
|
||||
expect(actionsB).toHaveLength(1);
|
||||
expect(actionsA[0].nodeId).toBe(
|
||||
'plugin-header-action:plugin-a/action-a',
|
||||
);
|
||||
expect(actionsB[0].nodeId).toBe(
|
||||
'plugin-header-action:plugin-b/action-b',
|
||||
);
|
||||
|
||||
render(<>{actionsA[0].element}</>);
|
||||
await expect(
|
||||
screen.findByRole('button', { name: 'Action A' }),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should return an empty array for unknown plugins', () => {
|
||||
const api = DefaultHeaderActionsApi.fromActions([
|
||||
{
|
||||
loader: async () => <span>Action</span>,
|
||||
pluginId: 'plugin-a',
|
||||
nodeId: 'plugin-header-action:plugin-a/action',
|
||||
},
|
||||
]);
|
||||
|
||||
expect(api.getHeaderActions('unknown-plugin')).toEqual([]);
|
||||
});
|
||||
|
||||
it('should group multiple actions by plugin', async () => {
|
||||
const api = DefaultHeaderActionsApi.fromActions([
|
||||
{
|
||||
loader: async () => <button>First</button>,
|
||||
pluginId: 'plugin-a',
|
||||
nodeId: 'plugin-header-action:plugin-a/first',
|
||||
},
|
||||
{
|
||||
loader: async () => <button>Second</button>,
|
||||
pluginId: 'plugin-a',
|
||||
nodeId: 'plugin-header-action:plugin-a/second',
|
||||
},
|
||||
]);
|
||||
|
||||
const actions = api.getHeaderActions('plugin-a');
|
||||
expect(actions).toHaveLength(2);
|
||||
expect(actions[0].nodeId).toBe('plugin-header-action:plugin-a/first');
|
||||
expect(actions[1].nodeId).toBe('plugin-header-action:plugin-a/second');
|
||||
|
||||
render(
|
||||
<>
|
||||
{actions.map(a => (
|
||||
<span key={a.nodeId}>{a.element}</span>
|
||||
))}
|
||||
</>,
|
||||
);
|
||||
await expect(
|
||||
screen.findByRole('button', { name: 'First' }),
|
||||
).resolves.toBeInTheDocument();
|
||||
await expect(
|
||||
screen.findByRole('button', { name: 'Second' }),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
* Copyright 2025 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Suspense, lazy } from 'react';
|
||||
import {
|
||||
type HeaderActionsApi,
|
||||
type HeaderAction,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
|
||||
type ActionInput = {
|
||||
loader: () => Promise<JSX.Element>;
|
||||
pluginId: string;
|
||||
nodeId: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Default implementation of HeaderActionsApi.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export class DefaultHeaderActionsApi implements HeaderActionsApi {
|
||||
constructor(
|
||||
private readonly actionsByPlugin: Map<string, HeaderAction[]>,
|
||||
) {}
|
||||
|
||||
getHeaderActions(pluginId: string): HeaderAction[] {
|
||||
return this.actionsByPlugin.get(pluginId) ?? [];
|
||||
}
|
||||
|
||||
static fromActions(
|
||||
actions: Array<ActionInput>,
|
||||
): DefaultHeaderActionsApi {
|
||||
const actionsByPlugin = new Map<string, HeaderAction[]>();
|
||||
|
||||
for (const action of actions) {
|
||||
let pluginActions = actionsByPlugin.get(action.pluginId);
|
||||
if (!pluginActions) {
|
||||
pluginActions = [];
|
||||
actionsByPlugin.set(action.pluginId, pluginActions);
|
||||
}
|
||||
|
||||
const LazyAction = lazy(async () => {
|
||||
const element = await action.loader();
|
||||
return { default: () => element };
|
||||
});
|
||||
|
||||
pluginActions.push({
|
||||
nodeId: action.nodeId,
|
||||
element: (
|
||||
<Suspense key={action.nodeId} fallback={null}>
|
||||
<LazyAction />
|
||||
</Suspense>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
return new DefaultHeaderActionsApi(actionsByPlugin);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2025 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { DefaultHeaderActionsApi } from './DefaultHeaderActionsApi';
|
||||
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* Copyright 2025 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
PluginHeaderActionBlueprint,
|
||||
headerActionsApiRef,
|
||||
createExtensionInput,
|
||||
ApiBlueprint,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
import { DefaultHeaderActionsApi } from '../apis/HeaderActionsApi';
|
||||
|
||||
/**
|
||||
* Contains the plugin-scoped header actions installed into the app.
|
||||
*/
|
||||
export const HeaderActionsApi = ApiBlueprint.makeWithOverrides({
|
||||
name: 'header-actions',
|
||||
inputs: {
|
||||
actions: createExtensionInput([
|
||||
PluginHeaderActionBlueprint.dataRefs.action,
|
||||
]),
|
||||
},
|
||||
factory: (originalFactory, { inputs }) => {
|
||||
return originalFactory(defineParams =>
|
||||
defineParams({
|
||||
api: headerActionsApiRef,
|
||||
deps: {},
|
||||
factory: () => {
|
||||
return DefaultHeaderActionsApi.fromActions(
|
||||
inputs.actions.map(actionInput => ({
|
||||
loader: actionInput.get(
|
||||
PluginHeaderActionBlueprint.dataRefs.action,
|
||||
),
|
||||
pluginId: actionInput.node.spec.plugin.pluginId,
|
||||
nodeId: actionInput.node.spec.id,
|
||||
})),
|
||||
);
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -73,13 +73,18 @@ export const PageLayout = SwappableComponentBlueprint.make({
|
||||
define({
|
||||
component: SwappablePageLayout,
|
||||
loader: () => (props: PageLayoutProps) => {
|
||||
const { title, icon, tabs, children } = props;
|
||||
const { title, icon, headerActions, tabs, children } = props;
|
||||
return (
|
||||
<Flex
|
||||
direction="column"
|
||||
style={{ flexGrow: 1, minHeight: 0, gap: 0 }}
|
||||
>
|
||||
<Header title={title} icon={icon} tabs={tabs} />
|
||||
<Header
|
||||
title={title}
|
||||
icon={icon}
|
||||
tabs={tabs}
|
||||
customActions={headerActions}
|
||||
/>
|
||||
<Flex direction="column" style={{ flexGrow: 1, minHeight: 0 }}>
|
||||
{children}
|
||||
</Flex>
|
||||
|
||||
@@ -38,3 +38,4 @@ export {
|
||||
PageLayout,
|
||||
} from './components';
|
||||
export { PluginWrapperApi } from './PluginWrapperApi';
|
||||
export { HeaderActionsApi } from './HeaderActionsApi';
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
IconsApi,
|
||||
FeatureFlagsApi,
|
||||
PluginWrapperApi,
|
||||
HeaderActionsApi,
|
||||
TranslationsApi,
|
||||
oauthRequestDialogAppRootElement,
|
||||
alertDisplayAppRootElement,
|
||||
@@ -61,6 +62,7 @@ export const appPlugin = createFrontendPlugin({
|
||||
IconsApi,
|
||||
FeatureFlagsApi,
|
||||
PluginWrapperApi,
|
||||
HeaderActionsApi,
|
||||
TranslationsApi,
|
||||
DefaultSignInPage,
|
||||
oauthRequestDialogAppRootElement,
|
||||
|
||||
Reference in New Issue
Block a user