From 5aa7f2cde5cbf40fba5542b26bf5c62452b110dc Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 1 Mar 2025 19:39:24 +0100 Subject: [PATCH 1/7] frontend-plugin-api: added DialogApi definition Signed-off-by: Patrik Oldsberg --- .changeset/cuddly-apricots-fetch.md | 5 + .../src/apis/definitions/DialogApi.ts | 178 ++++++++++++++++++ .../src/apis/definitions/index.ts | 1 + 3 files changed, 184 insertions(+) create mode 100644 .changeset/cuddly-apricots-fetch.md create mode 100644 packages/frontend-plugin-api/src/apis/definitions/DialogApi.ts diff --git a/.changeset/cuddly-apricots-fetch.md b/.changeset/cuddly-apricots-fetch.md new file mode 100644 index 0000000000..77126268a8 --- /dev/null +++ b/.changeset/cuddly-apricots-fetch.md @@ -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. diff --git a/packages/frontend-plugin-api/src/apis/definitions/DialogApi.ts b/packages/frontend-plugin-api/src/apis/definitions/DialogApi.ts new file mode 100644 index 0000000000..ce19cb8d26 --- /dev/null +++ b/packages/frontend-plugin-api/src/apis/definitions/DialogApi.ts @@ -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 { + /** + * Closes the dialog with thet 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 }) => 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; +} + +/** + * 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( + * + * Are you sure? + * + * + * + * + * + * ); + * const result = await dialog.result(); + * ``` + * + * @example + * + * ### Example with separate dialog component + * ```tsx + * function CustomDialog({ dialog }: { dialog: DialogApiDialog }) { + * return ( + * + * Are you sure? + * + * + * + * + * + * ) + * } + * 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( + elementOrComponent: + | JSX.Element + | ((props: { + dialog: DialogApiDialog; + }) => JSX.Element), + ): DialogApiDialog; + + /** + * 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( + * + * Are you sure? + * + * + * + * + * + * ); + * const result = await dialog.result(); + * ``` + * + * @example + * + * ### Example with separate dialog component + * ```tsx + * function CustomDialog({ dialog }: { dialog: DialogApiDialog }) { + * return ( + * + * Are you sure? + * + * + * + * + * + * ) + * } + * 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( + elementOrComponent: + | JSX.Element + | ((props: { dialog: DialogApiDialog }) => JSX.Element), + ): DialogApiDialog; +} + +/** + * The `ApiRef` of {@link DialogApi}. + * + * @public + */ +export const dialogApiRef = createApiRef({ + id: 'core.dialog', +}); diff --git a/packages/frontend-plugin-api/src/apis/definitions/index.ts b/packages/frontend-plugin-api/src/apis/definitions/index.ts index d766a8dfdf..0da23fcacb 100644 --- a/packages/frontend-plugin-api/src/apis/definitions/index.ts +++ b/packages/frontend-plugin-api/src/apis/definitions/index.ts @@ -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'; From 0aa9d8275193b7a64588c5ff442da02cfe00c6f3 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 1 Mar 2025 19:43:35 +0100 Subject: [PATCH 2/7] plugins/app: add default implementation of DialogApi Signed-off-by: Patrik Oldsberg --- .changeset/lucky-ducks-attend.md | 5 + plugins/app/package.json | 1 + plugins/app/src/apis/DefaultDialogApi.ts | 70 ++++++++++ plugins/app/src/defaultApis.ts | 13 +- plugins/app/src/extensions/DialogDisplay.tsx | 127 +++++++++++++++++++ plugins/app/src/extensions/index.ts | 1 + plugins/app/src/plugin.ts | 2 + yarn.lock | 1 + 8 files changed, 219 insertions(+), 1 deletion(-) create mode 100644 .changeset/lucky-ducks-attend.md create mode 100644 plugins/app/src/apis/DefaultDialogApi.ts create mode 100644 plugins/app/src/extensions/DialogDisplay.tsx diff --git a/.changeset/lucky-ducks-attend.md b/.changeset/lucky-ducks-attend.md new file mode 100644 index 0000000000..6c1fd010b0 --- /dev/null +++ b/.changeset/lucky-ducks-attend.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-app': patch +--- + +Added implementation of the new `DialogApi`. diff --git a/plugins/app/package.json b/plugins/app/package.json index 3b2f516d9f..86007ac2d0 100644 --- a/plugins/app/package.json +++ b/plugins/app/package.json @@ -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", diff --git a/plugins/app/src/apis/DefaultDialogApi.ts b/plugins/app/src/apis/DefaultDialogApi.ts new file mode 100644 index 0000000000..a05f9b17a1 --- /dev/null +++ b/plugins/app/src/apis/DefaultDialogApi.ts @@ -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 }) => React.JSX.Element; + modal: boolean; +}) => DialogApiDialog; + +/** + * Default implementation for the {@link DialogApi}. + * @internal + */ +export class DefaultDialogApi implements DialogApi { + #onShow?: OnShowDialog; + + show( + elementOrComponent: + | JSX.Element + | ((props: { + dialog: DialogApiDialog; + }) => JSX.Element), + ): DialogApiDialog { + 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; + } + + showModal( + elementOrComponent: + | JSX.Element + | ((props: { dialog: DialogApiDialog }) => JSX.Element), + ): DialogApiDialog { + 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; + } + + connect(onShow: OnShowDialog): void { + this.#onShow = onShow; + } +} diff --git a/plugins/app/src/defaultApis.ts b/plugins/app/src/defaultApis.ts index b4f6fa0b48..535be78e05 100644 --- a/plugins/app/src/defaultApis.ts +++ b/plugins/app/src/defaultApis.ts @@ -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: { diff --git a/plugins/app/src/extensions/DialogDisplay.tsx b/plugins/app/src/extensions/DialogDisplay.tsx new file mode 100644 index 0000000000..7381523e0f --- /dev/null +++ b/plugins/app/src/extensions/DialogDisplay.tsx @@ -0,0 +1,127 @@ +/* + * 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 }[] + >([]); + // const [state, dispatch] = React.useReducer(dialogReducer) + useEffect(() => { + dialogApi.connect(options => { + const id = getDialogId(); + const deferred = createDeferred(); + 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 }) + : elementOrComponent; + setDialogs(ds => + ds.map(d => (d.dialog.id === id ? { dialog, element } : d)), + ); + }, + async result() { + return deferred; + }, + }; + const element = options.component({ dialog }); + setDialogs(ds => [...ds, { dialog, element }]); + return dialog; + }); + }, [dialogApi]); + + if (dialogs.length > 0) { + const lastDialog = dialogs[dialogs.length - 1]; + return ( + { + if (!lastDialog.dialog.modal) { + lastDialog.dialog.close(); + } + }} + > + {lastDialog.element} + + ); + } + + return null; +} + +export const dialogDisplayAppRootElement = + AppRootElementBlueprint.makeWithOverrides({ + name: 'dialog-display', + factory(originalFactory, { apis }) { + const dialogApi = apis.get(dialogApiRef); + if (!isInternalDialogApi(dialogApi)) { + throw new Error( + `Invalid dialog API implementation, dialog API has been overridden without also overriding the dialog-display element, got ${dialogApi}`, + ); + } + return originalFactory({ + element: , + }); + }, + }); + +function isInternalDialogApi( + dialogApi?: DialogApi, +): dialogApi is DialogApi & { connect(onShow: OnShowDialog): void } { + if (!dialogApi) { + return false; + } + return 'connect' in dialogApi; +} diff --git a/plugins/app/src/extensions/index.ts b/plugins/app/src/extensions/index.ts index 22b020a441..090e7c2cae 100644 --- a/plugins/app/src/extensions/index.ts +++ b/plugins/app/src/extensions/index.ts @@ -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, diff --git a/plugins/app/src/plugin.ts b/plugins/app/src/plugin.ts index 18c412bf77..809e101d0f 100644 --- a/plugins/app/src/plugin.ts +++ b/plugins/app/src/plugin.ts @@ -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, ], }); diff --git a/yarn.lock b/yarn.lock index 2e0955967b..1a5ac96f70 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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 From b508e58efd4333747baf385489572c82077a4b98 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 1 Mar 2025 22:07:18 +0100 Subject: [PATCH 3/7] plugins/app: added tests for DialogDisplay + fix component rendering Signed-off-by: Patrik Oldsberg --- .../app/src/extensions/DialogDisplay.test.tsx | 175 ++++++++++++++++++ plugins/app/src/extensions/DialogDisplay.tsx | 12 +- 2 files changed, 182 insertions(+), 5 deletions(-) create mode 100644 plugins/app/src/extensions/DialogDisplay.test.tsx diff --git a/plugins/app/src/extensions/DialogDisplay.test.tsx b/plugins/app/src/extensions/DialogDisplay.test.tsx new file mode 100644 index 0000000000..8bcf86817c --- /dev/null +++ b/plugins/app/src/extensions/DialogDisplay.test.tsx @@ -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( + callback: (dialogApi: DialogApi) => Promise, +) { + const deferred = createDeferred(); + await renderInTestApp(
, { + extensions: [ + AppRootElementBlueprint.makeWithOverrides({ + name: 'derp', + factory(originalFactory, { apis }) { + function TestComponent() { + useEffect(() => { + deferred.resolve(apis.get(dialogApiRef)!); + }, []); + return
; + } + return originalFactory({ element: }); + }, + }), + ], + }); + return await callback(await deferred); +} + +describe('DialogDisplay', () => { + function AutoDialog({ + dialog, + result, + }: { + dialog: DialogApiDialog; + result?: string; + }) { + useEffect(() => { + if (result) { + dialog.close(result); + } + }, [dialog, result]); + return
; + } + + it('should render a simple dialog', async () => { + const result = await withDialogApi(async dialogApi => { + const dialog = await act(() => dialogApi.show(
Test
)); + 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 => ); + }); + }, 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'); + }); +}); diff --git a/plugins/app/src/extensions/DialogDisplay.tsx b/plugins/app/src/extensions/DialogDisplay.tsx index 7381523e0f..fef3fa61ff 100644 --- a/plugins/app/src/extensions/DialogDisplay.tsx +++ b/plugins/app/src/extensions/DialogDisplay.tsx @@ -63,11 +63,13 @@ function DialogDisplay({ deferred.resolve(result); setDialogs(ds => ds.filter(d => d.dialog.id !== id)); }, - update(elementOrComponent) { + update(ElementOrComponent) { const element = - typeof elementOrComponent === 'function' - ? elementOrComponent({ dialog }) - : elementOrComponent; + typeof ElementOrComponent === 'function' ? ( + + ) : ( + ElementOrComponent + ); setDialogs(ds => ds.map(d => (d.dialog.id === id ? { dialog, element } : d)), ); @@ -76,7 +78,7 @@ function DialogDisplay({ return deferred; }, }; - const element = options.component({ dialog }); + const element = ; setDialogs(ds => [...ds, { dialog, element }]); return dialog; }); From 2150075a607dca66071484df0c9451e042dc221e Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 3 Mar 2025 10:43:58 +0100 Subject: [PATCH 4/7] frontend-defaults: update createApp test to include dialog API and display Signed-off-by: Patrik Oldsberg --- packages/frontend-defaults/src/createApp.test.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/frontend-defaults/src/createApp.test.tsx b/packages/frontend-defaults/src/createApp.test.tsx index 899d5d1640..0a9450804a 100644 --- a/packages/frontend-defaults/src/createApp.test.tsx +++ b/packages/frontend-defaults/src/createApp.test.tsx @@ -269,6 +269,7 @@ describe('createApp', () => { expect(String(tree.root)).toMatchInlineSnapshot(` " apis [ + @@ -328,6 +329,7 @@ describe('createApp', () => { elements [ + ] signInPage [ From cce8467e80d633b1584ee1c3738dc0466a4608ba Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 3 Mar 2025 10:44:22 +0100 Subject: [PATCH 5/7] update API reports for dialog API extensions Signed-off-by: Patrik Oldsberg --- packages/frontend-plugin-api/report.api.md | 32 ++++++++++++++++++ plugins/app/report.api.md | 38 ++++++++++++++++++++++ 2 files changed, 70 insertions(+) diff --git a/packages/frontend-plugin-api/report.api.md b/packages/frontend-plugin-api/report.api.md index c931ee120b..0acb2c4bb8 100644 --- a/packages/frontend-plugin-api/report.api.md +++ b/packages/frontend-plugin-api/report.api.md @@ -782,6 +782,38 @@ export { createTranslationRef }; export { createTranslationResource }; +// @public +export interface DialogApi { + show( + elementOrComponent: + | JSX.Element + | ((props: { + dialog: DialogApiDialog; + }) => JSX.Element), + ): DialogApiDialog; + showModal( + elementOrComponent: + | JSX.Element + | ((props: { dialog: DialogApiDialog }) => JSX.Element), + ): DialogApiDialog; +} + +// @public +export interface DialogApiDialog { + close( + ...args: undefined extends TResult ? [result?: TResult] : [result: TResult] + ): void; + result(): Promise; + update( + elementOrComponent: + | React.JSX.Element + | ((props: { dialog: DialogApiDialog }) => JSX.Element), + ): void; +} + +// @public +export const dialogApiRef: ApiRef; + export { DiscoveryApi }; export { discoveryApiRef }; diff --git a/plugins/app/report.api.md b/plugins/app/report.api.md index 3c6fba3776..811960eb39 100644 --- a/plugins/app/report.api.md +++ b/plugins/app/report.api.md @@ -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'; From 3ae0ea4936a79a6b1ea82d4de4149e63bf8c28cc Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 3 Mar 2025 10:44:47 +0100 Subject: [PATCH 6/7] plugins/app: no longer throw if dialog API is not available in dialog display Signed-off-by: Patrik Oldsberg --- plugins/app/src/extensions/DialogDisplay.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/plugins/app/src/extensions/DialogDisplay.tsx b/plugins/app/src/extensions/DialogDisplay.tsx index fef3fa61ff..fe6d6b26a2 100644 --- a/plugins/app/src/extensions/DialogDisplay.tsx +++ b/plugins/app/src/extensions/DialogDisplay.tsx @@ -109,9 +109,9 @@ export const dialogDisplayAppRootElement = factory(originalFactory, { apis }) { const dialogApi = apis.get(dialogApiRef); if (!isInternalDialogApi(dialogApi)) { - throw new Error( - `Invalid dialog API implementation, dialog API has been overridden without also overriding the dialog-display element, got ${dialogApi}`, - ); + return originalFactory({ + element: , + }); } return originalFactory({ element: , From 80f800b192a2e5fe3504ab22e4acbf0fd41c58f4 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 4 Mar 2025 15:41:03 +0100 Subject: [PATCH 7/7] dialog API review fixes Signed-off-by: Patrik Oldsberg --- packages/frontend-plugin-api/src/apis/definitions/DialogApi.ts | 2 +- plugins/app/src/extensions/DialogDisplay.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/frontend-plugin-api/src/apis/definitions/DialogApi.ts b/packages/frontend-plugin-api/src/apis/definitions/DialogApi.ts index ce19cb8d26..71cd70d3aa 100644 --- a/packages/frontend-plugin-api/src/apis/definitions/DialogApi.ts +++ b/packages/frontend-plugin-api/src/apis/definitions/DialogApi.ts @@ -27,7 +27,7 @@ import { createApiRef } from '@backstage/core-plugin-api'; */ export interface DialogApiDialog { /** - * Closes the dialog with thet provided result. + * Closes the dialog with that provided result. * * @remarks * diff --git a/plugins/app/src/extensions/DialogDisplay.tsx b/plugins/app/src/extensions/DialogDisplay.tsx index fe6d6b26a2..d7efee56a7 100644 --- a/plugins/app/src/extensions/DialogDisplay.tsx +++ b/plugins/app/src/extensions/DialogDisplay.tsx @@ -51,7 +51,7 @@ function DialogDisplay({ const [dialogs, setDialogs] = useState< { dialog: DialogState; element: React.JSX.Element }[] >([]); - // const [state, dispatch] = React.useReducer(dialogReducer) + useEffect(() => { dialogApi.connect(options => { const id = getDialogId();