add templating extensions page
Signed-off-by: Matt Benson <gudnabrsam@gmail.com>
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'@backstage/plugin-scaffolder-react': minor
|
||||
'@backstage/plugin-scaffolder': minor
|
||||
---
|
||||
|
||||
add templating extensions page
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
+28
-6
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
+431
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
+329
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user