plugins/app: add default implementation of DialogApi

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2025-03-01 19:43:35 +01:00
parent 5aa7f2cde5
commit 0aa9d82751
8 changed files with 219 additions and 1 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-app': patch
---
Added implementation of the new `DialogApi`.
+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",
+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,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<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 })
: 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 (
<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)) {
throw new Error(
`Invalid dialog API implementation, dialog API has been overridden without also overriding the dialog-display element, got ${dialogApi}`,
);
}
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