add templating extensions page

Signed-off-by: Matt Benson <gudnabrsam@gmail.com>
This commit is contained in:
Matt Benson
2025-02-08 10:37:53 -06:00
parent 51105a038c
commit 4235e87d2f
27 changed files with 1490 additions and 12 deletions
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/plugin-scaffolder-react': minor
'@backstage/plugin-scaffolder': minor
---
add templating extensions page
+1
View File
@@ -29,6 +29,7 @@ module.exports = {
'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',
@@ -317,6 +317,7 @@ export type ScaffolderPageContextMenuProps = {
onActionsClicked?: () => void;
onTasksClicked?: () => void;
onCreateClicked?: () => void;
onTemplatingExtensionsClicked?: () => void;
};
// @alpha (undocumented)
@@ -339,6 +340,7 @@ export const scaffolderReactTranslationRef: TranslationRef<
readonly 'scaffolderPageContextMenu.editorLabel': 'Manage Templates';
readonly 'scaffolderPageContextMenu.actionsLabel': 'Installed Actions';
readonly 'scaffolderPageContextMenu.tasksLabel': 'Task List';
readonly 'scaffolderPageContextMenu.templatingExtensionsLabel': 'Templating Extensions';
readonly 'stepper.backButtonText': 'Back';
readonly 'stepper.createButtonText': 'Create';
readonly 'stepper.reviewButtonText': 'Review';
+4 -4
View File
@@ -86,7 +86,7 @@ export type Action = {
export type ListActionsResponse = Array<Action>;
/**
* The response shape for a single filter in the `listTemplateExtensions` call to the `scaffolder-backend`
* The response shape for a single filter in the `listTemplatingExtensions` call to the `scaffolder-backend`
*
* @public
*/
@@ -101,7 +101,7 @@ export type TemplateFilter = {
};
/**
* The response shape for a single global function in the `listTemplateExtensions` call to the `scaffolder-backend`
* The response shape for a single global function in the `listTemplatingExtensions` call to the `scaffolder-backend`
*
* @public
*/
@@ -115,7 +115,7 @@ export type TemplateGlobalFunction = {
};
/**
* The response shape for a single global value in the `listTemplateExtensions` call to the `scaffolder-backend`
* The response shape for a single global value in the `listTemplatingExtensions` call to the `scaffolder-backend`
*
* @public
*/
@@ -125,7 +125,7 @@ export type TemplateGlobalValue = {
};
/**
* The response shape for the `listTemplateExtensions` call to the `scaffolder-backend`
* The response shape for the `listTemplatingExtensions` call to the `scaffolder-backend`
*
* @public
*/
@@ -26,6 +26,7 @@ import CreateComponentIcon from '@material-ui/icons/AddCircleOutline';
import Description from '@material-ui/icons/Description';
import Edit from '@material-ui/icons/Edit';
import List from '@material-ui/icons/List';
import Functions from '@material-ui/icons/Functions';
import MoreVert from '@material-ui/icons/MoreVert';
import { SyntheticEvent, useState } from 'react';
import { usePermission } from '@backstage/plugin-permission-react';
@@ -48,6 +49,7 @@ export type ScaffolderPageContextMenuProps = {
onActionsClicked?: () => void;
onTasksClicked?: () => void;
onCreateClicked?: () => void;
onTemplatingExtensionsClicked?: () => void;
};
/**
@@ -57,8 +59,13 @@ export function ScaffolderPageContextMenu(
props: ScaffolderPageContextMenuProps,
) {
const { t } = useTranslationRef(scaffolderReactTranslationRef);
const { onEditorClicked, onActionsClicked, onTasksClicked, onCreateClicked } =
props;
const {
onEditorClicked,
onActionsClicked,
onTasksClicked,
onCreateClicked,
onTemplatingExtensionsClicked,
} = props;
const classes = useStyles();
const [anchorEl, setAnchorEl] = useState<HTMLButtonElement>();
@@ -71,10 +78,13 @@ export function ScaffolderPageContextMenu(
});
if (
!onEditorClicked &&
!onActionsClicked &&
!onTasksClicked &&
!onCreateClicked
!(
onEditorClicked ||
onActionsClicked ||
onTasksClicked ||
onCreateClicked ||
onTemplatingExtensionsClicked
)
) {
return null;
}
@@ -132,6 +142,18 @@ export function ScaffolderPageContextMenu(
/>
</MenuItem>
)}
{onTemplatingExtensionsClicked && (
<MenuItem onClick={onTemplatingExtensionsClicked}>
<ListItemIcon>
<Functions fontSize="small" />
</ListItemIcon>
<ListItemText
primary={t(
'scaffolderPageContextMenu.templatingExtensionsLabel',
)}
/>
</MenuItem>
)}
{onActionsClicked && (
<MenuItem onClick={onActionsClicked}>
<ListItemIcon>
@@ -29,6 +29,7 @@ export const scaffolderReactTranslationRef = createTranslationRef({
editorLabel: 'Manage Templates',
actionsLabel: 'Installed Actions',
tasksLabel: 'Task List',
templatingExtensionsLabel: 'Templating Extensions',
},
stepper: {
backButtonText: 'Back',
+23
View File
@@ -41,6 +41,7 @@ const _default: FrontendPlugin<
actions: SubRouteRef<undefined>;
listTasks: SubRouteRef<undefined>;
edit: SubRouteRef<undefined>;
templatingExtensions: SubRouteRef<undefined>;
},
{
registerComponent: ExternalRouteRef<undefined>;
@@ -367,6 +368,26 @@ export const scaffolderTranslationRef: TranslationRef<
readonly 'renderSchema.tableCell.type': 'Type';
readonly 'renderSchema.tableCell.title': 'Title';
readonly 'renderSchema.tableCell.description': 'Description';
readonly 'templatingExtensions.content.values.title': 'Values';
readonly 'templatingExtensions.content.values.notAvailable': 'There are no global template values defined.';
readonly 'templatingExtensions.content.emptyState.title': 'No information to display';
readonly 'templatingExtensions.content.emptyState.description': 'There are no templating extensions available or there was an issue communicating with the backend.';
readonly 'templatingExtensions.content.filters.title': 'Filters';
readonly 'templatingExtensions.content.filters.schema.input': 'Input';
readonly 'templatingExtensions.content.filters.schema.output': 'Output';
readonly 'templatingExtensions.content.filters.schema.arguments': 'Arguments';
readonly 'templatingExtensions.content.filters.examples': 'Examples';
readonly 'templatingExtensions.content.filters.notAvailable': 'There are no template filters defined.';
readonly 'templatingExtensions.content.filters.metadataAbsent': 'Filter metadata unavailable';
readonly 'templatingExtensions.content.searchFieldPlaceholder': 'Search for an extension';
readonly 'templatingExtensions.content.functions.title': 'Functions';
readonly 'templatingExtensions.content.functions.schema.output': 'Output';
readonly 'templatingExtensions.content.functions.schema.arguments': 'Arguments';
readonly 'templatingExtensions.content.functions.examples': 'Examples';
readonly 'templatingExtensions.content.functions.notAvailable': 'There are no global template functions defined.';
readonly 'templatingExtensions.title': 'Templating Extensions';
readonly 'templatingExtensions.subtitle': 'This is the collection of available templating extensions';
readonly 'templatingExtensions.pageTitle': 'Templating Extensions';
readonly 'templateTypePicker.title': 'Categories';
readonly 'templateIntroPage.title': 'Manage Templates';
readonly 'templateIntroPage.subtitle': 'Edit, preview, and try out templates, forms, and custom fields';
@@ -422,6 +443,7 @@ export const scaffolderTranslationRef: TranslationRef<
readonly 'templateWizardPage.pageContextMenu.editConfigurationTitle': 'Edit Configuration';
readonly 'templateEditorToolbar.customFieldExplorerTooltip': 'Custom Fields Explorer';
readonly 'templateEditorToolbar.installedActionsDocumentationTooltip': 'Installed Actions Documentation';
readonly 'templateEditorToolbar.templatingExtensionsDocumentationTooltip': 'Templating Extensions Documentation';
readonly 'templateEditorToolbar.addToCatalogButton': 'Publish';
readonly 'templateEditorToolbar.addToCatalogDialogTitle': 'Publish changes';
readonly 'templateEditorToolbar.addToCatalogDialogContent.stepsIntroduction': 'Follow the instructions below to create or update a template:';
@@ -447,6 +469,7 @@ export type TemplateListPageProps = {
editor?: boolean;
actions?: boolean;
tasks?: boolean;
templatingExtensions?: boolean;
};
headerOptions?: {
pageTitleOverride?: string;
+1
View File
@@ -618,6 +618,7 @@ export const scaffolderPlugin: BackstagePlugin<
editor: SubRouteRef<undefined>;
customFields: SubRouteRef<undefined>;
templateForm: SubRouteRef<undefined>;
templatingExtensions: SubRouteRef<undefined>;
},
{
registerComponent: ExternalRouteRef<undefined, true>;
@@ -30,6 +30,7 @@ import DialogContentText from '@material-ui/core/DialogContentText';
import DialogActions from '@material-ui/core/DialogActions';
import ExtensionIcon from '@material-ui/icons/Extension';
import DescriptionIcon from '@material-ui/icons/Description';
import FunctionsIcon from '@material-ui/icons/Functions';
import { useTranslationRef } from '@backstage/frontend-plugin-api';
import { FieldExtensionOptions } from '@backstage/plugin-scaffolder-react';
@@ -37,6 +38,7 @@ import { FieldExtensionOptions } from '@backstage/plugin-scaffolder-react';
import { ActionPageContent } from '../../../components/ActionsPage/ActionsPage';
import { scaffolderTranslationRef } from '../../../translation';
import { CustomFieldPlaygroud } from './CustomFieldPlaygroud';
import { TemplatingExtensionsPageContent } from '../../../components/TemplatingExtensionsPage/TemplatingExtensionsPage';
const useStyles = makeStyles(
theme => ({
@@ -83,6 +85,7 @@ export function TemplateEditorToolbar(props: {
const { t } = useTranslationRef(scaffolderTranslationRef);
const [showFieldsDrawer, setShowFieldsDrawer] = useState(false);
const [showActionsDrawer, setShowActionsDrawer] = useState(false);
const [showExtensionsDrawer, setShowExtensionsDrawer] = useState(false);
const [showPublishModal, setShowPublishModal] = useState(false);
return (
@@ -106,6 +109,15 @@ export function TemplateEditorToolbar(props: {
<DescriptionIcon />
</Button>
</Tooltip>
<Tooltip
title={t(
'templateEditorToolbar.templatingExtensionsDocumentationTooltip',
)}
>
<Button onClick={() => setShowExtensionsDrawer(true)}>
<FunctionsIcon />
</Button>
</Tooltip>
<Button onClick={() => setShowPublishModal(true)}>
{t('templateEditorToolbar.addToCatalogButton')}
</Button>
@@ -126,6 +138,14 @@ export function TemplateEditorToolbar(props: {
>
<ActionPageContent />
</Drawer>
<Drawer
classes={{ paper: classes.paper }}
anchor="right"
open={showExtensionsDrawer}
onClose={() => setShowExtensionsDrawer(false)}
>
<TemplatingExtensionsPageContent />
</Drawer>
<Dialog
onClose={() => setShowPublishModal(false)}
open={showPublishModal}
@@ -204,6 +204,7 @@ describe('TemplateListPage', () => {
editor: false,
actions: false,
tasks: false,
templatingExtensions: false,
}}
/>
</TestApiProvider>,
@@ -49,6 +49,7 @@ import {
registerComponentRouteRef,
scaffolderListTaskRouteRef,
selectedTemplateRouteRef,
templatingExtensionsRouteRef,
viewTechDocRouteRef,
} from '../../../routes';
import { parseEntityRef, stringifyEntityRef } from '@backstage/catalog-model';
@@ -72,6 +73,7 @@ export type TemplateListPageProps = {
editor?: boolean;
actions?: boolean;
tasks?: boolean;
templatingExtensions?: boolean;
};
headerOptions?: {
pageTitleOverride?: string;
@@ -108,6 +110,7 @@ export const TemplateListPage = (props: TemplateListPageProps) => {
const tasksLink = useRouteRef(scaffolderListTaskRouteRef);
const viewTechDocsLink = useRouteRef(viewTechDocRouteRef);
const templateRoute = useRouteRef(selectedTemplateRouteRef);
const templatingExtensionsLink = useRouteRef(templatingExtensionsRouteRef);
const app = useApp();
const { t } = useTranslationRef(scaffolderTranslationRef);
@@ -133,6 +136,10 @@ export const TemplateListPage = (props: TemplateListPageProps) => {
props?.contextMenu?.tasks !== false
? () => navigate(tasksLink())
: undefined,
onTemplatingExtensionsClicked:
props?.contextMenu?.templatingExtensions !== false
? () => navigate(templatingExtensionsLink())
: undefined,
};
const additionalLinksForEntity = useCallback(
+2
View File
@@ -24,6 +24,7 @@ import {
scaffolderListTaskRouteRef,
scaffolderTaskRouteRef,
selectedTemplateRouteRef,
templatingExtensionsRouteRef,
viewTechDocRouteRef,
} from '../routes';
import {
@@ -45,6 +46,7 @@ export default createFrontendPlugin({
actions: actionsRouteRef,
listTasks: scaffolderListTaskRouteRef,
edit: editRouteRef,
templatingExtensions: templatingExtensionsRouteRef,
}),
externalRoutes: convertLegacyRouteRefs({
registerComponent: registerComponentRouteRef,
+1 -1
View File
@@ -328,7 +328,7 @@ export class ScaffolderClient implements ScaffolderApi {
async listTemplatingExtensions(): Promise<ListTemplatingExtensionsResponse> {
const baseUrl = await this.discoveryApi.getBaseUrl('scaffolder');
const response = await this.fetchApi.fetch(
`${baseUrl}/v2/template-extensions`,
`${baseUrl}/v2/templating-extensions`,
);
if (!response.ok) {
throw ResponseError.fromResponse(response);
@@ -46,6 +46,7 @@ import {
editRouteRef,
rootRouteRef,
scaffolderListTaskRouteRef,
templatingExtensionsRouteRef,
} from '../../routes';
import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
import { scaffolderTranslationRef } from '../../translation';
@@ -247,6 +248,7 @@ export const ActionsPage = (props: ActionsPageProps) => {
const editorLink = useRouteRef(editRouteRef);
const tasksLink = useRouteRef(scaffolderListTaskRouteRef);
const createLink = useRouteRef(rootRouteRef);
const templatingExtensionsLink = useRouteRef(templatingExtensionsRouteRef);
const { t } = useTranslationRef(scaffolderTranslationRef);
const scaffolderPageContextMenuProps = {
@@ -263,6 +265,7 @@ export const ActionsPage = (props: ActionsPageProps) => {
props?.contextMenu?.create !== false
? () => navigate(createLink())
: undefined,
onTemplatingExtensionsClicked: () => navigate(templatingExtensionsLink()),
};
return (
@@ -38,7 +38,12 @@ import {
TaskStatusColumn,
TemplateTitleColumn,
} from './columns';
import { actionsRouteRef, editRouteRef, rootRouteRef } from '../../routes';
import {
actionsRouteRef,
editRouteRef,
rootRouteRef,
templatingExtensionsRouteRef,
} from '../../routes';
import { ScaffolderPageContextMenu } from '@backstage/plugin-scaffolder-react/alpha';
import { useNavigate } from 'react-router-dom';
import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
@@ -164,6 +169,7 @@ export const ListTasksPage = (props: MyTaskPageProps) => {
const actionsLink = useRouteRef(actionsRouteRef);
const createLink = useRouteRef(rootRouteRef);
const { t } = useTranslationRef(scaffolderTranslationRef);
const templatingExtensionsLink = useRouteRef(templatingExtensionsRouteRef);
const scaffolderPageContextMenuProps = {
onEditorClicked:
@@ -179,6 +185,7 @@ export const ListTasksPage = (props: MyTaskPageProps) => {
props?.contextMenu?.create !== false
? () => navigate(createLink())
: undefined,
onTemplatingExtensionsClicked: () => navigate(templatingExtensionsLink()),
};
return (
<Page themeId="home">
@@ -41,6 +41,7 @@ import {
scaffolderTaskRouteRef,
selectedTemplateRouteRef,
templateFormRouteRef,
templatingExtensionsRouteRef,
} from '../../routes';
import { ActionsPage } from '../../components/ActionsPage';
@@ -66,6 +67,7 @@ import {
import { useApp } from '@backstage/core-plugin-api';
import { FormField, OpaqueFormField } from '@internal/scaffolder';
import { useAsync, useMountEffect } from '@react-hookz/web';
import { TemplatingExtensionsPage } from '../TemplatingExtensionsPage';
/**
* The Props for the Scaffolder Router
@@ -248,6 +250,10 @@ export const InternalRouter = (
</RequirePermission>
}
/>
<Route
path={templatingExtensionsRouteRef.path}
element={<TemplatingExtensionsPage />}
/>
<Route path="*" element={<NotFoundErrorPage />} />
</Routes>
);
@@ -0,0 +1,190 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Link, MarkdownContent } from '@backstage/core-components';
import {
ListTemplatingExtensionsResponse,
TemplateFilter,
} from '@backstage/plugin-scaffolder-react';
import Accordion from '@material-ui/core/Accordion';
import AccordionDetails from '@material-ui/core/AccordionDetails';
import AccordionSummary from '@material-ui/core/AccordionSummary';
import Box from '@material-ui/core/Box';
import { ClassNameMap } from '@material-ui/core/styles/withStyles';
import Typography from '@material-ui/core/Typography';
import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
import classNames from 'classnames';
import React, { ReactElement, useState } from 'react';
import { scaffolderTranslationRef } from '../../translation';
import { Expanded, RenderSchema, SchemaRenderContext } from '../RenderSchema';
import { ScaffolderUsageExamplesTable } from '../ScaffolderUsageExamplesTable';
import { inspectFunctionArgSchema } from './functionArgs';
import { Extension, renderFragment } from './navigation';
import { StyleClasses, Xlate } from './types';
const FilterDetailContent = ({
t,
classes,
name,
filter,
}: {
t: Xlate<typeof scaffolderTranslationRef>;
classes: ClassNameMap;
name: string;
filter: TemplateFilter;
}) => {
const expanded = useState<Expanded>({});
if (!Object.keys(filter).length) {
return (
<Typography style={{ fontStyle: 'italic' }}>
{t('templatingExtensions.content.filters.metadataAbsent')}
</Typography>
);
}
const schema = filter.schema;
const partialSchemaRenderContext: Omit<SchemaRenderContext, 'parentId'> = {
classes,
expanded,
headings: [<Typography variant="h6" component="h4" />],
};
return (
<React.Fragment key={`${name}.detail`}>
{filter.description && <MarkdownContent content={filter.description} />}
<Box pb={2}>
<Typography variant="h5" component="h3">
{t('templatingExtensions.content.filters.schema.input')}
</Typography>
<RenderSchema
strategy="root"
context={{
parentId: `${name}.input`,
...partialSchemaRenderContext,
}}
schema={schema?.input ?? {}}
/>
</Box>
{schema?.arguments?.length && (
<Box key={`${name}.args`} pb={2}>
<Typography variant="h5" component="h3">
{t('templatingExtensions.content.filters.schema.arguments')}
</Typography>
{schema.arguments.map((arg, i) => {
const [argSchema, required] = inspectFunctionArgSchema(arg);
return (
<React.Fragment key={i}>
<div
className={classNames({ [classes.argRequired]: required })}
>
<Typography variant="h6" component="h4">
{`[${i}]`}
</Typography>
</div>
<RenderSchema
strategy="root"
context={{
parentId: `${name}.arg${i}`,
...partialSchemaRenderContext,
headings: [<Typography variant="h6" component="h5" />],
}}
schema={argSchema}
/>
</React.Fragment>
);
})}
</Box>
)}
<Box pb={2}>
<Typography variant="h5" component="h3">
{t('templatingExtensions.content.filters.schema.output')}
</Typography>
<RenderSchema
strategy="root"
context={{
parentId: `${name}.output`,
...partialSchemaRenderContext,
}}
schema={schema?.output ?? {}}
/>
</Box>
{filter.examples && (
<Accordion>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography variant="h5" component="h3">
{t('templatingExtensions.content.filters.examples')}
</Typography>
</AccordionSummary>
<AccordionDetails>
<Box pb={2}>
<ScaffolderUsageExamplesTable examples={filter.examples} />
</Box>
</AccordionDetails>
</Accordion>
)}
</React.Fragment>
);
};
export const TemplateFilters = ({
t,
classes,
filters,
baseLink,
selectedItem,
}: {
t: Xlate<typeof scaffolderTranslationRef>;
classes: StyleClasses;
filters: ListTemplatingExtensionsResponse['filters'];
baseLink: ReactElement<Parameters<typeof Link>[0]>;
selectedItem: Extension | null;
}) => {
if (selectedItem && selectedItem.kind !== 'filter') {
return <></>;
}
if (!Object.keys(filters).length) {
return (
<div data-testid="no-filters">
{t('templatingExtensions.content.filters.notAvailable')}
</div>
);
}
return (
<div data-testid="filters">
{Object.entries(
selectedItem
? { [selectedItem.name]: filters[selectedItem.name] }
: filters,
).map(([name, filter]) => {
const fragment = renderFragment({ kind: 'filter', name });
return (
<Box pb={4} key={name} data-testid={name}>
<Typography
id={fragment}
variant="h4"
component="h2"
className={classes.code}
>
{name}
</Typography>
{React.cloneElement(baseLink, {
to: `${baseLink.props.to}#${fragment}`,
})}
<FilterDetailContent {...{ t, classes, name, filter }} />
</Box>
);
})}
</div>
);
};
@@ -0,0 +1,237 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { CodeSnippet, Link, MarkdownContent } from '@backstage/core-components';
import {
ListTemplatingExtensionsResponse,
TemplateGlobalFunction,
} from '@backstage/plugin-scaffolder-react';
import Accordion from '@material-ui/core/Accordion';
import AccordionDetails from '@material-ui/core/AccordionDetails';
import AccordionSummary from '@material-ui/core/AccordionSummary';
import Box from '@material-ui/core/Box';
import { ClassNameMap } from '@material-ui/core/styles/withStyles';
import Typography from '@material-ui/core/Typography';
import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
import classNames from 'classnames';
import React, { ReactElement, useState } from 'react';
import { scaffolderTranslationRef } from '../../translation';
import { Expanded, RenderSchema, SchemaRenderContext } from '../RenderSchema';
import { ScaffolderUsageExamplesTable } from '../ScaffolderUsageExamplesTable';
import { inspectFunctionArgSchema } from './functionArgs';
import { Extension, renderFragment } from './navigation';
import { Xlate } from './types';
const FunctionDetailContent = ({
classes,
name,
fn,
t,
}: {
classes: ClassNameMap;
name: string;
fn: TemplateGlobalFunction;
t: Xlate<typeof scaffolderTranslationRef>;
}) => {
const expanded = useState<Expanded>({});
if (!Object.keys(fn).length) {
return (
<Typography style={{ fontStyle: 'italic' }}>
Function metadata unavailable
</Typography>
);
}
const schema = fn.schema;
const partialSchemaRenderContext: Omit<SchemaRenderContext, 'parentId'> = {
classes,
expanded,
headings: [<Typography variant="h6" component="h4" />],
};
return (
<React.Fragment key={`${name}.detail`}>
{fn.description && <MarkdownContent content={fn.description} />}
{schema?.arguments?.length && (
<Box key={`${name}.args`} pb={2}>
<Typography variant="h5" component="h3">
{t('templatingExtensions.content.functions.schema.arguments')}
</Typography>
{schema.arguments.map((arg, i) => {
const [argSchema, required] = inspectFunctionArgSchema(arg);
return (
<React.Fragment key={i}>
<div
className={classNames({ [classes.argRequired]: required })}
>
<Typography
variant="h6"
component="h4"
>{`[${i}]`}</Typography>
</div>
<RenderSchema
strategy="root"
context={{
parentId: `${name}.arg${i}`,
...partialSchemaRenderContext,
headings: [<Typography variant="h6" component="h5" />],
}}
schema={argSchema}
/>
</React.Fragment>
);
})}
</Box>
)}
<Box pb={2}>
<Typography variant="h5" component="h3">
{t('templatingExtensions.content.functions.schema.output')}
</Typography>
<RenderSchema
strategy="root"
context={{
parentId: `${name}.output`,
...partialSchemaRenderContext,
}}
schema={schema?.output ?? {}}
/>
</Box>
{fn.examples && (
<Accordion>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography variant="h5" component="h3">
{t('templatingExtensions.content.functions.examples')}
</Typography>
</AccordionSummary>
<AccordionDetails>
<Box pb={2}>
<ScaffolderUsageExamplesTable examples={fn.examples} />
</Box>
</AccordionDetails>
</Accordion>
)}
</React.Fragment>
);
};
export const TemplateGlobalFunctions = ({
classes,
functions,
t,
baseLink,
selectedItem,
}: {
classes: ClassNameMap;
functions: ListTemplatingExtensionsResponse['globals']['functions'];
t: Xlate<typeof scaffolderTranslationRef>;
baseLink: ReactElement<Parameters<typeof Link>[0]>;
selectedItem: Extension | null;
}) => {
if (selectedItem && selectedItem.kind !== 'function') {
return <></>;
}
if (!Object.keys(functions).length) {
return (
<div data-testid="no-functions">
{t('templatingExtensions.content.functions.notAvailable')}
</div>
);
}
return (
<div data-testid="functions">
{Object.entries(
selectedItem
? { [selectedItem.name]: functions[selectedItem.name] }
: functions,
).map(([name, fn]) => {
const fragment = renderFragment({ kind: 'function', name });
return (
<Box pb={4} key={name} data-testid={name}>
<Typography
id={fragment}
variant="h4"
component="h2"
className={classes.code}
>
{name}
</Typography>
{React.cloneElement(baseLink, {
to: `${baseLink.props.to}#${fragment}`,
})}
<FunctionDetailContent {...{ classes, name, fn, t }} />
</Box>
);
})}
</div>
);
};
export const TemplateGlobalValues = ({
classes,
t,
values,
baseLink,
selectedItem,
}: {
classes: ClassNameMap;
t: Xlate<typeof scaffolderTranslationRef>;
values: ListTemplatingExtensionsResponse['globals']['values'];
baseLink: ReactElement<Parameters<typeof Link>[0]>;
selectedItem: Extension | null;
}) => {
if (selectedItem && selectedItem.kind !== 'value') {
return <></>;
}
if (!Object.keys(values).length) {
return (
<div data-testid="no-values">
{t('templatingExtensions.content.values.notAvailable')}
</div>
);
}
return (
<div data-testid="values">
{Object.entries(
selectedItem
? { [selectedItem.name]: values[selectedItem.name] }
: values,
).map(([name, gv]) => {
const fragment = renderFragment({ kind: 'value', name });
return (
<Box pb={4} key={name} data-testid={name}>
<Typography
id={fragment}
variant="h4"
component="h2"
className={classes.code}
>
{name}
</Typography>
{React.cloneElement(baseLink, {
to: `${baseLink.props.to}#${fragment}`,
})}
{gv.description && <MarkdownContent content={gv.description} />}
<Box padding={1} data-testid={`${name}.value`}>
<CodeSnippet
text={JSON.stringify(gv.value, null, 2)}
showCopyCodeButton
language="json"
/>
</Box>
</Box>
);
})}
</div>
);
};
@@ -0,0 +1,431 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ApiProvider } from '@backstage/core-app-api';
import { permissionApiRef } from '@backstage/plugin-permission-react';
import {
ListTemplatingExtensionsResponse,
ScaffolderApi,
scaffolderApiRef,
} from '@backstage/plugin-scaffolder-react';
import { renderInTestApp, TestApiRegistry } from '@backstage/test-utils';
import { fireEvent, within } from '@testing-library/react';
import { capitalize } from 'lodash';
import { rootRouteRef } from '../../routes';
import { TemplatingExtensionsPage } from './TemplatingExtensionsPage';
import { ExtensionKind } from './navigation';
const listTemplatingExtensions = jest.fn();
const scaffolderApiMock: jest.Mocked<ScaffolderApi> = {
scaffold: jest.fn(),
cancelTask: jest.fn(),
getTemplateParameterSchema: jest.fn(),
getIntegrationsList: jest.fn(),
getTask: jest.fn(),
streamLogs: jest.fn(),
listActions: jest.fn(),
listTemplatingExtensions,
listTasks: jest.fn(),
autocomplete: jest.fn(),
};
const mockPermissionApi = { authorize: jest.fn() };
const apis = TestApiRegistry.from(
[scaffolderApiRef, scaffolderApiMock],
[permissionApiRef, mockPermissionApi],
);
const r = async () =>
renderInTestApp(
<ApiProvider apis={apis}>
<TemplatingExtensionsPage />
</ApiProvider>,
{
mountedRoutes: {
'/create/templating-extensions': rootRouteRef,
},
},
);
const emptyExtensions: ListTemplatingExtensionsResponse = {
filters: {},
globals: {
functions: {},
values: {},
},
};
describe('TemplatingExtensionsPage', () => {
it('renders with error response', async () => {
listTemplatingExtensions.mockRejectedValue(new Error('contrived'));
const { getByTestId } = await r();
const empty = getByTestId('empty');
expect(empty).toBeInTheDocument();
expect(within(empty).getByText('contrived')).toBeInTheDocument();
});
it('renders tabs', async () => {
listTemplatingExtensions.mockResolvedValue(emptyExtensions);
const { getByRole, findByTestId, queryByTestId } = await r();
const tabList = getByRole('tablist');
expect(tabList).toBeInTheDocument();
const extensionKinds: ExtensionKind[] = ['filter', 'function', 'value'];
const tabs = Object.fromEntries(
extensionKinds.map(
t =>
[t, within(tabList).getByText(`${capitalize(t)}s`)] as [
ExtensionKind,
HTMLElement,
],
),
);
for (const k of extensionKinds) {
expect(tabs[k]).toBeInTheDocument();
}
const verifyActiveTab = async (k: ExtensionKind) => {
expect(await findByTestId(`no-${k}s`)).toBeInTheDocument();
extensionKinds
.filter(candidate => candidate !== k)
.forEach(nk =>
expect(queryByTestId(`no-${nk}s`)).not.toBeInTheDocument(),
);
};
await verifyActiveTab('filter');
for (let i = extensionKinds.length - 1; i >= 0; i--) {
fireEvent.click(tabs[extensionKinds[i]]);
await verifyActiveTab(extensionKinds[i]);
}
});
it('renders with no extensions', async () => {
listTemplatingExtensions.mockResolvedValue(emptyExtensions);
const { findByTestId, getByRole, getByTestId, queryByTestId } = await r();
expect(queryByTestId('empty')).not.toBeInTheDocument();
expect(getByTestId('no-filters')).toBeInTheDocument();
fireEvent.click(within(getByRole('tablist')).getByText('Functions'));
await expect(findByTestId('no-functions')).resolves.toBeInTheDocument();
fireEvent.click(within(getByRole('tablist')).getByText('Values'));
await expect(findByTestId('no-values')).resolves.toBeInTheDocument();
});
describe('renders filters', () => {
it('renders filter without metadata', async () => {
listTemplatingExtensions.mockResolvedValue({
...emptyExtensions,
filters: {
bar: {},
},
});
const { getByTestId, queryByTestId } = await r();
const filters = getByTestId('filters');
expect(filters).toBeInTheDocument();
const bar = within(filters).getByTestId('bar');
expect(bar).toBeInTheDocument();
const title = within(bar).getByText('bar');
expect(title).toBeInTheDocument();
expect(title.id).toBe('filter_bar');
const link = within(bar).getByRole('link');
expect(link).toBeInTheDocument();
expect(link).toHaveAttribute(
'href',
expect.stringMatching(new RegExp(`#${title.id}$`)),
);
expect(queryByTestId('root_bar.input')).not.toBeInTheDocument();
expect(queryByTestId('root_bar.arg0')).not.toBeInTheDocument();
expect(queryByTestId('root_bar.output')).not.toBeInTheDocument();
});
it('renders input/output with empty filter schema', async () => {
listTemplatingExtensions.mockResolvedValue({
...emptyExtensions,
filters: {
foo: {
schema: {},
},
},
});
const { getByTestId, queryByTestId } = await r();
const filters = getByTestId('filters');
expect(filters).toBeInTheDocument();
const foo = within(filters).getByTestId('foo');
expect(foo).toBeInTheDocument();
const title = within(foo).getByText('foo');
expect(title).toBeInTheDocument();
expect(title.id).toBe('filter_foo');
const link = within(foo).getByRole('link');
expect(link).toBeInTheDocument();
expect(link).toHaveAttribute(
'href',
expect.stringMatching(new RegExp(`#${title.id}$`)),
);
expect(getByTestId('root_foo.input')).toBeInTheDocument();
expect(queryByTestId('root_foo.arg0')).not.toBeInTheDocument();
expect(getByTestId('root_foo.output')).toBeInTheDocument();
});
it('renders fully specified filter metadata', async () => {
listTemplatingExtensions.mockResolvedValue({
...emptyExtensions,
filters: {
foo: {
description: 'foo filter',
schema: {
input: {
description: 'a value',
},
arguments: [
{
description: 'an arg',
},
],
output: {
description: 'same value',
},
},
},
},
});
const { getByTestId } = await r();
const filters = getByTestId('filters');
expect(filters).toBeInTheDocument();
const foo = within(filters).getByTestId('foo');
expect(foo).toBeInTheDocument();
const title = within(foo).getByText('foo');
expect(title).toBeInTheDocument();
expect(title.id).toBe('filter_foo');
const link = within(foo).getByRole('link');
expect(link).toBeInTheDocument();
expect(link).toHaveAttribute(
'href',
expect.stringMatching(new RegExp(`#${title.id}$`)),
);
expect(within(foo).getByText('foo filter')).toBeInTheDocument();
expect(within(foo).getByTestId('root_foo.input')).toBeInTheDocument();
expect(within(foo).getByTestId('root_foo.arg0')).toBeInTheDocument();
expect(
within(foo).queryByTestId('root_foo.arg1'),
).not.toBeInTheDocument();
expect(within(foo).getByTestId('root_foo.output')).toBeInTheDocument();
});
it('renders multiple args', async () => {
listTemplatingExtensions.mockResolvedValue({
...emptyExtensions,
filters: {
baz: {
schema: {
arguments: [
{
type: 'number',
},
{
type: 'string',
},
],
},
},
},
});
const { getByTestId } = await r();
const filters = getByTestId('filters');
expect(filters).toBeInTheDocument();
const baz = within(filters).getByTestId('baz');
expect(baz).toBeInTheDocument();
const title = within(baz).getByText('baz');
expect(title).toBeInTheDocument();
expect(title.id).toBe('filter_baz');
const link = within(baz).getByRole('link');
expect(link).toBeInTheDocument();
expect(link).toHaveAttribute(
'href',
expect.stringMatching(new RegExp(`#${title.id}$`)),
);
expect(within(baz).getByTestId('root_baz.input')).toBeInTheDocument();
expect(within(baz).getByTestId('root_baz.arg0')).toBeInTheDocument();
expect(within(baz).getByTestId('root_baz.arg1')).toBeInTheDocument();
expect(within(baz).getByTestId('root_baz.output')).toBeInTheDocument();
});
it('renders examples', async () => {
listTemplatingExtensions.mockResolvedValue({
...emptyExtensions,
filters: {
wut: {
examples: [
{
description: 'thing 1',
example: 'let me show you',
},
{
description: 'thing 2',
example: "how it's done",
},
],
},
},
});
const { getByTestId } = await r();
const filters = getByTestId('filters');
expect(filters).toBeInTheDocument();
const wut = within(filters).getByTestId('wut');
expect(wut).toBeInTheDocument();
expect(within(wut).getByTestId('examples')).toBeInTheDocument();
});
});
describe('renders global', () => {
it('renders global functions', async () => {
listTemplatingExtensions.mockResolvedValue({
...emptyExtensions,
globals: {
...emptyExtensions.globals,
functions: {
truthy: {
description: 'evaluate truthiness',
schema: {
arguments: [
{
title: 'input',
},
],
output: {
type: 'boolean',
},
},
examples: [
{
description: 'basic usage',
example: "truthy('foo')",
notes: 'yields `true`',
},
],
},
},
},
});
const { findByTestId, getByRole } = await r();
fireEvent.click(within(getByRole('tablist')).getByText('Functions'));
const functions = await findByTestId('functions');
const truthy = within(functions).getByTestId('truthy');
const title = within(truthy).getByText('truthy');
expect(title).toBeInTheDocument();
expect(title.id).toBe('function_truthy');
const link = within(truthy).getByRole('link');
expect(link).toBeInTheDocument();
expect(link).toHaveAttribute(
'href',
expect.stringMatching(new RegExp(`#${title.id}$`)),
);
expect(
within(truthy).getByText('evaluate truthiness'),
).toBeInTheDocument();
expect(within(truthy).getByText('[0]')).toBeInTheDocument();
expect(
within(truthy).getByTestId('root_truthy.arg0'),
).toBeInTheDocument();
expect(
within(truthy).queryByTestId('root_truthy.arg1'),
).not.toBeInTheDocument();
expect(
within(truthy).getByTestId('root_truthy.output'),
).toBeInTheDocument();
const x = within(truthy).getByTestId('examples');
expect(x).toBeInTheDocument();
const xd0 = within(x).getByTestId('example_desc0');
expect(xd0).toBeInTheDocument();
expect(xd0).toHaveTextContent(/basic usage\s*yields\s*true/);
const xc0 = within(x).getByTestId('example_code0');
expect(within(xc0).getByText("truthy('foo')")).toBeInTheDocument();
});
it('renders global values', async () => {
const msvValue = ['foo', 'bar', 'baz'];
listTemplatingExtensions.mockResolvedValue({
...emptyExtensions,
globals: {
...emptyExtensions.globals,
values: {
msv: {
description: 'metasyntactic variables',
value: msvValue,
},
},
},
});
const { findByTestId, getByRole } = await r();
fireEvent.click(within(getByRole('tablist')).getByText('Values'));
const values = await findByTestId('values');
const msv = within(values).getByTestId('msv');
expect(msv).toBeInTheDocument();
const title = within(msv).getByText('msv');
expect(title).toBeInTheDocument();
expect(title.id).toBe('value_msv');
const link = within(msv).getByRole('link');
expect(link).toBeInTheDocument();
expect(link).toHaveAttribute(
'href',
expect.stringMatching(new RegExp(`#${title.id}$`)),
);
expect(
within(msv).getByText('metasyntactic variables'),
).toBeInTheDocument();
const msvValueElement = within(msv).getByTestId('msv.value');
expect(msvValueElement).toBeInTheDocument();
expect(JSON.parse(msvValueElement.textContent!)).toEqual(msvValue);
});
});
});
@@ -0,0 +1,329 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useApi, useRouteRef } from '@backstage/core-plugin-api';
import {
actionsRouteRef,
editRouteRef,
rootRouteRef,
scaffolderListTaskRouteRef,
templatingExtensionsRouteRef,
} from '../../routes';
import { makeStyles } from '@material-ui/core/styles';
import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
import { scaffolderTranslationRef } from '../../translation';
import {
Content,
EmptyState,
ErrorPanel,
Header,
Link,
Page,
Progress,
} from '@backstage/core-components';
import { scaffolderApiRef } from '@backstage/plugin-scaffolder-react';
import {
ScaffolderPageContextMenu,
ScaffolderPageContextMenuProps,
} from '@backstage/plugin-scaffolder-react/alpha';
import Box from '@material-ui/core/Box';
import InputAdornment from '@material-ui/core/InputAdornment';
import ListItemText from '@material-ui/core/ListItemText';
import Tab from '@material-ui/core/Tab';
import Tabs from '@material-ui/core/Tabs';
import TextField from '@material-ui/core/TextField';
import AllInclusiveIcon from '@material-ui/icons/AllInclusive';
import FilterListIcon from '@material-ui/icons/FilterList';
import FunctionsIcon from '@material-ui/icons/Functions';
import LinkIcon from '@material-ui/icons/Link';
import SearchIcon from '@material-ui/icons/Search';
import Autocomplete from '@material-ui/lab/Autocomplete';
import { useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import useAsync from 'react-use/esm/useAsync';
import {
Extension,
ExtensionKind,
listTemplatingExtensions,
parseFragment,
} from './navigation';
import { TemplateFilters } from './TemplateFilters';
import {
TemplateGlobalFunctions,
TemplateGlobalValues,
} from './TemplateGlobals';
const useStyles = makeStyles(theme => ({
code: {
fontFamily: 'Menlo, monospace',
padding: theme.spacing(1),
backgroundColor:
theme.palette.type === 'dark'
? theme.palette.grey[700]
: theme.palette.grey[300],
display: 'inline-block',
borderRadius: 5,
border: `1px solid ${theme.palette.grey[500]}`,
position: 'relative',
},
codeRequired: {
'&::after': {
position: 'absolute',
content: '"*"',
top: 0,
right: theme.spacing(0.5),
fontWeight: 'bolder',
color: theme.palette.error.light,
},
},
argRequired: {
position: 'relative',
'& > *': {
display: 'inline',
position: 'relative',
'&::after': {
position: 'absolute',
content: '"*"',
top: 0,
right: theme.spacing(-1),
fontWeight: 'bolder',
color: theme.palette.error.light,
},
},
},
link: {
paddingLeft: theme.spacing(1),
cursor: 'pointer',
},
tabs: {
display: 'block',
minHeight: 'initial',
overflow: 'initial',
},
}));
export const TemplatingExtensionsPageContent = ({
linkLocal,
}: {
linkLocal?: boolean;
}) => {
const api = useApi(scaffolderApiRef);
const classes = useStyles();
const { t } = useTranslationRef(scaffolderTranslationRef);
const { loading, value, error } = useAsync(async () => {
if (api.listTemplatingExtensions) {
return api.listTemplatingExtensions();
}
// eslint-disable-next-line no-console
console.warn(
'listTemplatingExtensions is not implemented in the scaffolderApi; please make sure to implement this method.',
);
return Promise.resolve({
filters: {},
globals: { functions: {}, values: {} },
});
}, [api]);
const [tab, selectTab] = useState<ExtensionKind>('filter');
const [selectedItem, setSelectedItem] = useState<Extension | null>(null);
const [input, setInput] = useState<string>('');
const handleTab = (_event: any, kind: ExtensionKind) => {
if (selectedItem?.kind !== kind) {
setSelectedItem(null);
setInput('');
}
selectTab(kind);
};
const selectItem = (item: Extension | null) => {
setSelectedItem(item);
if (item) {
selectTab(item.kind);
}
};
useEffect(() => {
if (value && window.location.hash) {
try {
selectTab(parseFragment(window.location.hash.substring(1)).kind);
document.querySelector(window.location.hash)?.scrollIntoView();
} catch (e) {
// ignore bad link
}
}
}, [value]);
const extensionKinds = useMemo(
() => ({
filter: {
icon: <FilterListIcon />,
label: t('templatingExtensions.content.filters.title'),
},
function: {
icon: <FunctionsIcon />,
label: t('templatingExtensions.content.functions.title'),
},
value: {
icon: <AllInclusiveIcon />,
label: t('templatingExtensions.content.values.title'),
},
}),
[t],
);
const templatingExtensionsLink = useRouteRef(templatingExtensionsRouteRef);
if (loading) {
return <Progress />;
}
if (error || !value) {
return (
<div data-testid="empty">
{error && <ErrorPanel error={error} />}
<EmptyState
missing="info"
title={t('templatingExtensions.content.emptyState.title')}
description={t('templatingExtensions.content.emptyState.description')}
/>
</div>
);
}
const { filters, globals } = value;
const baseLink = (
<Link
className={classes.link}
to={templatingExtensionsLink()}
{...(linkLocal ? {} : { target: '_blank', rel: 'noopener noreferrer' })}
>
<LinkIcon />
</Link>
);
return (
<>
<Autocomplete
renderInput={params => (
<TextField
{...params}
aria-label={t(
'templatingExtensions.content.searchFieldPlaceholder',
)}
placeholder={t(
'templatingExtensions.content.searchFieldPlaceholder',
)}
variant="outlined"
InputProps={{
...params.InputProps,
startAdornment: (
<InputAdornment position="start">
<SearchIcon />
</InputAdornment>
),
}}
/>
)}
getOptionLabel={option => option.name}
getOptionSelected={(lhs, rhs) => lhs === rhs}
options={listTemplatingExtensions(value)}
groupBy={option => option.kind}
renderGroup={params => (
<>
<Box display="flex" alignItems="center">
{extensionKinds[params.group as ExtensionKind].icon}
<Box sx={{ ml: 1 }}>
{extensionKinds[params.group as ExtensionKind].label}
</Box>
</Box>
<ul>{params.children}</ul>
</>
)}
renderOption={(option: Extension) => (
<ListItemText primary={option.name} />
)}
onChange={(_event: any, option: Extension | null) => {
selectItem(option);
}}
inputValue={input}
onInputChange={(_event: any, s: string) => setInput(s)}
loading={loading}
fullWidth
clearOnEscape
/>
<Tabs value={tab} onChange={handleTab} centered className={classes.tabs}>
{Object.entries(extensionKinds).map(([k, v]) => (
<Tab key={k} value={k} {...v} />
))}
</Tabs>
{tab === 'filter' && (
<TemplateFilters {...{ baseLink, t, classes, filters, selectedItem }} />
)}
{tab === 'function' && (
<TemplateGlobalFunctions
functions={globals.functions}
{...{ baseLink, t, classes, selectedItem }}
/>
)}
{tab === 'value' && (
<TemplateGlobalValues
values={globals.values}
{...{ baseLink, t, classes, selectedItem }}
/>
)}
</>
);
};
export const TemplatingExtensionsPage = () => {
const navigate = useNavigate();
const editorLink = useRouteRef(editRouteRef);
const tasksLink = useRouteRef(scaffolderListTaskRouteRef);
const createLink = useRouteRef(rootRouteRef);
const actionsLink = useRouteRef(actionsRouteRef);
const scaffolderPageContextMenuProps: ScaffolderPageContextMenuProps = {
onEditorClicked: () => navigate(editorLink()),
onActionsClicked: () => navigate(actionsLink()),
onTasksClicked: () => navigate(tasksLink()),
onCreateClicked: () => navigate(createLink()),
};
const { t } = useTranslationRef(scaffolderTranslationRef);
return (
<Page themeId="home">
<Header
pageTitleOverride={t('templatingExtensions.pageTitle')}
title={t('templatingExtensions.title')}
subtitle={t('templatingExtensions.subtitle')}
>
<ScaffolderPageContextMenu {...scaffolderPageContextMenuProps} />
</Header>
<Content>
<TemplatingExtensionsPageContent linkLocal />
</Content>
</Page>
);
};
@@ -0,0 +1,42 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { JSONSchema7, JSONSchema7Definition } from 'json-schema';
function isSchema(d: JSONSchema7Definition): d is JSONSchema7 {
return typeof d === 'object';
}
export function inspectFunctionArgSchema(
schema: JSONSchema7Definition,
): [schema: JSONSchema7Definition, required: boolean] {
const hasOnlyKey = (k: string) => (o: any) =>
k in o && Object.keys(o).every(ok => ok.startsWith('$') || ok === k);
if (
isSchema(schema) &&
hasOnlyKey('anyOf')(schema) &&
schema.anyOf?.length === 2
) {
const alt0 = schema.anyOf[0];
if (isSchema(alt0) && hasOnlyKey('not')(alt0)) {
const not = alt0.not!;
if (isSchema(not) && !Object.keys(not).length) {
return [schema.anyOf[1], false];
}
}
}
return [schema, true];
}
@@ -0,0 +1,16 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { TemplatingExtensionsPage } from './TemplatingExtensionsPage';
@@ -0,0 +1,56 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ListTemplatingExtensionsResponse } from '@backstage/plugin-scaffolder-react';
const kinds = ['filter', 'function', 'value'] as const;
export type ExtensionKind = (typeof kinds)[number];
export type Extension = {
kind: ExtensionKind;
name: string;
};
export const listTemplatingExtensions = (
data: Partial<Pick<ListTemplatingExtensionsResponse, 'filters'>> &
Partial<{ globals: Partial<ListTemplatingExtensionsResponse['globals']> }>,
): Extension[] => {
const exts = (
kind: ExtensionKind,
record: Record<string, any> | undefined,
): Extension[] =>
record ? Object.keys(record).map((name: string) => ({ kind, name })) : [];
return [
...exts('filter', data.filters),
...exts('function', data.globals?.functions),
...exts('value', data.globals?.values),
];
};
export const renderFragment = (e: Extension) => `${e.kind}_${e.name}`;
export const parseFragment = (fragment: string): Extension => {
const [k, name] = fragment.split('_', 2);
const kind = k as ExtensionKind;
if (kinds.includes(kind)) {
return {
kind,
name,
};
}
throw Error(fragment);
};
@@ -0,0 +1,28 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
TranslationFunction,
TranslationRef,
} from '@backstage/core-plugin-api/alpha';
import { ClassNameMap } from '@material-ui/core/styles/withStyles';
export type StyleClasses = ClassNameMap<
'code' | 'codeRequired' | 'argRequired' | 'link'
>;
export type Xlate<R> = R extends TranslationRef<any, infer M>
? TranslationFunction<M>
: never;
+2
View File
@@ -71,6 +71,7 @@ import {
editorRouteRef,
customFieldsRouteRef,
templateFormRouteRef,
templatingExtensionsRouteRef,
} from './routes';
import {
MyGroupsPicker,
@@ -126,6 +127,7 @@ export const scaffolderPlugin = createPlugin({
editor: editorRouteRef,
customFields: customFieldsRouteRef,
templateForm: templateFormRouteRef,
templatingExtensions: templatingExtensionsRouteRef,
},
externalRoutes: {
registerComponent: registerComponentRouteRef,
+6
View File
@@ -96,3 +96,9 @@ export const templateFormRouteRef = createSubRouteRef({
parent: rootRouteRef,
path: '/template-form',
});
export const templatingExtensionsRouteRef = createSubRouteRef({
id: 'scaffolder/templating-extensions',
parent: rootRouteRef,
path: '/templating-extensions',
});
+39
View File
@@ -194,6 +194,43 @@ export const scaffolderTranslationRef = createTranslationRef({
},
undefined: 'No schema defined',
},
templatingExtensions: {
title: 'Templating Extensions',
pageTitle: 'Templating Extensions',
subtitle: 'This is the collection of available templating extensions',
content: {
emptyState: {
title: 'No information to display',
description:
'There are no templating extensions available or there was an issue communicating with the backend.',
},
searchFieldPlaceholder: 'Search for an extension',
filters: {
title: 'Filters',
notAvailable: 'There are no template filters defined.',
metadataAbsent: 'Filter metadata unavailable',
schema: {
input: 'Input',
arguments: 'Arguments',
output: 'Output',
},
examples: 'Examples',
},
functions: {
title: 'Functions',
notAvailable: 'There are no global template functions defined.',
schema: {
arguments: 'Arguments',
output: 'Output',
},
examples: 'Examples',
},
values: {
title: 'Values',
notAvailable: 'There are no global template values defined.',
},
},
},
templateTypePicker: {
title: 'Categories',
},
@@ -316,6 +353,8 @@ export const scaffolderTranslationRef = createTranslationRef({
templateEditorToolbar: {
customFieldExplorerTooltip: 'Custom Fields Explorer',
installedActionsDocumentationTooltip: 'Installed Actions Documentation',
templatingExtensionsDocumentationTooltip:
'Templating Extensions Documentation',
addToCatalogButton: 'Publish',
addToCatalogDialogTitle: 'Publish changes',
addToCatalogDialogContent: {