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:
Patrik Oldsberg
2026-03-20 16:34:12 +01:00
committed by GitHub
23 changed files with 1277 additions and 186 deletions
+5
View File
@@ -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.
+1
View File
@@ -0,0 +1 @@
Fix scaffolder plugin page layout in the new frontend system
+7 -6
View File
@@ -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 */
+2
View File
@@ -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",
+191
View File
@@ -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';
+1
View File
@@ -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>
);
}
+72 -16
View File
@@ -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,
+27 -8
View File
@@ -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,
+10
View File
@@ -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';
+1
View File
@@ -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',
+2
View File
@@ -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"