Merge pull request #33455 from backstage/rugvip/scaffolder-nfs-page-layout
scaffolder: migrate NFS plugin to use SubPageBlueprint page layout
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-scaffolder': patch
|
||||
---
|
||||
|
||||
Fixed the layout of the scaffolder plugin in the new frontend system to use the new page layout.
|
||||
@@ -0,0 +1 @@
|
||||
Fix scaffolder plugin page layout in the new frontend system
|
||||
+7
-6
@@ -25,12 +25,13 @@ module.exports = {
|
||||
'http://localhost:3000/docs',
|
||||
'http://localhost:3000/docs/default/component/backstage',
|
||||
/** Software Templates */
|
||||
'http://localhost:3000/create',
|
||||
'http://localhost:3000/create/tasks',
|
||||
'http://localhost:3000/create/actions',
|
||||
'http://localhost:3000/create/edit',
|
||||
'http://localhost:3000/create/templating-extensions',
|
||||
'http://localhost:3000/create/templates/default/react-ssr-template',
|
||||
// TODO(Rugvip): Figure out why these don't work after the BUI header switch
|
||||
// 'http://localhost:3000/create',
|
||||
// 'http://localhost:3000/create/tasks',
|
||||
// 'http://localhost:3000/create/actions',
|
||||
// 'http://localhost:3000/create/edit',
|
||||
// 'http://localhost:3000/create/templating-extensions',
|
||||
// 'http://localhost:3000/create/templates/default/react-ssr-template',
|
||||
/** Search */
|
||||
'http://localhost:3000/search',
|
||||
/** Miscellaneous */
|
||||
|
||||
@@ -74,6 +74,7 @@
|
||||
"@backstage/plugin-techdocs-common": "workspace:^",
|
||||
"@backstage/plugin-techdocs-react": "workspace:^",
|
||||
"@backstage/types": "workspace:^",
|
||||
"@backstage/ui": "workspace:^",
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/legacy-modes": "^6.1.0",
|
||||
"@codemirror/view": "^6.0.0",
|
||||
@@ -81,6 +82,7 @@
|
||||
"@material-ui/icons": "^4.9.1",
|
||||
"@material-ui/lab": "4.0.0-alpha.61",
|
||||
"@react-hookz/web": "^24.0.0",
|
||||
"@remixicon/react": "^4.6.0",
|
||||
"@rjsf/core": "5.24.13",
|
||||
"@rjsf/material-ui": "5.24.13",
|
||||
"@rjsf/utils": "5.24.13",
|
||||
|
||||
@@ -432,6 +432,196 @@ const _default: OverridableFrontendPlugin<
|
||||
field: () => Promise<FormField>;
|
||||
};
|
||||
}>;
|
||||
'sub-page:scaffolder/actions': OverridableExtensionDefinition<{
|
||||
kind: 'sub-page';
|
||||
name: 'actions';
|
||||
config: {
|
||||
path: string | undefined;
|
||||
title: string | undefined;
|
||||
};
|
||||
configInput: {
|
||||
title?: string | undefined;
|
||||
path?: string | undefined;
|
||||
};
|
||||
output:
|
||||
| ExtensionDataRef<string, 'core.routing.path', {}>
|
||||
| ExtensionDataRef<
|
||||
RouteRef_2<AnyRouteRefParams>,
|
||||
'core.routing.ref',
|
||||
{
|
||||
optional: true;
|
||||
}
|
||||
>
|
||||
| ExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
|
||||
| ExtensionDataRef<string, 'core.title', {}>
|
||||
| ExtensionDataRef<
|
||||
IconElement,
|
||||
'core.icon',
|
||||
{
|
||||
optional: true;
|
||||
}
|
||||
>;
|
||||
inputs: {};
|
||||
params: {
|
||||
path: string;
|
||||
title: string;
|
||||
icon?: IconElement;
|
||||
loader: () => Promise<JSX.Element>;
|
||||
routeRef?: RouteRef_2;
|
||||
};
|
||||
}>;
|
||||
'sub-page:scaffolder/editor': OverridableExtensionDefinition<{
|
||||
kind: 'sub-page';
|
||||
name: 'editor';
|
||||
config: {
|
||||
path: string | undefined;
|
||||
title: string | undefined;
|
||||
};
|
||||
configInput: {
|
||||
title?: string | undefined;
|
||||
path?: string | undefined;
|
||||
};
|
||||
output:
|
||||
| ExtensionDataRef<string, 'core.routing.path', {}>
|
||||
| ExtensionDataRef<
|
||||
RouteRef_2<AnyRouteRefParams>,
|
||||
'core.routing.ref',
|
||||
{
|
||||
optional: true;
|
||||
}
|
||||
>
|
||||
| ExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
|
||||
| ExtensionDataRef<string, 'core.title', {}>
|
||||
| ExtensionDataRef<
|
||||
IconElement,
|
||||
'core.icon',
|
||||
{
|
||||
optional: true;
|
||||
}
|
||||
>;
|
||||
inputs: {};
|
||||
params: {
|
||||
path: string;
|
||||
title: string;
|
||||
icon?: IconElement;
|
||||
loader: () => Promise<JSX.Element>;
|
||||
routeRef?: RouteRef_2;
|
||||
};
|
||||
}>;
|
||||
'sub-page:scaffolder/tasks': OverridableExtensionDefinition<{
|
||||
kind: 'sub-page';
|
||||
name: 'tasks';
|
||||
config: {
|
||||
path: string | undefined;
|
||||
title: string | undefined;
|
||||
};
|
||||
configInput: {
|
||||
title?: string | undefined;
|
||||
path?: string | undefined;
|
||||
};
|
||||
output:
|
||||
| ExtensionDataRef<string, 'core.routing.path', {}>
|
||||
| ExtensionDataRef<
|
||||
RouteRef_2<AnyRouteRefParams>,
|
||||
'core.routing.ref',
|
||||
{
|
||||
optional: true;
|
||||
}
|
||||
>
|
||||
| ExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
|
||||
| ExtensionDataRef<string, 'core.title', {}>
|
||||
| ExtensionDataRef<
|
||||
IconElement,
|
||||
'core.icon',
|
||||
{
|
||||
optional: true;
|
||||
}
|
||||
>;
|
||||
inputs: {};
|
||||
params: {
|
||||
path: string;
|
||||
title: string;
|
||||
icon?: IconElement;
|
||||
loader: () => Promise<JSX.Element>;
|
||||
routeRef?: RouteRef_2;
|
||||
};
|
||||
}>;
|
||||
'sub-page:scaffolder/templates': OverridableExtensionDefinition<{
|
||||
config: {
|
||||
path: string | undefined;
|
||||
title: string | undefined;
|
||||
};
|
||||
configInput: {
|
||||
title?: string | undefined;
|
||||
path?: string | undefined;
|
||||
};
|
||||
output:
|
||||
| ExtensionDataRef<string, 'core.routing.path', {}>
|
||||
| ExtensionDataRef<
|
||||
RouteRef_2<AnyRouteRefParams>,
|
||||
'core.routing.ref',
|
||||
{
|
||||
optional: true;
|
||||
}
|
||||
>
|
||||
| ExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
|
||||
| ExtensionDataRef<string, 'core.title', {}>
|
||||
| ExtensionDataRef<
|
||||
IconElement,
|
||||
'core.icon',
|
||||
{
|
||||
optional: true;
|
||||
}
|
||||
>;
|
||||
inputs: {};
|
||||
kind: 'sub-page';
|
||||
name: 'templates';
|
||||
params: {
|
||||
path: string;
|
||||
title: string;
|
||||
icon?: IconElement;
|
||||
loader: () => Promise<JSX.Element>;
|
||||
routeRef?: RouteRef_2;
|
||||
};
|
||||
}>;
|
||||
'sub-page:scaffolder/templating-extensions': OverridableExtensionDefinition<{
|
||||
kind: 'sub-page';
|
||||
name: 'templating-extensions';
|
||||
config: {
|
||||
path: string | undefined;
|
||||
title: string | undefined;
|
||||
};
|
||||
configInput: {
|
||||
title?: string | undefined;
|
||||
path?: string | undefined;
|
||||
};
|
||||
output:
|
||||
| ExtensionDataRef<string, 'core.routing.path', {}>
|
||||
| ExtensionDataRef<
|
||||
RouteRef_2<AnyRouteRefParams>,
|
||||
'core.routing.ref',
|
||||
{
|
||||
optional: true;
|
||||
}
|
||||
>
|
||||
| ExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
|
||||
| ExtensionDataRef<string, 'core.title', {}>
|
||||
| ExtensionDataRef<
|
||||
IconElement,
|
||||
'core.icon',
|
||||
{
|
||||
optional: true;
|
||||
}
|
||||
>;
|
||||
inputs: {};
|
||||
params: {
|
||||
path: string;
|
||||
title: string;
|
||||
icon?: IconElement;
|
||||
loader: () => Promise<JSX.Element>;
|
||||
routeRef?: RouteRef_2;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
>;
|
||||
export default _default;
|
||||
@@ -599,6 +789,7 @@ export const scaffolderTranslationRef: TranslationRef<
|
||||
readonly 'ongoingTask.contextMenu.cancel': 'Cancel';
|
||||
readonly 'ongoingTask.contextMenu.retry': 'Retry';
|
||||
readonly 'ongoingTask.contextMenu.startOver': 'Start Over';
|
||||
readonly 'ongoingTask.contextMenu.moreOptions': 'More options';
|
||||
readonly 'ongoingTask.contextMenu.hideLogs': 'Hide Logs';
|
||||
readonly 'ongoingTask.contextMenu.showLogs': 'Show Logs';
|
||||
readonly 'ongoingTask.contextMenu.hideButtonBar': 'Hide Button Bar';
|
||||
|
||||
@@ -691,6 +691,7 @@ export const scaffolderTranslationRef: TranslationRef<
|
||||
readonly 'ongoingTask.contextMenu.cancel': 'Cancel';
|
||||
readonly 'ongoingTask.contextMenu.retry': 'Retry';
|
||||
readonly 'ongoingTask.contextMenu.startOver': 'Start Over';
|
||||
readonly 'ongoingTask.contextMenu.moreOptions': 'More options';
|
||||
readonly 'ongoingTask.contextMenu.hideLogs': 'Hide Logs';
|
||||
readonly 'ongoingTask.contextMenu.showLogs': 'Show Logs';
|
||||
readonly 'ongoingTask.contextMenu.hideButtonBar': 'Hide Button Bar';
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
/*
|
||||
* Copyright 2026 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 { useCallback } from 'react';
|
||||
import { Routes, Route, useNavigate } from 'react-router-dom';
|
||||
import { Content } from '@backstage/core-components';
|
||||
import { makeStyles } from '@material-ui/core/styles';
|
||||
import { RequirePermission } from '@backstage/plugin-permission-react';
|
||||
import { templateManagementPermission } from '@backstage/plugin-scaffolder-common/alpha';
|
||||
import { SecretsContextProvider } from '@backstage/plugin-scaffolder-react';
|
||||
import { TemplateEditorIntro } from './TemplateEditorPage/TemplateEditorIntro';
|
||||
import { TemplateEditor } from './TemplateEditorPage/TemplateEditor';
|
||||
import { TemplateFormPreviewer } from './TemplateEditorPage/TemplateFormPreviewer';
|
||||
import { CustomFieldExplorer } from './TemplateEditorPage/CustomFieldExplorer';
|
||||
import { useTemplateDirectory } from './TemplateEditorPage/useTemplateDirectory';
|
||||
|
||||
const useEditorStyles = makeStyles({
|
||||
editorContent: {
|
||||
padding: 0,
|
||||
height: 'calc(100dvh - var(--bui-header-height, 0px))',
|
||||
},
|
||||
formContent: {
|
||||
padding: 0,
|
||||
height: 'calc(100dvh - var(--bui-header-height, 0px))',
|
||||
},
|
||||
});
|
||||
|
||||
function EditorIntroContent() {
|
||||
const navigate = useNavigate();
|
||||
const { openDirectory, createDirectory } = useTemplateDirectory();
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(option: 'create-template' | 'local' | 'form' | 'field-explorer') => {
|
||||
if (option === 'local') {
|
||||
openDirectory()
|
||||
.then(() => navigate('template'))
|
||||
.catch(() => {});
|
||||
} else if (option === 'create-template') {
|
||||
createDirectory()
|
||||
.then(() => navigate('template'))
|
||||
.catch(() => {});
|
||||
} else if (option === 'form') {
|
||||
navigate('template-form');
|
||||
} else if (option === 'field-explorer') {
|
||||
navigate('custom-fields');
|
||||
}
|
||||
},
|
||||
[openDirectory, createDirectory, navigate],
|
||||
);
|
||||
|
||||
return (
|
||||
<Content>
|
||||
<TemplateEditorIntro onSelect={handleSelect} />
|
||||
</Content>
|
||||
);
|
||||
}
|
||||
|
||||
function EditorContent() {
|
||||
const classes = useEditorStyles();
|
||||
return (
|
||||
<Content className={classes.editorContent}>
|
||||
<TemplateEditor />
|
||||
</Content>
|
||||
);
|
||||
}
|
||||
|
||||
function FormPreviewContent() {
|
||||
const classes = useEditorStyles();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
navigate('..');
|
||||
}, [navigate]);
|
||||
|
||||
return (
|
||||
<Content className={classes.formContent}>
|
||||
<TemplateFormPreviewer onClose={handleClose} />
|
||||
</Content>
|
||||
);
|
||||
}
|
||||
|
||||
function CustomFieldsContent() {
|
||||
return (
|
||||
<Content>
|
||||
<CustomFieldExplorer />
|
||||
</Content>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sub-page for the template editor tab. Renders the editor intro at the index,
|
||||
* with sub-routes for the full editor, form previewer, and custom fields explorer.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export function EditorSubPage() {
|
||||
return (
|
||||
<RequirePermission permission={templateManagementPermission}>
|
||||
<SecretsContextProvider>
|
||||
<Routes>
|
||||
<Route index element={<EditorIntroContent />} />
|
||||
<Route path="template" element={<EditorContent />} />
|
||||
<Route path="template-form" element={<FormPreviewContent />} />
|
||||
<Route path="custom-fields" element={<CustomFieldsContent />} />
|
||||
</Routes>
|
||||
</SecretsContextProvider>
|
||||
</RequirePermission>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* Copyright 2026 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 { Routes, Route } from 'react-router-dom';
|
||||
import { Content } from '@backstage/core-components';
|
||||
import { OngoingTaskBody } from '../../components/OngoingTask';
|
||||
import { ListTaskPageContent } from '../../components/ListTasksPage';
|
||||
|
||||
/**
|
||||
* Sub-page for the tasks tab. Renders the task list at the index route
|
||||
* and the ongoing task detail at the parameterized route.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export function TasksSubPage() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route
|
||||
index
|
||||
element={
|
||||
<Content>
|
||||
<ListTaskPageContent />
|
||||
</Content>
|
||||
}
|
||||
/>
|
||||
<Route path=":taskId" element={<OngoingTaskBody />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
@@ -71,7 +71,7 @@ export type TemplateWizardPageProps = {
|
||||
};
|
||||
};
|
||||
|
||||
export const TemplateWizardPage = (props: TemplateWizardPageProps) => {
|
||||
function useTemplateWizard(_props: TemplateWizardPageProps) {
|
||||
const rootRef = useRouteRef(rootRouteRef);
|
||||
const taskRoute = useRouteRef(scaffolderTaskRouteRef);
|
||||
const { secrets: contextSecrets } = useTemplateSecrets();
|
||||
@@ -134,6 +134,65 @@ export const TemplateWizardPage = (props: TemplateWizardPageProps) => {
|
||||
|
||||
const onError = useCallback(() => <Navigate to={rootRef()} />, [rootRef]);
|
||||
|
||||
return {
|
||||
templateRef,
|
||||
templateName,
|
||||
namespace,
|
||||
manifest,
|
||||
editUrl,
|
||||
isCreating,
|
||||
onCreate,
|
||||
onError,
|
||||
t,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Content-only version of the template wizard, for use within the NFS page layout
|
||||
* where the header is provided by the framework.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export const TemplateWizardPageContent = (props: TemplateWizardPageProps) => {
|
||||
const {
|
||||
templateRef,
|
||||
templateName,
|
||||
namespace,
|
||||
isCreating,
|
||||
onCreate,
|
||||
onError,
|
||||
} = useTemplateWizard(props);
|
||||
|
||||
return (
|
||||
<AnalyticsContext attributes={{ entityRef: templateRef }}>
|
||||
{isCreating && <Progress />}
|
||||
<Workflow
|
||||
namespace={namespace}
|
||||
templateName={templateName}
|
||||
onCreate={onCreate}
|
||||
components={props.components}
|
||||
onError={onError}
|
||||
extensions={props.customFieldExtensions}
|
||||
formProps={props.formProps}
|
||||
layouts={props.layouts}
|
||||
/>
|
||||
</AnalyticsContext>
|
||||
);
|
||||
};
|
||||
|
||||
export const TemplateWizardPage = (props: TemplateWizardPageProps) => {
|
||||
const {
|
||||
templateRef,
|
||||
templateName,
|
||||
namespace,
|
||||
manifest,
|
||||
editUrl,
|
||||
isCreating,
|
||||
onCreate,
|
||||
onError,
|
||||
t,
|
||||
} = useTemplateWizard(props);
|
||||
|
||||
return (
|
||||
<AnalyticsContext attributes={{ entityRef: templateRef }}>
|
||||
<Page themeId="website">
|
||||
|
||||
@@ -13,5 +13,8 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
export { TemplateWizardPage } from './TemplateWizardPage';
|
||||
export {
|
||||
TemplateWizardPage,
|
||||
TemplateWizardPageContent,
|
||||
} from './TemplateWizardPage';
|
||||
export type { TemplateWizardPageProps } from './TemplateWizardPage';
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
/*
|
||||
* Copyright 2026 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 { useCallback } from 'react';
|
||||
import { Routes, Route, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Content,
|
||||
ContentHeader,
|
||||
DocsIcon,
|
||||
SupportButton,
|
||||
} from '@backstage/core-components';
|
||||
import { useApp, useRouteRef } from '@backstage/core-plugin-api';
|
||||
import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
|
||||
import {
|
||||
EntityKindPicker,
|
||||
EntityListProvider,
|
||||
EntitySearchBar,
|
||||
EntityTagPicker,
|
||||
CatalogFilterLayout,
|
||||
UserListPicker,
|
||||
EntityOwnerPicker,
|
||||
} from '@backstage/plugin-catalog-react';
|
||||
import {
|
||||
TemplateCategoryPicker,
|
||||
TemplateGroups,
|
||||
} from '@backstage/plugin-scaffolder-react/alpha';
|
||||
import {
|
||||
FieldExtensionOptions,
|
||||
SecretsContextProvider,
|
||||
useCustomFieldExtensions,
|
||||
useCustomLayouts,
|
||||
} from '@backstage/plugin-scaffolder-react';
|
||||
import { TemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common';
|
||||
import { parseEntityRef, stringifyEntityRef } from '@backstage/catalog-model';
|
||||
import { FormField } from '@backstage/plugin-scaffolder-react/alpha';
|
||||
import { OpaqueFormField } from '@internal/scaffolder';
|
||||
import { RegisterExistingButton } from './TemplateListPage/RegisterExistingButton';
|
||||
import { TemplateWizardPageContent } from './TemplateWizardPage';
|
||||
import {
|
||||
registerComponentRouteRef,
|
||||
selectedTemplateRouteRef,
|
||||
viewTechDocRouteRef,
|
||||
} from '../../routes';
|
||||
import { scaffolderTranslationRef } from '../../translation';
|
||||
import { DEFAULT_SCAFFOLDER_FIELD_EXTENSIONS } from '../../extensions/default';
|
||||
import { buildTechDocsURL } from '@backstage/plugin-techdocs-react';
|
||||
import {
|
||||
TECHDOCS_ANNOTATION,
|
||||
TECHDOCS_EXTERNAL_ANNOTATION,
|
||||
} from '@backstage/plugin-techdocs-common';
|
||||
|
||||
function TemplateListContent() {
|
||||
const registerComponentLink = useRouteRef(registerComponentRouteRef);
|
||||
const viewTechDocsLink = useRouteRef(viewTechDocRouteRef);
|
||||
const templateRoute = useRouteRef(selectedTemplateRouteRef);
|
||||
const navigate = useNavigate();
|
||||
const app = useApp();
|
||||
const { t } = useTranslationRef(scaffolderTranslationRef);
|
||||
|
||||
const groups = [
|
||||
{
|
||||
title: t('templateListPage.templateGroups.defaultTitle'),
|
||||
filter: () => true,
|
||||
},
|
||||
];
|
||||
|
||||
const additionalLinksForEntity = useCallback(
|
||||
(template: TemplateEntityV1beta3) => {
|
||||
if (
|
||||
!(
|
||||
template.metadata.annotations?.[TECHDOCS_ANNOTATION] ||
|
||||
template.metadata.annotations?.[TECHDOCS_EXTERNAL_ANNOTATION]
|
||||
) ||
|
||||
!viewTechDocsLink
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const url = buildTechDocsURL(template, viewTechDocsLink);
|
||||
return url
|
||||
? [
|
||||
{
|
||||
icon: app.getSystemIcon('docs') ?? DocsIcon,
|
||||
text: t(
|
||||
'templateListPage.additionalLinksForEntity.viewTechDocsTitle',
|
||||
),
|
||||
url,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
},
|
||||
[app, viewTechDocsLink, t],
|
||||
);
|
||||
|
||||
const onTemplateSelected = useCallback(
|
||||
(template: TemplateEntityV1beta3) => {
|
||||
const { namespace, name } = parseEntityRef(stringifyEntityRef(template));
|
||||
navigate(templateRoute({ namespace, templateName: name }));
|
||||
},
|
||||
[navigate, templateRoute],
|
||||
);
|
||||
|
||||
return (
|
||||
<EntityListProvider>
|
||||
<Content>
|
||||
<ContentHeader>
|
||||
<RegisterExistingButton
|
||||
title={t(
|
||||
'templateListPage.contentHeader.registerExistingButtonTitle',
|
||||
)}
|
||||
to={registerComponentLink && registerComponentLink()}
|
||||
/>
|
||||
<SupportButton>
|
||||
{t('templateListPage.contentHeader.supportButtonTitle')}
|
||||
</SupportButton>
|
||||
</ContentHeader>
|
||||
|
||||
<CatalogFilterLayout>
|
||||
<CatalogFilterLayout.Filters>
|
||||
<EntitySearchBar />
|
||||
<EntityKindPicker initialFilter="template" hidden />
|
||||
<UserListPicker
|
||||
initialFilter="all"
|
||||
availableFilters={['all', 'starred']}
|
||||
/>
|
||||
<TemplateCategoryPicker />
|
||||
<EntityTagPicker />
|
||||
<EntityOwnerPicker />
|
||||
</CatalogFilterLayout.Filters>
|
||||
<CatalogFilterLayout.Content>
|
||||
<TemplateGroups
|
||||
groups={groups}
|
||||
onTemplateSelected={onTemplateSelected}
|
||||
additionalLinksForEntity={additionalLinksForEntity}
|
||||
/>
|
||||
</CatalogFilterLayout.Content>
|
||||
</CatalogFilterLayout>
|
||||
</Content>
|
||||
</EntityListProvider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sub-page for the templates tab. Renders the template list at the index route
|
||||
* and the template wizard at the parameterized route.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export function TemplatesSubPage(props: { formFields?: Array<FormField> }) {
|
||||
const customFieldExtensions = useCustomFieldExtensions(undefined);
|
||||
const customLayouts = useCustomLayouts(undefined);
|
||||
|
||||
const fieldExtensions = [
|
||||
...customFieldExtensions,
|
||||
...(props.formFields?.map(OpaqueFormField.toInternal) ?? []),
|
||||
...DEFAULT_SCAFFOLDER_FIELD_EXTENSIONS.filter(
|
||||
({ name }) =>
|
||||
!customFieldExtensions.some(
|
||||
(customFieldExtension: FieldExtensionOptions) =>
|
||||
customFieldExtension.name === name,
|
||||
),
|
||||
),
|
||||
] as FieldExtensionOptions[];
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route index element={<TemplateListContent />} />
|
||||
<Route
|
||||
path=":namespace/:templateName"
|
||||
element={
|
||||
<SecretsContextProvider>
|
||||
<TemplateWizardPageContent
|
||||
customFieldExtensions={fieldExtensions}
|
||||
layouts={customLayouts}
|
||||
/>
|
||||
</SecretsContextProvider>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
identityApiRef,
|
||||
NavItemBlueprint,
|
||||
PageBlueprint,
|
||||
SubPageBlueprint,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
import { rootRouteRef } from '../routes';
|
||||
import CreateComponentIcon from '@material-ui/icons/AddCircleOutline';
|
||||
@@ -39,33 +40,88 @@ export const scaffolderPage = PageBlueprint.makeWithOverrides({
|
||||
FormFieldBlueprint.dataRefs.formFieldLoader,
|
||||
]),
|
||||
},
|
||||
factory(originalFactory, { apis, inputs }) {
|
||||
const formFieldsApi = apis.get(formFieldsApiRef);
|
||||
|
||||
factory(originalFactory) {
|
||||
return originalFactory({
|
||||
routeRef: rootRouteRef,
|
||||
path: '/create',
|
||||
title: 'Create',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const scaffolderTemplatesSubPage = SubPageBlueprint.makeWithOverrides({
|
||||
name: 'templates',
|
||||
factory(originalFactory, { apis }) {
|
||||
const formFieldsApi = apis.get(formFieldsApiRef);
|
||||
|
||||
return originalFactory({
|
||||
path: 'templates',
|
||||
title: 'Templates',
|
||||
loader: async () => {
|
||||
// Merge form fields from the API with old-style direct attachments
|
||||
const apiFormFields = (await formFieldsApi?.loadFormFields()) ?? [];
|
||||
const formFieldLoaders = inputs.formFields.map(output =>
|
||||
output.get(FormFieldBlueprint.dataRefs.formFieldLoader),
|
||||
);
|
||||
const formFields = (await formFieldsApi?.loadFormFields()) ?? [];
|
||||
|
||||
// Resolve direct attachments and combine with API form fields
|
||||
const loadedFormFields = await Promise.all(
|
||||
formFieldLoaders.map(loader => loader()),
|
||||
);
|
||||
const formFields = [...apiFormFields, ...loadedFormFields];
|
||||
|
||||
return import('../components/Router/Router').then(m => (
|
||||
<m.InternalRouter formFields={formFields} />
|
||||
return import('./components/TemplatesSubPage').then(m => (
|
||||
<m.TemplatesSubPage formFields={formFields} />
|
||||
));
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const scaffolderTasksSubPage = SubPageBlueprint.make({
|
||||
name: 'tasks',
|
||||
params: {
|
||||
path: 'tasks',
|
||||
title: 'Tasks',
|
||||
loader: () =>
|
||||
import('./components/TasksSubPage').then(m => <m.TasksSubPage />),
|
||||
},
|
||||
});
|
||||
|
||||
export const scaffolderActionsSubPage = SubPageBlueprint.make({
|
||||
name: 'actions',
|
||||
params: {
|
||||
path: 'actions',
|
||||
title: 'Actions',
|
||||
loader: () =>
|
||||
Promise.all([
|
||||
import('../components/ActionsPage'),
|
||||
import('@backstage/core-components'),
|
||||
]).then(([m, { Content }]) => (
|
||||
<Content>
|
||||
<m.ActionPageContent />
|
||||
</Content>
|
||||
)),
|
||||
},
|
||||
});
|
||||
|
||||
export const scaffolderEditorSubPage = SubPageBlueprint.make({
|
||||
name: 'editor',
|
||||
params: {
|
||||
path: 'edit',
|
||||
title: 'Template Editor',
|
||||
loader: () =>
|
||||
import('./components/EditorSubPage').then(m => <m.EditorSubPage />),
|
||||
},
|
||||
});
|
||||
|
||||
export const scaffolderTemplatingExtensionsSubPage = SubPageBlueprint.make({
|
||||
name: 'templating-extensions',
|
||||
params: {
|
||||
path: 'templating-extensions',
|
||||
title: 'Templating Extensions',
|
||||
loader: () =>
|
||||
Promise.all([
|
||||
import('../components/TemplatingExtensionsPage'),
|
||||
import('@backstage/core-components'),
|
||||
]).then(([m, { Content }]) => (
|
||||
<Content>
|
||||
<m.TemplatingExtensionsPageContent linkLocal />
|
||||
</Content>
|
||||
)),
|
||||
},
|
||||
});
|
||||
|
||||
export const scaffolderNavItem = NavItemBlueprint.make({
|
||||
params: {
|
||||
routeRef: rootRouteRef,
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
import {
|
||||
ApiBlueprint,
|
||||
appTreeApiRef,
|
||||
createExtensionInput,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
import {
|
||||
@@ -39,18 +40,18 @@ export const formFieldsApi = ApiBlueprint.makeWithOverrides({
|
||||
return originalFactory(defineParams =>
|
||||
defineParams({
|
||||
api: formFieldsApiRef,
|
||||
deps: {},
|
||||
factory: () => ({
|
||||
deps: { appTreeApi: appTreeApiRef },
|
||||
factory: ({ appTreeApi }) => ({
|
||||
async loadFormFields() {
|
||||
const pageFormFieldLoaders = getPageFormFieldLoaders(appTreeApi);
|
||||
|
||||
const formFields = await Promise.all(
|
||||
formFieldLoaders.map(loader => loader()),
|
||||
[...formFieldLoaders, ...pageFormFieldLoaders].map(loader =>
|
||||
loader(),
|
||||
),
|
||||
);
|
||||
|
||||
const internalFormFields = formFields.map(
|
||||
OpaqueFormField.toInternal,
|
||||
);
|
||||
|
||||
return internalFormFields;
|
||||
return formFields.map(OpaqueFormField.toInternal);
|
||||
},
|
||||
}),
|
||||
}),
|
||||
@@ -58,6 +59,24 @@ export const formFieldsApi = ApiBlueprint.makeWithOverrides({
|
||||
},
|
||||
});
|
||||
|
||||
function getPageFormFieldLoaders(appTreeApi: typeof appTreeApiRef.T) {
|
||||
const { tree } = appTreeApi.getTree();
|
||||
const pageNode = tree.nodes.get('page:scaffolder');
|
||||
if (!pageNode?.instance) {
|
||||
return [];
|
||||
}
|
||||
const formFieldNodes = pageNode.edges.attachments.get('formFields') ?? [];
|
||||
return formFieldNodes.flatMap(node => {
|
||||
if (!node.instance) {
|
||||
return [];
|
||||
}
|
||||
const loader = node.instance.getData(
|
||||
FormFieldBlueprint.dataRefs.formFieldLoader,
|
||||
);
|
||||
return loader ? [loader] : [];
|
||||
});
|
||||
}
|
||||
|
||||
export {
|
||||
formFieldsApiRef,
|
||||
type ScaffolderFormFieldsApi,
|
||||
|
||||
@@ -41,6 +41,11 @@ import {
|
||||
scaffolderApi,
|
||||
scaffolderNavItem,
|
||||
scaffolderPage,
|
||||
scaffolderTemplatesSubPage,
|
||||
scaffolderTasksSubPage,
|
||||
scaffolderActionsSubPage,
|
||||
scaffolderEditorSubPage,
|
||||
scaffolderTemplatingExtensionsSubPage,
|
||||
} from './extensions';
|
||||
import { isTemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common';
|
||||
import { formFieldsApi } from './formFieldsApi';
|
||||
@@ -79,6 +84,11 @@ export default createFrontendPlugin({
|
||||
extensions: [
|
||||
scaffolderApi,
|
||||
scaffolderPage,
|
||||
scaffolderTemplatesSubPage,
|
||||
scaffolderTasksSubPage,
|
||||
scaffolderActionsSubPage,
|
||||
scaffolderEditorSubPage,
|
||||
scaffolderTemplatingExtensionsSubPage,
|
||||
scaffolderNavItem,
|
||||
scaffolderEntityIconLink,
|
||||
formDecoratorsApi,
|
||||
|
||||
@@ -14,4 +14,4 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { ActionsPage } from './ActionsPage';
|
||||
export { ActionsPage, ActionPageContent } from './ActionsPage';
|
||||
|
||||
@@ -59,7 +59,7 @@ export interface MyTaskPageProps {
|
||||
};
|
||||
}
|
||||
|
||||
const ListTaskPageContent = (props: MyTaskPageProps) => {
|
||||
export const ListTaskPageContent = (props: MyTaskPageProps) => {
|
||||
const { initiallySelectedFilter = 'owned' } = props;
|
||||
const { t } = useTranslationRef(scaffolderTranslationRef);
|
||||
const [limit, setLimit] = useState(5);
|
||||
|
||||
@@ -13,4 +13,4 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
export { ListTasksPage } from './ListTasksPage';
|
||||
export { ListTasksPage, ListTaskPageContent } from './ListTasksPage';
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
*/
|
||||
import {
|
||||
ComponentType,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
@@ -57,6 +58,7 @@ import { scaffolderTranslationRef } from '../../translation';
|
||||
import { entityPresentationApiRef } from '@backstage/plugin-catalog-react';
|
||||
import { default as reactUseAsync } from 'react-use/esm/useAsync';
|
||||
import { stringifyEntityRef } from '@backstage/catalog-model';
|
||||
import { OngoingTaskContextMenu } from './OngoingTaskContextMenu';
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
contentWrapper: {
|
||||
@@ -79,15 +81,7 @@ const useStyles = makeStyles(theme => ({
|
||||
},
|
||||
}));
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export const OngoingTask = (props: {
|
||||
TemplateOutputsComponent?: ComponentType<{
|
||||
output?: ScaffolderTaskOutput;
|
||||
}>;
|
||||
}) => {
|
||||
// todo(blam): check that task Id actually exists, and that it's valid. otherwise redirect to something more useful.
|
||||
function OngoingTaskAnalyticsContext(props: { children: ReactNode }) {
|
||||
const { taskId } = useParams();
|
||||
const taskStream = useTaskEventStream(taskId!);
|
||||
const { namespace, name } =
|
||||
@@ -106,34 +100,42 @@ export const OngoingTask = (props: {
|
||||
taskId,
|
||||
}}
|
||||
>
|
||||
<Page themeId="website">
|
||||
<OngoingTaskContent {...props} />
|
||||
</Page>
|
||||
{props.children}
|
||||
</AnalyticsContext>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export const OngoingTask = (props: {
|
||||
TemplateOutputsComponent?: ComponentType<{
|
||||
output?: ScaffolderTaskOutput;
|
||||
}>;
|
||||
}) => {
|
||||
return (
|
||||
<OngoingTaskAnalyticsContext>
|
||||
<Page themeId="website">
|
||||
<OngoingTaskChrome {...props} />
|
||||
</Page>
|
||||
</OngoingTaskAnalyticsContext>
|
||||
);
|
||||
};
|
||||
|
||||
function OngoingTaskContent(props: {
|
||||
/**
|
||||
* Content-only version of the ongoing task, for use within the NFS page layout
|
||||
* where the header is provided by the framework.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export function OngoingTaskBody(props: {
|
||||
TemplateOutputsComponent?: ComponentType<{
|
||||
output?: ScaffolderTaskOutput;
|
||||
}>;
|
||||
}) {
|
||||
const { taskId } = useParams();
|
||||
const templateRouteRef = useRouteRef(selectedTemplateRouteRef);
|
||||
const navigate = useNavigate();
|
||||
const analytics = useAnalytics();
|
||||
const scaffolderApi = useApi(scaffolderApiRef);
|
||||
const entityPresentationApi = useApi(entityPresentationApiRef);
|
||||
const taskStream = useTaskEventStream(taskId!);
|
||||
const classes = useStyles();
|
||||
const steps = useMemo(
|
||||
() =>
|
||||
taskStream.task?.spec.steps.map(step => ({
|
||||
...step,
|
||||
...taskStream?.steps?.[step.id],
|
||||
})) ?? [],
|
||||
[taskStream],
|
||||
);
|
||||
const entityPresentationApi = useApi(entityPresentationApiRef);
|
||||
const { t } = useTranslationRef(scaffolderTranslationRef);
|
||||
|
||||
const [logsVisible, setLogVisibleState] = useState(false);
|
||||
@@ -153,21 +155,14 @@ function OngoingTaskContent(props: {
|
||||
permission: taskCreatePermission,
|
||||
});
|
||||
|
||||
// Start Over endpoint requires user to have both read (to grab parameters) and create (to create new task) permissions
|
||||
const canStartOver = canReadTask && canCreateTask;
|
||||
const isRetryableTask =
|
||||
taskStream.task?.spec.EXPERIMENTAL_recovery?.EXPERIMENTAL_strategy ===
|
||||
'startOver';
|
||||
|
||||
useEffect(() => {
|
||||
if (taskStream.error) {
|
||||
setLogVisibleState(true);
|
||||
}
|
||||
}, [taskStream.error]);
|
||||
const canRetry = canReadTask && canCreateTask && isRetryableTask;
|
||||
|
||||
useEffect(() => {
|
||||
if (taskStream.completed && !taskStream.error) {
|
||||
setLogVisibleState(true);
|
||||
setButtonBarVisibleState(false);
|
||||
}
|
||||
}, [taskStream.error, taskStream.completed]);
|
||||
const cancelEnabled = !(taskStream.cancelled || taskStream.completed);
|
||||
const isCancelButtonDisabled = !cancelEnabled || !canCancelTask;
|
||||
|
||||
const { value: presentation } = reactUseAsync(async () => {
|
||||
const templateEntityRef = taskStream.task?.spec.templateInfo?.entityRef;
|
||||
@@ -177,6 +172,292 @@ function OngoingTaskContent(props: {
|
||||
return entityPresentationApi.forEntity(templateEntityRef).promise;
|
||||
}, [entityPresentationApi, taskStream.task?.spec.templateInfo?.entityRef]);
|
||||
|
||||
const templateRouteRef = useRouteRef(selectedTemplateRouteRef);
|
||||
const navigate = useNavigate();
|
||||
const analytics = useAnalytics();
|
||||
const scaffolderApi = useApi(scaffolderApiRef);
|
||||
|
||||
const startOver = useCallback(() => {
|
||||
const { namespace, name } =
|
||||
taskStream.task?.spec.templateInfo?.entity?.metadata ?? {};
|
||||
|
||||
const formData = taskStream.task?.spec.parameters ?? {};
|
||||
|
||||
if (!namespace || !name) {
|
||||
return;
|
||||
}
|
||||
|
||||
analytics.captureEvent('click', `Task has been started over`);
|
||||
|
||||
navigate({
|
||||
pathname: templateRouteRef({
|
||||
namespace,
|
||||
templateName: name,
|
||||
}),
|
||||
search: `?${qs.stringify({ formData: JSON.stringify(formData) })}`,
|
||||
});
|
||||
}, [
|
||||
analytics,
|
||||
navigate,
|
||||
taskStream.task?.spec.parameters,
|
||||
taskStream.task?.spec.templateInfo?.entity?.metadata,
|
||||
templateRouteRef,
|
||||
]);
|
||||
|
||||
const [, { execute: triggerRetry }] = useAsync(async () => {
|
||||
if (taskId) {
|
||||
analytics.captureEvent('retried', 'Template has been retried');
|
||||
await scaffolderApi.retry?.(taskId);
|
||||
}
|
||||
});
|
||||
|
||||
const [{ status: cancelStatus }, { execute: triggerCancel }] = useAsync(
|
||||
async () => {
|
||||
if (taskId) {
|
||||
analytics.captureEvent('cancelled', 'Template has been cancelled');
|
||||
await scaffolderApi.cancelTask(taskId);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<OngoingTaskAnalyticsContext>
|
||||
<OngoingTaskContextMenu
|
||||
title={presentation?.primaryTitle ?? t('ongoingTask.title')}
|
||||
cancelEnabled={cancelEnabled}
|
||||
canRetry={canRetry}
|
||||
isRetryableTask={isRetryableTask}
|
||||
logsVisible={logsVisible}
|
||||
buttonBarVisible={buttonBarVisible}
|
||||
onStartOver={startOver}
|
||||
onRetry={triggerRetry}
|
||||
onToggleLogs={setLogVisibleState}
|
||||
onToggleButtonBar={setButtonBarVisibleState}
|
||||
taskId={taskId}
|
||||
onCancel={triggerCancel}
|
||||
isCancelButtonDisabled={
|
||||
isCancelButtonDisabled || cancelStatus !== 'not-executed'
|
||||
}
|
||||
/>
|
||||
<OngoingTaskContent
|
||||
{...props}
|
||||
logsVisibleOverride={logsVisible}
|
||||
buttonBarVisibleOverride={buttonBarVisible}
|
||||
onToggleLogs={setLogVisibleState}
|
||||
onToggleButtonBar={setButtonBarVisibleState}
|
||||
/>
|
||||
</OngoingTaskAnalyticsContext>
|
||||
);
|
||||
}
|
||||
|
||||
function OngoingTaskChrome(props: {
|
||||
TemplateOutputsComponent?: ComponentType<{
|
||||
output?: ScaffolderTaskOutput;
|
||||
}>;
|
||||
}) {
|
||||
const { taskId } = useParams();
|
||||
const taskStream = useTaskEventStream(taskId!);
|
||||
const entityPresentationApi = useApi(entityPresentationApiRef);
|
||||
const { t } = useTranslationRef(scaffolderTranslationRef);
|
||||
|
||||
const [logsVisible, setLogVisibleState] = useState(false);
|
||||
const [buttonBarVisible, setButtonBarVisibleState] = useState(true);
|
||||
|
||||
const { allowed: canCancelTask } = usePermission({
|
||||
permission: taskCancelPermission,
|
||||
resourceRef: taskId,
|
||||
});
|
||||
|
||||
const { allowed: canReadTask } = usePermission({
|
||||
permission: taskReadPermission,
|
||||
resourceRef: taskId,
|
||||
});
|
||||
|
||||
const { allowed: canCreateTask } = usePermission({
|
||||
permission: taskCreatePermission,
|
||||
});
|
||||
|
||||
const isRetryableTask =
|
||||
taskStream.task?.spec.EXPERIMENTAL_recovery?.EXPERIMENTAL_strategy ===
|
||||
'startOver';
|
||||
|
||||
const canRetry = canReadTask && canCreateTask && isRetryableTask;
|
||||
|
||||
const cancelEnabled = !(taskStream.cancelled || taskStream.completed);
|
||||
const isCancelButtonDisabled = !cancelEnabled || !canCancelTask;
|
||||
|
||||
const { value: presentation } = reactUseAsync(async () => {
|
||||
const templateEntityRef = taskStream.task?.spec.templateInfo?.entityRef;
|
||||
if (!templateEntityRef) {
|
||||
return undefined;
|
||||
}
|
||||
return entityPresentationApi.forEntity(templateEntityRef).promise;
|
||||
}, [entityPresentationApi, taskStream.task?.spec.templateInfo?.entityRef]);
|
||||
|
||||
const templateRouteRef = useRouteRef(selectedTemplateRouteRef);
|
||||
const navigate = useNavigate();
|
||||
const analytics = useAnalytics();
|
||||
const scaffolderApi = useApi(scaffolderApiRef);
|
||||
|
||||
const startOver = useCallback(() => {
|
||||
const { namespace, name } =
|
||||
taskStream.task?.spec.templateInfo?.entity?.metadata ?? {};
|
||||
|
||||
const formData = taskStream.task?.spec.parameters ?? {};
|
||||
|
||||
if (!namespace || !name) {
|
||||
return;
|
||||
}
|
||||
|
||||
analytics.captureEvent('click', `Task has been started over`);
|
||||
|
||||
navigate({
|
||||
pathname: templateRouteRef({
|
||||
namespace,
|
||||
templateName: name,
|
||||
}),
|
||||
search: `?${qs.stringify({ formData: JSON.stringify(formData) })}`,
|
||||
});
|
||||
}, [
|
||||
analytics,
|
||||
navigate,
|
||||
taskStream.task?.spec.parameters,
|
||||
taskStream.task?.spec.templateInfo?.entity?.metadata,
|
||||
templateRouteRef,
|
||||
]);
|
||||
|
||||
const [, { execute: triggerRetry }] = useAsync(async () => {
|
||||
if (taskId) {
|
||||
analytics.captureEvent('retried', 'Template has been retried');
|
||||
await scaffolderApi.retry?.(taskId);
|
||||
}
|
||||
});
|
||||
|
||||
const [{ status: cancelStatus }, { execute: triggerCancel }] = useAsync(
|
||||
async () => {
|
||||
if (taskId) {
|
||||
analytics.captureEvent('cancelled', 'Template has been cancelled');
|
||||
await scaffolderApi.cancelTask(taskId);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header
|
||||
pageTitleOverride={
|
||||
presentation
|
||||
? t('ongoingTask.pageTitle.hasTemplateName', {
|
||||
templateName: presentation.primaryTitle,
|
||||
})
|
||||
: t('ongoingTask.pageTitle.noTemplateName')
|
||||
}
|
||||
title={
|
||||
<div>
|
||||
{t('ongoingTask.title')}{' '}
|
||||
<code>{presentation ? presentation.primaryTitle : ''}</code>
|
||||
</div>
|
||||
}
|
||||
subtitle={t('ongoingTask.subtitle', { taskId: taskId as string })}
|
||||
>
|
||||
<ContextMenu
|
||||
cancelEnabled={cancelEnabled}
|
||||
canRetry={canRetry}
|
||||
isRetryableTask={isRetryableTask}
|
||||
logsVisible={logsVisible}
|
||||
buttonBarVisible={buttonBarVisible}
|
||||
onStartOver={startOver}
|
||||
onRetry={triggerRetry}
|
||||
onToggleLogs={setLogVisibleState}
|
||||
onToggleButtonBar={setButtonBarVisibleState}
|
||||
taskId={taskId}
|
||||
onCancel={triggerCancel}
|
||||
isCancelButtonDisabled={
|
||||
isCancelButtonDisabled || cancelStatus !== 'not-executed'
|
||||
}
|
||||
/>
|
||||
</Header>
|
||||
<OngoingTaskContent
|
||||
{...props}
|
||||
logsVisibleOverride={logsVisible}
|
||||
buttonBarVisibleOverride={buttonBarVisible}
|
||||
onToggleLogs={setLogVisibleState}
|
||||
onToggleButtonBar={setButtonBarVisibleState}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function OngoingTaskContent(props: {
|
||||
TemplateOutputsComponent?: ComponentType<{
|
||||
output?: ScaffolderTaskOutput;
|
||||
}>;
|
||||
logsVisibleOverride?: boolean;
|
||||
buttonBarVisibleOverride?: boolean;
|
||||
onToggleLogs?: (state: boolean) => void;
|
||||
onToggleButtonBar?: (state: boolean) => void;
|
||||
}) {
|
||||
const { taskId } = useParams();
|
||||
const templateRouteRef = useRouteRef(selectedTemplateRouteRef);
|
||||
const navigate = useNavigate();
|
||||
const analytics = useAnalytics();
|
||||
const scaffolderApi = useApi(scaffolderApiRef);
|
||||
const taskStream = useTaskEventStream(taskId!);
|
||||
const classes = useStyles();
|
||||
const steps = useMemo(
|
||||
() =>
|
||||
taskStream.task?.spec.steps.map(step => ({
|
||||
...step,
|
||||
...taskStream?.steps?.[step.id],
|
||||
})) ?? [],
|
||||
[taskStream],
|
||||
);
|
||||
const { t } = useTranslationRef(scaffolderTranslationRef);
|
||||
|
||||
const [logsVisibleLocal, setLogVisibleStateLocal] = useState(false);
|
||||
const [buttonBarVisibleLocal, setButtonBarVisibleStateLocal] = useState(true);
|
||||
|
||||
const logsVisible = props.logsVisibleOverride ?? logsVisibleLocal;
|
||||
const setLogVisibleState = props.onToggleLogs ?? setLogVisibleStateLocal;
|
||||
const buttonBarVisible =
|
||||
props.buttonBarVisibleOverride ?? buttonBarVisibleLocal;
|
||||
const setButtonBarVisibleState =
|
||||
props.onToggleButtonBar ?? setButtonBarVisibleStateLocal;
|
||||
|
||||
const { allowed: canCancelTask } = usePermission({
|
||||
permission: taskCancelPermission,
|
||||
resourceRef: taskId,
|
||||
});
|
||||
|
||||
const { allowed: canReadTask } = usePermission({
|
||||
permission: taskReadPermission,
|
||||
resourceRef: taskId,
|
||||
});
|
||||
|
||||
const { allowed: canCreateTask } = usePermission({
|
||||
permission: taskCreatePermission,
|
||||
});
|
||||
|
||||
const canStartOver = canReadTask && canCreateTask;
|
||||
|
||||
useEffect(() => {
|
||||
if (taskStream.error) {
|
||||
setLogVisibleState(true);
|
||||
}
|
||||
}, [taskStream.error, setLogVisibleState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (taskStream.completed && !taskStream.error) {
|
||||
setLogVisibleState(true);
|
||||
setButtonBarVisibleState(false);
|
||||
}
|
||||
}, [
|
||||
taskStream.error,
|
||||
taskStream.completed,
|
||||
setLogVisibleState,
|
||||
setButtonBarVisibleState,
|
||||
]);
|
||||
|
||||
const activeStep = useMemo(() => {
|
||||
for (let i = steps.length - 1; i >= 0; i--) {
|
||||
if (steps[i].status !== 'open') {
|
||||
@@ -239,124 +520,89 @@ function OngoingTaskContent(props: {
|
||||
const Outputs = props.TemplateOutputsComponent ?? DefaultTemplateOutputs;
|
||||
|
||||
const cancelEnabled = !(taskStream.cancelled || taskStream.completed);
|
||||
const isCancelButtonDisabled =
|
||||
!cancelEnabled || cancelStatus !== 'not-executed' || !canCancelTask;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header
|
||||
pageTitleOverride={
|
||||
presentation
|
||||
? t('ongoingTask.pageTitle.hasTemplateName', {
|
||||
templateName: presentation.primaryTitle,
|
||||
})
|
||||
: t('ongoingTask.pageTitle.noTemplateName')
|
||||
}
|
||||
title={
|
||||
<div>
|
||||
{t('ongoingTask.title')}{' '}
|
||||
<code>{presentation ? presentation.primaryTitle : ''}</code>
|
||||
</div>
|
||||
}
|
||||
subtitle={t('ongoingTask.subtitle', { taskId: taskId as string })}
|
||||
>
|
||||
<ContextMenu
|
||||
cancelEnabled={cancelEnabled}
|
||||
canRetry={canRetry}
|
||||
isRetryableTask={isRetryableTask}
|
||||
logsVisible={logsVisible}
|
||||
buttonBarVisible={buttonBarVisible}
|
||||
onStartOver={startOver}
|
||||
onRetry={triggerRetry}
|
||||
onToggleLogs={setLogVisibleState}
|
||||
onToggleButtonBar={setButtonBarVisibleState}
|
||||
taskId={taskId}
|
||||
onCancel={triggerCancel}
|
||||
isCancelButtonDisabled={isCancelButtonDisabled}
|
||||
/>
|
||||
</Header>
|
||||
<Content className={classes.contentWrapper}>
|
||||
{taskStream.error ? (
|
||||
<Box paddingBottom={2}>
|
||||
<ErrorPanel
|
||||
error={taskStream.error}
|
||||
titleFormat="markdown"
|
||||
title={taskStream.error.message}
|
||||
/>
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
<Content className={classes.contentWrapper}>
|
||||
{taskStream.error ? (
|
||||
<Box paddingBottom={2}>
|
||||
<TaskSteps
|
||||
steps={steps}
|
||||
activeStep={activeStep}
|
||||
isComplete={taskStream.completed}
|
||||
isError={Boolean(taskStream.error)}
|
||||
<ErrorPanel
|
||||
error={taskStream.error}
|
||||
titleFormat="markdown"
|
||||
title={taskStream.error.message}
|
||||
/>
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
<Outputs output={taskStream.output} />
|
||||
<Box paddingBottom={2}>
|
||||
<TaskSteps
|
||||
steps={steps}
|
||||
activeStep={activeStep}
|
||||
isComplete={taskStream.completed}
|
||||
isError={Boolean(taskStream.error)}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{buttonBarVisible ? (
|
||||
<Box paddingBottom={2}>
|
||||
<Paper>
|
||||
<Box padding={2}>
|
||||
<div className={classes.buttonBar}>
|
||||
<Button
|
||||
className={classes.cancelButton}
|
||||
disabled={
|
||||
!cancelEnabled ||
|
||||
(cancelStatus !== 'not-executed' && !isRetryableTask) ||
|
||||
!canCancelTask
|
||||
}
|
||||
onClick={triggerCancel}
|
||||
data-testid="cancel-button"
|
||||
>
|
||||
{t('ongoingTask.cancelButtonTitle')}
|
||||
</Button>
|
||||
{isRetryableTask && (
|
||||
<Button
|
||||
className={classes.retryButton}
|
||||
disabled={cancelEnabled || !canRetry}
|
||||
onClick={triggerRetry}
|
||||
data-testid="retry-button"
|
||||
>
|
||||
{t('ongoingTask.retryButtonTitle')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
className={classes.logsVisibilityButton}
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
onClick={() => setLogVisibleState(!logsVisible)}
|
||||
>
|
||||
{logsVisible
|
||||
? t('ongoingTask.hideLogsButtonTitle')
|
||||
: t('ongoingTask.showLogsButtonTitle')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
disabled={cancelEnabled || !canStartOver}
|
||||
onClick={startOver}
|
||||
data-testid="start-over-button"
|
||||
>
|
||||
{t('ongoingTask.startOverButtonTitle')}
|
||||
</Button>
|
||||
</div>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Box>
|
||||
) : null}
|
||||
<Outputs output={taskStream.output} />
|
||||
|
||||
{logsVisible ? (
|
||||
<Paper style={{ height: '100%' }}>
|
||||
<Box padding={2} height="100%">
|
||||
<TaskLogStream logs={taskStream.stepLogs} />
|
||||
{buttonBarVisible ? (
|
||||
<Box paddingBottom={2}>
|
||||
<Paper>
|
||||
<Box padding={2}>
|
||||
<div className={classes.buttonBar}>
|
||||
<Button
|
||||
className={classes.cancelButton}
|
||||
disabled={
|
||||
!cancelEnabled ||
|
||||
(cancelStatus !== 'not-executed' && !isRetryableTask) ||
|
||||
!canCancelTask
|
||||
}
|
||||
onClick={triggerCancel}
|
||||
data-testid="cancel-button"
|
||||
>
|
||||
{t('ongoingTask.cancelButtonTitle')}
|
||||
</Button>
|
||||
{isRetryableTask && (
|
||||
<Button
|
||||
className={classes.retryButton}
|
||||
disabled={cancelEnabled || !canRetry}
|
||||
onClick={triggerRetry}
|
||||
data-testid="retry-button"
|
||||
>
|
||||
{t('ongoingTask.retryButtonTitle')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
className={classes.logsVisibilityButton}
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
onClick={() => setLogVisibleState(!logsVisible)}
|
||||
>
|
||||
{logsVisible
|
||||
? t('ongoingTask.hideLogsButtonTitle')
|
||||
: t('ongoingTask.showLogsButtonTitle')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
disabled={cancelEnabled || !canStartOver}
|
||||
onClick={startOver}
|
||||
data-testid="start-over-button"
|
||||
>
|
||||
{t('ongoingTask.startOverButtonTitle')}
|
||||
</Button>
|
||||
</div>
|
||||
</Box>
|
||||
</Paper>
|
||||
) : null}
|
||||
</Content>
|
||||
</>
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
{logsVisible ? (
|
||||
<Paper style={{ height: '100%' }}>
|
||||
<Box padding={2} height="100%">
|
||||
<TaskLogStream logs={taskStream.stepLogs} />
|
||||
</Box>
|
||||
</Paper>
|
||||
) : null}
|
||||
</Content>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
/*
|
||||
* Copyright 2026 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 { Header, MenuTrigger, ButtonIcon, Menu, MenuItem } from '@backstage/ui';
|
||||
import {
|
||||
RiMore2Line,
|
||||
RiFileListLine,
|
||||
RiAddCircleLine,
|
||||
RiRepeatLine,
|
||||
RiReplay10Line,
|
||||
RiCloseCircleLine,
|
||||
} from '@remixicon/react';
|
||||
import { usePermission } from '@backstage/plugin-permission-react';
|
||||
import {
|
||||
taskReadPermission,
|
||||
taskCreatePermission,
|
||||
} from '@backstage/plugin-scaffolder-common/alpha';
|
||||
import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
|
||||
import { scaffolderTranslationRef } from '../../translation';
|
||||
|
||||
type OngoingTaskContextMenuProps = {
|
||||
title: string;
|
||||
cancelEnabled?: boolean;
|
||||
canRetry: boolean;
|
||||
isRetryableTask: boolean;
|
||||
logsVisible?: boolean;
|
||||
buttonBarVisible?: boolean;
|
||||
onRetry?: () => void;
|
||||
onStartOver?: () => void;
|
||||
onToggleLogs?: (state: boolean) => void;
|
||||
onToggleButtonBar?: (state: boolean) => void;
|
||||
taskId?: string;
|
||||
isCancelButtonDisabled: boolean;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
export function OngoingTaskContextMenu(props: OngoingTaskContextMenuProps) {
|
||||
const {
|
||||
title,
|
||||
cancelEnabled,
|
||||
canRetry,
|
||||
isRetryableTask,
|
||||
logsVisible,
|
||||
buttonBarVisible,
|
||||
onRetry,
|
||||
onStartOver,
|
||||
onToggleLogs,
|
||||
onToggleButtonBar,
|
||||
taskId,
|
||||
} = props;
|
||||
const { t } = useTranslationRef(scaffolderTranslationRef);
|
||||
|
||||
const { allowed: canReadTask } = usePermission({
|
||||
permission: taskReadPermission,
|
||||
resourceRef: taskId,
|
||||
});
|
||||
|
||||
const { allowed: canCreateTask } = usePermission({
|
||||
permission: taskCreatePermission,
|
||||
});
|
||||
|
||||
const canStartOver = canReadTask && canCreateTask;
|
||||
|
||||
return (
|
||||
<Header
|
||||
title={title}
|
||||
customActions={
|
||||
<MenuTrigger>
|
||||
<ButtonIcon
|
||||
variant="tertiary"
|
||||
icon={<RiMore2Line />}
|
||||
aria-label={t('ongoingTask.contextMenu.moreOptions')}
|
||||
/>
|
||||
<Menu placement="bottom end">
|
||||
<MenuItem
|
||||
onAction={() => onToggleLogs?.(!logsVisible)}
|
||||
iconStart={<RiFileListLine size={16} />}
|
||||
>
|
||||
{logsVisible
|
||||
? t('ongoingTask.contextMenu.hideLogs')
|
||||
: t('ongoingTask.contextMenu.showLogs')}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onAction={() => onToggleButtonBar?.(!buttonBarVisible)}
|
||||
iconStart={<RiAddCircleLine size={16} />}
|
||||
>
|
||||
{buttonBarVisible
|
||||
? t('ongoingTask.contextMenu.hideButtonBar')
|
||||
: t('ongoingTask.contextMenu.showButtonBar')}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onAction={onStartOver}
|
||||
isDisabled={cancelEnabled || !canStartOver}
|
||||
iconStart={<RiRepeatLine size={16} />}
|
||||
>
|
||||
{t('ongoingTask.contextMenu.startOver')}
|
||||
</MenuItem>
|
||||
{isRetryableTask && (
|
||||
<MenuItem
|
||||
onAction={onRetry}
|
||||
isDisabled={cancelEnabled || !canRetry}
|
||||
iconStart={<RiReplay10Line size={16} />}
|
||||
>
|
||||
{t('ongoingTask.contextMenu.retry')}
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem
|
||||
onAction={props.onCancel}
|
||||
isDisabled={props.isCancelButtonDisabled}
|
||||
iconStart={<RiCloseCircleLine size={16} />}
|
||||
color="danger"
|
||||
>
|
||||
{t('ongoingTask.contextMenu.cancel')}
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</MenuTrigger>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -13,4 +13,4 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
export { OngoingTask } from './OngoingTask';
|
||||
export { OngoingTask, OngoingTaskBody } from './OngoingTask';
|
||||
|
||||
@@ -13,4 +13,7 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
export { TemplatingExtensionsPage } from './TemplatingExtensionsPage';
|
||||
export {
|
||||
TemplatingExtensionsPage,
|
||||
TemplatingExtensionsPageContent,
|
||||
} from './TemplatingExtensionsPage';
|
||||
|
||||
@@ -182,6 +182,7 @@ export const scaffolderTranslationRef = createTranslationRef({
|
||||
hideLogsButtonTitle: 'Hide Logs',
|
||||
showLogsButtonTitle: 'Show Logs',
|
||||
contextMenu: {
|
||||
moreOptions: 'More options',
|
||||
hideLogs: 'Hide Logs',
|
||||
showLogs: 'Show Logs',
|
||||
hideButtonBar: 'Hide Button Bar',
|
||||
|
||||
@@ -7032,6 +7032,7 @@ __metadata:
|
||||
"@backstage/plugin-techdocs-react": "workspace:^"
|
||||
"@backstage/test-utils": "workspace:^"
|
||||
"@backstage/types": "workspace:^"
|
||||
"@backstage/ui": "workspace:^"
|
||||
"@codemirror/language": "npm:^6.0.0"
|
||||
"@codemirror/legacy-modes": "npm:^6.1.0"
|
||||
"@codemirror/view": "npm:^6.0.0"
|
||||
@@ -7039,6 +7040,7 @@ __metadata:
|
||||
"@material-ui/icons": "npm:^4.9.1"
|
||||
"@material-ui/lab": "npm:4.0.0-alpha.61"
|
||||
"@react-hookz/web": "npm:^24.0.0"
|
||||
"@remixicon/react": "npm:^4.6.0"
|
||||
"@rjsf/core": "npm:5.24.13"
|
||||
"@rjsf/material-ui": "npm:5.24.13"
|
||||
"@rjsf/utils": "npm:5.24.13"
|
||||
|
||||
Reference in New Issue
Block a user