scaffolder: migrate NFS plugin to use SubPageBlueprint page layout
Refactored the scaffolder plugin's new frontend system (NFS) definition to use the SubPageBlueprint pattern with tabbed page layout, mirroring the approach used by the Settings plugin. The scaffolder page is now a parent PageBlueprint without a loader, receiving sub-pages as inputs that render as tabs: - Templates (with nested template wizard route) - Tasks (with nested ongoing task detail route) - Actions - Template Editor (with nested editor/form/fields routes) - Templating Extensions Page components used in the NFS path no longer render their own Page/Header chrome, relying on the framework's PageLayout instead. Legacy frontend system compatibility is preserved. Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com> Made-with: Cursor
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-scaffolder': minor
|
||||
---
|
||||
|
||||
Migrated the scaffolder plugin's new frontend system (NFS) definition to use the `SubPageBlueprint` pattern with tabbed navigation. The plugin now renders a parent page with sub-pages for Templates, Tasks, Actions, Template Editor, and Templating Extensions, matching the pattern used by the Settings plugin. Each sub-page handles its own internal routing, including parameterized routes for template wizard and task detail views. The legacy frontend system compatibility is preserved.
|
||||
@@ -195,6 +195,8 @@ const _default: OverridableFrontendPlugin<
|
||||
};
|
||||
}>;
|
||||
'page:scaffolder': OverridableExtensionDefinition<{
|
||||
kind: 'page';
|
||||
name: undefined;
|
||||
config: {
|
||||
path: string | undefined;
|
||||
title: string | undefined;
|
||||
@@ -258,21 +260,7 @@ const _default: OverridableFrontendPlugin<
|
||||
internal: false;
|
||||
}
|
||||
>;
|
||||
formFields: ExtensionInput<
|
||||
ConfigurableExtensionDataRef<
|
||||
() => Promise<FormField>,
|
||||
'scaffolder.form-field-loader',
|
||||
{}
|
||||
>,
|
||||
{
|
||||
singleton: false;
|
||||
optional: false;
|
||||
internal: false;
|
||||
}
|
||||
>;
|
||||
};
|
||||
kind: 'page';
|
||||
name: undefined;
|
||||
params: {
|
||||
path: string;
|
||||
title?: string;
|
||||
@@ -432,6 +420,209 @@ 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: {
|
||||
formFields: ExtensionInput<
|
||||
ConfigurableExtensionDataRef<
|
||||
() => Promise<FormField>,
|
||||
'scaffolder.form-field-loader',
|
||||
{}
|
||||
>,
|
||||
{
|
||||
singleton: false;
|
||||
optional: false;
|
||||
internal: false;
|
||||
}
|
||||
>;
|
||||
};
|
||||
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;
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
/*
|
||||
* 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,
|
||||
},
|
||||
formContent: {
|
||||
padding: 0,
|
||||
},
|
||||
});
|
||||
|
||||
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';
|
||||
@@ -33,7 +34,16 @@ import { scmIntegrationsApiRef } from '@backstage/integration-react';
|
||||
import { scaffolderApiRef } from '@backstage/plugin-scaffolder-react';
|
||||
import { ScaffolderClient } from '../api';
|
||||
|
||||
export const scaffolderPage = PageBlueprint.makeWithOverrides({
|
||||
export const scaffolderPage = PageBlueprint.make({
|
||||
params: {
|
||||
routeRef: rootRouteRef,
|
||||
path: '/create',
|
||||
title: 'Create',
|
||||
},
|
||||
});
|
||||
|
||||
export const scaffolderTemplatesSubPage = SubPageBlueprint.makeWithOverrides({
|
||||
name: 'templates',
|
||||
inputs: {
|
||||
formFields: createExtensionInput([
|
||||
FormFieldBlueprint.dataRefs.formFieldLoader,
|
||||
@@ -43,29 +53,80 @@ export const scaffolderPage = PageBlueprint.makeWithOverrides({
|
||||
const formFieldsApi = apis.get(formFieldsApiRef);
|
||||
|
||||
return originalFactory({
|
||||
routeRef: rootRouteRef,
|
||||
path: '/create',
|
||||
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),
|
||||
);
|
||||
|
||||
// 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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -107,33 +107,54 @@ export const OngoingTask = (props: {
|
||||
}}
|
||||
>
|
||||
<Page themeId="website">
|
||||
<OngoingTaskContent {...props} />
|
||||
<OngoingTaskChrome {...props} />
|
||||
</Page>
|
||||
</AnalyticsContext>
|
||||
);
|
||||
};
|
||||
|
||||
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 { namespace, name } =
|
||||
taskStream.task?.spec.templateInfo?.entity?.metadata ?? {};
|
||||
|
||||
return (
|
||||
<AnalyticsContext
|
||||
attributes={{
|
||||
entityRef:
|
||||
name &&
|
||||
stringifyEntityRef({
|
||||
kind: 'template',
|
||||
namespace,
|
||||
name,
|
||||
}),
|
||||
taskId,
|
||||
}}
|
||||
>
|
||||
<OngoingTaskContent {...props} />
|
||||
</AnalyticsContext>
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -153,21 +174,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 +191,170 @@ 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 (
|
||||
<>
|
||||
<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 +417,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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user