Merge pull request #29020 from backstage/rugvip/dialogs

frontend-plugin-api: add new DialogApi
This commit is contained in:
Patrik Oldsberg
2025-03-05 21:26:34 +01:00
committed by GitHub
15 changed files with 652 additions and 1 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/frontend-plugin-api': patch
---
Added a new Utility API, `DialogApi`, which can be used to show dialogs in the React tree that can collect input from the user.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-app': patch
---
Added implementation of the new `DialogApi`.
@@ -269,6 +269,7 @@ describe('createApp', () => {
expect(String(tree.root)).toMatchInlineSnapshot(`
"<root out=[core.reactElement]>
apis [
<api:app/dialog out=[core.api.factory] />
<api:app/discovery out=[core.api.factory] />
<api:app/alert out=[core.api.factory] />
<api:app/analytics out=[core.api.factory] />
@@ -328,6 +329,7 @@ describe('createApp', () => {
elements [
<app-root-element:app/oauth-request-dialog out=[core.reactElement] />
<app-root-element:app/alert-display out=[core.reactElement] />
<app-root-element:app/dialog-display out=[core.reactElement] />
]
signInPage [
<sign-in-page:app />
@@ -782,6 +782,38 @@ export { createTranslationRef };
export { createTranslationResource };
// @public
export interface DialogApi {
show<TResult = {}>(
elementOrComponent:
| JSX.Element
| ((props: {
dialog: DialogApiDialog<TResult | undefined>;
}) => JSX.Element),
): DialogApiDialog<TResult | undefined>;
showModal<TResult = {}>(
elementOrComponent:
| JSX.Element
| ((props: { dialog: DialogApiDialog<TResult> }) => JSX.Element),
): DialogApiDialog<TResult>;
}
// @public
export interface DialogApiDialog<TResult = unknown> {
close(
...args: undefined extends TResult ? [result?: TResult] : [result: TResult]
): void;
result(): Promise<TResult>;
update(
elementOrComponent:
| React.JSX.Element
| ((props: { dialog: DialogApiDialog<TResult> }) => JSX.Element),
): void;
}
// @public
export const dialogApiRef: ApiRef<DialogApi>;
export { DiscoveryApi };
export { discoveryApiRef };
@@ -0,0 +1,178 @@
/*
* 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 { createApiRef } from '@backstage/core-plugin-api';
/**
* A handle for an open dialog that can be used to interact with it.
*
* @remarks
*
* Dialogs can be opened using either {@link DialogApi.show} or {@link DialogApi.showModal}.
*
* @public
*/
export interface DialogApiDialog<TResult = unknown> {
/**
* Closes the dialog with that provided result.
*
* @remarks
*
* If the dialog is a modal dialog a result must always be provided. If it's a regular dialog then passing a result is optional.
*/
close(
...args: undefined extends TResult ? [result?: TResult] : [result: TResult]
): void;
/**
* Replaces the content of the dialog with the provided element or component, causing it to be rerenedered.
*/
update(
elementOrComponent:
| React.JSX.Element
| ((props: { dialog: DialogApiDialog<TResult> }) => JSX.Element),
): void;
/**
* Wait until the dialog is closed and return the result.
*
* @remarks
*
* If the dialog is a modal dialog a result will always be returned. If it's a regular dialog then the result may be `undefined`.
*/
result(): Promise<TResult>;
}
/**
* A Utility API for showing dialogs that render in the React tree and return a result.
*
* @public
*/
export interface DialogApi {
/**
* Opens a modal dialog and returns a handle to it.
*
* @remarks
*
* This dialog can be closed by calling the `close` method on the returned handle, optionally providing a result.
* The dialog can also be closed by the user by clicking the backdrop or pressing the escape key.
*
* If the dialog is closed without a result, the result will be `undefined`.
*
* @example
*
* ### Example with inline dialog content
* ```tsx
* const dialog = dialogApi.show<boolean>(
* <DialogContent>
* <DialogTitle>Are you sure?</DialogTitle>
* <DialogActions>
* <Button onClick={() => dialog.close(true)}>Yes</Button>
* <Button onClick={() => dialog.close(false)}>No</Button>
* </DialogActions>
* </DialogContent>
* );
* const result = await dialog.result();
* ```
*
* @example
*
* ### Example with separate dialog component
* ```tsx
* function CustomDialog({ dialog }: { dialog: DialogApiDialog<boolean | undefined> }) {
* return (
* <DialogContent>
* <DialogTitle>Are you sure?</DialogTitle>
* <DialogActions>
* <Button onClick={() => dialog.close(true)}>Yes</Button>
* <Button onClick={() => dialog.close(false)}>No</Button>
* </DialogActions>
* </DialogContent>
* )
* }
* const result = await dialogApi.show(CustomDialog).result();
* ```
*
* @param elementOrComponent - The element or component to render in the dialog. If a component is provided, it will be provided with a `dialog` prop that contains the dialog handle.
* @public
*/
show<TResult = {}>(
elementOrComponent:
| JSX.Element
| ((props: {
dialog: DialogApiDialog<TResult | undefined>;
}) => JSX.Element),
): DialogApiDialog<TResult | undefined>;
/**
* Opens a modal dialog and returns a handle to it.
*
* @remarks
*
* This dialog can not be closed in any other way than calling the `close` method on the returned handle and providing a result.
*
* @example
*
* ### Example with inline dialog content
* ```tsx
* const dialog = dialogApi.showModal<boolean>(
* <DialogContent>
* <DialogTitle>Are you sure?</DialogTitle>
* <DialogActions>
* <Button onClick={() => dialog.close(true)}>Yes</Button>
* <Button onClick={() => dialog.close(false)}>No</Button>
* </DialogActions>
* </DialogContent>
* );
* const result = await dialog.result();
* ```
*
* @example
*
* ### Example with separate dialog component
* ```tsx
* function CustomDialog({ dialog }: { dialog: DialogApiDialog<boolean> }) {
* return (
* <DialogContent>
* <DialogTitle>Are you sure?</DialogTitle>
* <DialogActions>
* <Button onClick={() => dialog.close(true)}>Yes</Button>
* <Button onClick={() => dialog.close(false)}>No</Button>
* </DialogActions>
* </DialogContent>
* )
* }
* const result = await dialogApi.showModal(CustomDialog).result();
* ```
*
* @param elementOrComponent - The element or component to render in the dialog. If a component is provided, it will be provided with a `dialog` prop that contains the dialog handle.
* @public
*/
showModal<TResult = {}>(
elementOrComponent:
| JSX.Element
| ((props: { dialog: DialogApiDialog<TResult> }) => JSX.Element),
): DialogApiDialog<TResult>;
}
/**
* The `ApiRef` of {@link DialogApi}.
*
* @public
*/
export const dialogApiRef = createApiRef<DialogApi>({
id: 'core.dialog',
});
@@ -42,6 +42,7 @@ export * from './FeatureFlagsApi';
export * from './FetchApi';
export * from './IconsApi';
export * from './IdentityApi';
export * from './DialogApi';
export * from './OAuthRequestApi';
export * from './RouteResolutionApi';
export * from './StorageApi';
+1
View File
@@ -43,6 +43,7 @@
"@backstage/integration-react": "workspace:^",
"@backstage/plugin-permission-react": "workspace:^",
"@backstage/theme": "workspace:^",
"@backstage/types": "workspace:^",
"@material-ui/core": "^4.9.13",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "^4.0.0-alpha.61",
+38
View File
@@ -358,6 +358,21 @@ const appPlugin: FrontendPlugin<
factory: AnyApiFactory;
};
}>;
'api:app/dialog': ExtensionDefinition<{
kind: 'api';
name: 'dialog';
config: {};
configInput: {};
output: ConfigurableExtensionDataRef<
AnyApiFactory,
'core.api.factory',
{}
>;
inputs: {};
params: {
factory: AnyApiFactory;
};
}>;
'api:app/discovery': ExtensionDefinition<{
kind: 'api';
name: 'discovery';
@@ -698,6 +713,29 @@ const appPlugin: FrontendPlugin<
element: JSX.Element | (() => JSX.Element);
};
}>;
'app-root-element:app/dialog-display': ExtensionDefinition<{
config: {};
configInput: {};
output: ConfigurableExtensionDataRef<
JSX_2.Element,
'core.reactElement',
{}
>;
inputs: {
[x: string]: ExtensionInput<
AnyExtensionDataRef,
{
optional: boolean;
singleton: boolean;
}
>;
};
kind: 'app-root-element';
name: 'dialog-display';
params: {
element: JSX.Element | (() => JSX.Element);
};
}>;
'app-root-element:app/oauth-request-dialog': ExtensionDefinition<{
kind: 'app-root-element';
name: 'oauth-request-dialog';
+70
View File
@@ -0,0 +1,70 @@
/*
* 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 { DialogApi, DialogApiDialog } from '@backstage/frontend-plugin-api';
export type OnShowDialog = (options: {
component: (props: { dialog: DialogApiDialog<any> }) => React.JSX.Element;
modal: boolean;
}) => DialogApiDialog;
/**
* Default implementation for the {@link DialogApi}.
* @internal
*/
export class DefaultDialogApi implements DialogApi {
#onShow?: OnShowDialog;
show<TResult = {}>(
elementOrComponent:
| JSX.Element
| ((props: {
dialog: DialogApiDialog<TResult | undefined>;
}) => JSX.Element),
): DialogApiDialog<TResult | undefined> {
if (!this.#onShow) {
throw new Error('Dialog API has not been connected');
}
return this.#onShow({
component:
typeof elementOrComponent === 'function'
? elementOrComponent
: () => elementOrComponent,
modal: false,
}) as DialogApiDialog<TResult | undefined>;
}
showModal<TResult = {}>(
elementOrComponent:
| JSX.Element
| ((props: { dialog: DialogApiDialog<TResult> }) => JSX.Element),
): DialogApiDialog<TResult> {
if (!this.#onShow) {
throw new Error('Dialog API has not been connected');
}
return this.#onShow({
component:
typeof elementOrComponent === 'function'
? elementOrComponent
: () => elementOrComponent,
modal: true,
}) as DialogApiDialog<TResult>;
}
connect(onShow: OnShowDialog): void {
this.#onShow = onShow;
}
}
+12 -1
View File
@@ -60,7 +60,7 @@ import {
atlassianAuthApiRef,
vmwareCloudAuthApiRef,
} from '@backstage/core-plugin-api';
import { ApiBlueprint } from '@backstage/frontend-plugin-api';
import { ApiBlueprint, dialogApiRef } from '@backstage/frontend-plugin-api';
import {
ScmAuth,
ScmIntegrationsApi,
@@ -70,8 +70,19 @@ import {
permissionApiRef,
IdentityPermissionApi,
} from '@backstage/plugin-permission-react';
import { DefaultDialogApi } from './apis/DefaultDialogApi';
export const apis = [
ApiBlueprint.make({
name: 'dialog',
params: {
factory: createApiFactory({
api: dialogApiRef,
deps: {},
factory: () => new DefaultDialogApi(),
}),
},
}),
ApiBlueprint.make({
name: 'discovery',
params: {
@@ -0,0 +1,175 @@
/*
* 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 { renderInTestApp } from '@backstage/frontend-test-utils';
import React, { act, useEffect } from 'react';
import {
AppRootElementBlueprint,
DialogApi,
DialogApiDialog,
dialogApiRef,
} from '@backstage/frontend-plugin-api';
import { createDeferred } from '@backstage/types';
import userEvent from '@testing-library/user-event';
async function withDialogApi<T>(
callback: (dialogApi: DialogApi) => Promise<T>,
) {
const deferred = createDeferred<DialogApi>();
await renderInTestApp(<div />, {
extensions: [
AppRootElementBlueprint.makeWithOverrides({
name: 'derp',
factory(originalFactory, { apis }) {
function TestComponent() {
useEffect(() => {
deferred.resolve(apis.get(dialogApiRef)!);
}, []);
return <div />;
}
return originalFactory({ element: <TestComponent /> });
},
}),
],
});
return await callback(await deferred);
}
describe('DialogDisplay', () => {
function AutoDialog({
dialog,
result,
}: {
dialog: DialogApiDialog<string | undefined>;
result?: string;
}) {
useEffect(() => {
if (result) {
dialog.close(result);
}
}, [dialog, result]);
return <div />;
}
it('should render a simple dialog', async () => {
const result = await withDialogApi(async dialogApi => {
const dialog = await act(() => dialogApi.show<string>(<div>Test</div>));
dialog.close('test');
return dialog.result();
});
expect(result).toBe('test');
});
it('should allow dialog to be updated', async () => {
const result = await withDialogApi(async dialogApi => {
const dialog = await act(() => dialogApi.show(AutoDialog));
setTimeout(async () => {
await act(async () => {
dialog.update(props => <AutoDialog {...props} result="test2" />);
});
}, 100);
return dialog.result();
});
expect(result).toBe('test2');
});
it('should allow dialog to be closed by pressing escape', async () => {
const result = await withDialogApi(async dialogApi => {
const dialog = await act(() => dialogApi.show(AutoDialog));
setTimeout(async () => {
await userEvent.keyboard('{Escape}');
}, 100);
return dialog.result();
});
expect(result).toBe(undefined);
});
it('should allow a stack of dialogs', async () => {
const result = await withDialogApi(async dialogApi => {
const dialog1 = await act(() => dialogApi.show(AutoDialog));
const dialog2 = await act(() => dialogApi.show(AutoDialog));
const dialog3 = await act(() => dialogApi.show(AutoDialog));
setTimeout(async () => {
await act(async () => {
dialog3.close('test3');
dialog1.close('test1');
dialog2.close('test2');
});
}, 100);
return Promise.all([
dialog1.result(),
dialog2.result(),
dialog3.result(),
]);
});
expect(result).toEqual(['test1', 'test2', 'test3']);
});
it('should only cancel one dialog at a time', async () => {
const result = await withDialogApi(async dialogApi => {
const dialog1 = await act(() => dialogApi.show(AutoDialog));
const dialog2 = await act(() => dialogApi.show(AutoDialog));
const dialog3 = await act(() => dialogApi.show(AutoDialog));
setTimeout(async () => {
await userEvent.keyboard('{Escape}');
await act(async () => {
dialog1.close('test1');
dialog2.close('test2');
dialog3.close('test3');
});
}, 100);
return Promise.all([
dialog1.result(),
dialog2.result(),
dialog3.result(),
]);
});
expect(result).toEqual(['test1', 'test2', undefined]);
});
it('should not allow modal dialog to be closed by pressing escape', async () => {
const result = await withDialogApi(async dialogApi => {
const dialog = await act(() => dialogApi.showModal(AutoDialog));
setTimeout(async () => {
await userEvent.keyboard('{Escape}');
setTimeout(async () => {
await act(async () => {
dialog.close('test');
});
}, 100);
}, 100);
return dialog.result();
});
expect(result).toBe('test');
});
});
@@ -0,0 +1,129 @@
/*
* 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 React, { useEffect, useState } from 'react';
import {
AppRootElementBlueprint,
DialogApi,
DialogApiDialog,
dialogApiRef,
} from '@backstage/frontend-plugin-api';
import { createDeferred } from '@backstage/types';
import { OnShowDialog } from '../apis/DefaultDialogApi';
import Dialog from '@material-ui/core/Dialog';
let dialogId = 0;
function getDialogId() {
dialogId += 1;
return dialogId.toString(36);
}
type DialogState = DialogApiDialog & {
id: string;
modal: boolean;
};
/**
* The other half of the default implementation of the {@link DialogApi}.
*
* This component is responsible for rendering the dialogs in the React tree and managing a stack of dialogs.
* It expects the implementation of the {@link DialogApi} to be the `DefaultDialogApi`. If one is replaced the other must be too.
* @internal
*/
function DialogDisplay({
dialogApi,
}: {
dialogApi: DialogApi & { connect(onShow: OnShowDialog): void };
}) {
const [dialogs, setDialogs] = useState<
{ dialog: DialogState; element: React.JSX.Element }[]
>([]);
useEffect(() => {
dialogApi.connect(options => {
const id = getDialogId();
const deferred = createDeferred<unknown>();
const dialog: DialogState = {
id,
modal: options.modal,
close(result) {
deferred.resolve(result);
setDialogs(ds => ds.filter(d => d.dialog.id !== id));
},
update(ElementOrComponent) {
const element =
typeof ElementOrComponent === 'function' ? (
<ElementOrComponent dialog={dialog} />
) : (
ElementOrComponent
);
setDialogs(ds =>
ds.map(d => (d.dialog.id === id ? { dialog, element } : d)),
);
},
async result() {
return deferred;
},
};
const element = <options.component dialog={dialog} />;
setDialogs(ds => [...ds, { dialog, element }]);
return dialog;
});
}, [dialogApi]);
if (dialogs.length > 0) {
const lastDialog = dialogs[dialogs.length - 1];
return (
<Dialog
open
onClose={() => {
if (!lastDialog.dialog.modal) {
lastDialog.dialog.close();
}
}}
>
{lastDialog.element}
</Dialog>
);
}
return null;
}
export const dialogDisplayAppRootElement =
AppRootElementBlueprint.makeWithOverrides({
name: 'dialog-display',
factory(originalFactory, { apis }) {
const dialogApi = apis.get(dialogApiRef);
if (!isInternalDialogApi(dialogApi)) {
return originalFactory({
element: <React.Fragment />,
});
}
return originalFactory({
element: <DialogDisplay dialogApi={dialogApi} />,
});
},
});
function isInternalDialogApi(
dialogApi?: DialogApi,
): dialogApi is DialogApi & { connect(onShow: OnShowDialog): void } {
if (!dialogApi) {
return false;
}
return 'connect' in dialogApi;
}
+1
View File
@@ -25,6 +25,7 @@ export { IconsApi } from './IconsApi';
export { FeatureFlagsApi } from './FeatureFlagsApi';
export { TranslationsApi } from './TranslationsApi';
export { DefaultSignInPage } from './DefaultSignInPage';
export { dialogDisplayAppRootElement } from './DialogDisplay';
export {
DefaultProgressComponent,
DefaultErrorBoundaryComponent,
+2
View File
@@ -35,6 +35,7 @@ import {
oauthRequestDialogAppRootElement,
alertDisplayAppRootElement,
DefaultSignInPage,
dialogDisplayAppRootElement,
} from './extensions';
import { apis } from './defaultApis';
@@ -62,5 +63,6 @@ export const appPlugin = createFrontendPlugin({
DefaultSignInPage,
oauthRequestDialogAppRootElement,
alertDisplayAppRootElement,
dialogDisplayAppRootElement,
],
});
+1
View File
@@ -5099,6 +5099,7 @@ __metadata:
"@backstage/integration-react": "workspace:^"
"@backstage/plugin-permission-react": "workspace:^"
"@backstage/theme": "workspace:^"
"@backstage/types": "workspace:^"
"@material-ui/core": ^4.9.13
"@material-ui/icons": ^4.9.1
"@material-ui/lab": ^4.0.0-alpha.61