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