From 4235e87d2f038be7bfd49aaaaeec64a63c76a5b1 Mon Sep 17 00:00:00 2001 From: Matt Benson Date: Sat, 8 Feb 2025 10:37:53 -0600 Subject: [PATCH] add templating extensions page Signed-off-by: Matt Benson --- .changeset/small-eggs-develop.md | 6 + lighthouserc.js | 1 + plugins/scaffolder-react/report-alpha.api.md | 2 + plugins/scaffolder-react/src/api/types.ts | 8 +- .../ScaffolderPageContextMenu.tsx | 34 +- plugins/scaffolder-react/src/translation.ts | 1 + plugins/scaffolder/report-alpha.api.md | 23 + plugins/scaffolder/report.api.md | 1 + .../TemplateEditorToolbar.tsx | 20 + .../TemplateListPage.test.tsx | 1 + .../TemplateListPage/TemplateListPage.tsx | 7 + plugins/scaffolder/src/alpha/plugin.tsx | 2 + plugins/scaffolder/src/api.ts | 2 +- .../components/ActionsPage/ActionsPage.tsx | 3 + .../ListTasksPage/ListTasksPage.tsx | 9 +- .../src/components/Router/Router.tsx | 6 + .../TemplateFilters.tsx | 190 ++++++++ .../TemplateGlobals.tsx | 237 ++++++++++ .../TemplatingExtensionsPage.test.tsx | 431 ++++++++++++++++++ .../TemplatingExtensionsPage.tsx | 329 +++++++++++++ .../TemplatingExtensionsPage/functionArgs.ts | 42 ++ .../TemplatingExtensionsPage/index.ts | 16 + .../TemplatingExtensionsPage/navigation.ts | 56 +++ .../TemplatingExtensionsPage/types.ts | 28 ++ plugins/scaffolder/src/plugin.tsx | 2 + plugins/scaffolder/src/routes.ts | 6 + plugins/scaffolder/src/translation.ts | 39 ++ 27 files changed, 1490 insertions(+), 12 deletions(-) create mode 100644 .changeset/small-eggs-develop.md create mode 100644 plugins/scaffolder/src/components/TemplatingExtensionsPage/TemplateFilters.tsx create mode 100644 plugins/scaffolder/src/components/TemplatingExtensionsPage/TemplateGlobals.tsx create mode 100644 plugins/scaffolder/src/components/TemplatingExtensionsPage/TemplatingExtensionsPage.test.tsx create mode 100644 plugins/scaffolder/src/components/TemplatingExtensionsPage/TemplatingExtensionsPage.tsx create mode 100644 plugins/scaffolder/src/components/TemplatingExtensionsPage/functionArgs.ts create mode 100644 plugins/scaffolder/src/components/TemplatingExtensionsPage/index.ts create mode 100644 plugins/scaffolder/src/components/TemplatingExtensionsPage/navigation.ts create mode 100644 plugins/scaffolder/src/components/TemplatingExtensionsPage/types.ts diff --git a/.changeset/small-eggs-develop.md b/.changeset/small-eggs-develop.md new file mode 100644 index 0000000000..618b877fa7 --- /dev/null +++ b/.changeset/small-eggs-develop.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-scaffolder-react': minor +'@backstage/plugin-scaffolder': minor +--- + +add templating extensions page diff --git a/lighthouserc.js b/lighthouserc.js index 396bff5dc7..c7c1275cdb 100644 --- a/lighthouserc.js +++ b/lighthouserc.js @@ -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', diff --git a/plugins/scaffolder-react/report-alpha.api.md b/plugins/scaffolder-react/report-alpha.api.md index c4f4ecc990..06769b29e2 100644 --- a/plugins/scaffolder-react/report-alpha.api.md +++ b/plugins/scaffolder-react/report-alpha.api.md @@ -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'; diff --git a/plugins/scaffolder-react/src/api/types.ts b/plugins/scaffolder-react/src/api/types.ts index acc980afdc..d4a9bbe0f8 100644 --- a/plugins/scaffolder-react/src/api/types.ts +++ b/plugins/scaffolder-react/src/api/types.ts @@ -86,7 +86,7 @@ export type Action = { export type ListActionsResponse = Array; /** - * 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 */ diff --git a/plugins/scaffolder-react/src/next/components/ScaffolderPageContextMenu/ScaffolderPageContextMenu.tsx b/plugins/scaffolder-react/src/next/components/ScaffolderPageContextMenu/ScaffolderPageContextMenu.tsx index 8dc6f1d8ef..9546792983 100644 --- a/plugins/scaffolder-react/src/next/components/ScaffolderPageContextMenu/ScaffolderPageContextMenu.tsx +++ b/plugins/scaffolder-react/src/next/components/ScaffolderPageContextMenu/ScaffolderPageContextMenu.tsx @@ -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(); @@ -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( /> )} + {onTemplatingExtensionsClicked && ( + + + + + + + )} {onActionsClicked && ( diff --git a/plugins/scaffolder-react/src/translation.ts b/plugins/scaffolder-react/src/translation.ts index c097d78e3e..18da2f58e7 100644 --- a/plugins/scaffolder-react/src/translation.ts +++ b/plugins/scaffolder-react/src/translation.ts @@ -29,6 +29,7 @@ export const scaffolderReactTranslationRef = createTranslationRef({ editorLabel: 'Manage Templates', actionsLabel: 'Installed Actions', tasksLabel: 'Task List', + templatingExtensionsLabel: 'Templating Extensions', }, stepper: { backButtonText: 'Back', diff --git a/plugins/scaffolder/report-alpha.api.md b/plugins/scaffolder/report-alpha.api.md index d6c3487aab..de088224b8 100644 --- a/plugins/scaffolder/report-alpha.api.md +++ b/plugins/scaffolder/report-alpha.api.md @@ -41,6 +41,7 @@ const _default: FrontendPlugin< actions: SubRouteRef; listTasks: SubRouteRef; edit: SubRouteRef; + templatingExtensions: SubRouteRef; }, { registerComponent: ExternalRouteRef; @@ -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; diff --git a/plugins/scaffolder/report.api.md b/plugins/scaffolder/report.api.md index 927599b91f..b929d7be5b 100644 --- a/plugins/scaffolder/report.api.md +++ b/plugins/scaffolder/report.api.md @@ -618,6 +618,7 @@ export const scaffolderPlugin: BackstagePlugin< editor: SubRouteRef; customFields: SubRouteRef; templateForm: SubRouteRef; + templatingExtensions: SubRouteRef; }, { registerComponent: ExternalRouteRef; diff --git a/plugins/scaffolder/src/alpha/components/TemplateEditorPage/TemplateEditorToolbar.tsx b/plugins/scaffolder/src/alpha/components/TemplateEditorPage/TemplateEditorToolbar.tsx index 06593a1c6f..6a0d673088 100644 --- a/plugins/scaffolder/src/alpha/components/TemplateEditorPage/TemplateEditorToolbar.tsx +++ b/plugins/scaffolder/src/alpha/components/TemplateEditorPage/TemplateEditorToolbar.tsx @@ -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: { + + + @@ -126,6 +138,14 @@ export function TemplateEditorToolbar(props: { > + setShowExtensionsDrawer(false)} + > + + setShowPublishModal(false)} open={showPublishModal} diff --git a/plugins/scaffolder/src/alpha/components/TemplateListPage/TemplateListPage.test.tsx b/plugins/scaffolder/src/alpha/components/TemplateListPage/TemplateListPage.test.tsx index e2be650851..a16474a4cf 100644 --- a/plugins/scaffolder/src/alpha/components/TemplateListPage/TemplateListPage.test.tsx +++ b/plugins/scaffolder/src/alpha/components/TemplateListPage/TemplateListPage.test.tsx @@ -204,6 +204,7 @@ describe('TemplateListPage', () => { editor: false, actions: false, tasks: false, + templatingExtensions: false, }} /> , diff --git a/plugins/scaffolder/src/alpha/components/TemplateListPage/TemplateListPage.tsx b/plugins/scaffolder/src/alpha/components/TemplateListPage/TemplateListPage.tsx index 072bc81d06..1bc74e64c4 100644 --- a/plugins/scaffolder/src/alpha/components/TemplateListPage/TemplateListPage.tsx +++ b/plugins/scaffolder/src/alpha/components/TemplateListPage/TemplateListPage.tsx @@ -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( diff --git a/plugins/scaffolder/src/alpha/plugin.tsx b/plugins/scaffolder/src/alpha/plugin.tsx index e281f51b0f..90fc22e980 100644 --- a/plugins/scaffolder/src/alpha/plugin.tsx +++ b/plugins/scaffolder/src/alpha/plugin.tsx @@ -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, diff --git a/plugins/scaffolder/src/api.ts b/plugins/scaffolder/src/api.ts index 1121593e2d..4fb3627917 100644 --- a/plugins/scaffolder/src/api.ts +++ b/plugins/scaffolder/src/api.ts @@ -328,7 +328,7 @@ export class ScaffolderClient implements ScaffolderApi { async listTemplatingExtensions(): Promise { 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); diff --git a/plugins/scaffolder/src/components/ActionsPage/ActionsPage.tsx b/plugins/scaffolder/src/components/ActionsPage/ActionsPage.tsx index 8329d236c9..402c0427dc 100644 --- a/plugins/scaffolder/src/components/ActionsPage/ActionsPage.tsx +++ b/plugins/scaffolder/src/components/ActionsPage/ActionsPage.tsx @@ -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 ( diff --git a/plugins/scaffolder/src/components/ListTasksPage/ListTasksPage.tsx b/plugins/scaffolder/src/components/ListTasksPage/ListTasksPage.tsx index 03afa7cb10..6d48cec125 100644 --- a/plugins/scaffolder/src/components/ListTasksPage/ListTasksPage.tsx +++ b/plugins/scaffolder/src/components/ListTasksPage/ListTasksPage.tsx @@ -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 ( diff --git a/plugins/scaffolder/src/components/Router/Router.tsx b/plugins/scaffolder/src/components/Router/Router.tsx index 429ba7b1a6..91567c1ddd 100644 --- a/plugins/scaffolder/src/components/Router/Router.tsx +++ b/plugins/scaffolder/src/components/Router/Router.tsx @@ -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 = ( } /> + } + /> } /> ); diff --git a/plugins/scaffolder/src/components/TemplatingExtensionsPage/TemplateFilters.tsx b/plugins/scaffolder/src/components/TemplatingExtensionsPage/TemplateFilters.tsx new file mode 100644 index 0000000000..32956e2063 --- /dev/null +++ b/plugins/scaffolder/src/components/TemplatingExtensionsPage/TemplateFilters.tsx @@ -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; + classes: ClassNameMap; + name: string; + filter: TemplateFilter; +}) => { + const expanded = useState({}); + if (!Object.keys(filter).length) { + return ( + + {t('templatingExtensions.content.filters.metadataAbsent')} + + ); + } + const schema = filter.schema; + const partialSchemaRenderContext: Omit = { + classes, + expanded, + headings: [], + }; + return ( + + {filter.description && } + + + {t('templatingExtensions.content.filters.schema.input')} + + + + {schema?.arguments?.length && ( + + + {t('templatingExtensions.content.filters.schema.arguments')} + + {schema.arguments.map((arg, i) => { + const [argSchema, required] = inspectFunctionArgSchema(arg); + + return ( + +
+ + {`[${i}]`} + +
+ ], + }} + schema={argSchema} + /> +
+ ); + })} +
+ )} + + + {t('templatingExtensions.content.filters.schema.output')} + + + + {filter.examples && ( + + }> + + {t('templatingExtensions.content.filters.examples')} + + + + + + + + + )} +
+ ); +}; + +export const TemplateFilters = ({ + t, + classes, + filters, + baseLink, + selectedItem, +}: { + t: Xlate; + classes: StyleClasses; + filters: ListTemplatingExtensionsResponse['filters']; + baseLink: ReactElement[0]>; + selectedItem: Extension | null; +}) => { + if (selectedItem && selectedItem.kind !== 'filter') { + return <>; + } + if (!Object.keys(filters).length) { + return ( +
+ {t('templatingExtensions.content.filters.notAvailable')} +
+ ); + } + return ( +
+ {Object.entries( + selectedItem + ? { [selectedItem.name]: filters[selectedItem.name] } + : filters, + ).map(([name, filter]) => { + const fragment = renderFragment({ kind: 'filter', name }); + return ( + + + {name} + + {React.cloneElement(baseLink, { + to: `${baseLink.props.to}#${fragment}`, + })} + + + ); + })} +
+ ); +}; diff --git a/plugins/scaffolder/src/components/TemplatingExtensionsPage/TemplateGlobals.tsx b/plugins/scaffolder/src/components/TemplatingExtensionsPage/TemplateGlobals.tsx new file mode 100644 index 0000000000..8edd5aabd3 --- /dev/null +++ b/plugins/scaffolder/src/components/TemplatingExtensionsPage/TemplateGlobals.tsx @@ -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; +}) => { + const expanded = useState({}); + if (!Object.keys(fn).length) { + return ( + + Function metadata unavailable + + ); + } + const schema = fn.schema; + const partialSchemaRenderContext: Omit = { + classes, + expanded, + headings: [], + }; + return ( + + {fn.description && } + {schema?.arguments?.length && ( + + + {t('templatingExtensions.content.functions.schema.arguments')} + + {schema.arguments.map((arg, i) => { + const [argSchema, required] = inspectFunctionArgSchema(arg); + + return ( + +
+ {`[${i}]`} +
+ ], + }} + schema={argSchema} + /> +
+ ); + })} +
+ )} + + + {t('templatingExtensions.content.functions.schema.output')} + + + + {fn.examples && ( + + }> + + {t('templatingExtensions.content.functions.examples')} + + + + + + + + + )} +
+ ); +}; + +export const TemplateGlobalFunctions = ({ + classes, + functions, + t, + baseLink, + selectedItem, +}: { + classes: ClassNameMap; + functions: ListTemplatingExtensionsResponse['globals']['functions']; + t: Xlate; + baseLink: ReactElement[0]>; + selectedItem: Extension | null; +}) => { + if (selectedItem && selectedItem.kind !== 'function') { + return <>; + } + if (!Object.keys(functions).length) { + return ( +
+ {t('templatingExtensions.content.functions.notAvailable')} +
+ ); + } + return ( +
+ {Object.entries( + selectedItem + ? { [selectedItem.name]: functions[selectedItem.name] } + : functions, + ).map(([name, fn]) => { + const fragment = renderFragment({ kind: 'function', name }); + return ( + + + {name} + + {React.cloneElement(baseLink, { + to: `${baseLink.props.to}#${fragment}`, + })} + + + ); + })} +
+ ); +}; + +export const TemplateGlobalValues = ({ + classes, + t, + values, + baseLink, + selectedItem, +}: { + classes: ClassNameMap; + t: Xlate; + values: ListTemplatingExtensionsResponse['globals']['values']; + baseLink: ReactElement[0]>; + selectedItem: Extension | null; +}) => { + if (selectedItem && selectedItem.kind !== 'value') { + return <>; + } + if (!Object.keys(values).length) { + return ( +
+ {t('templatingExtensions.content.values.notAvailable')} +
+ ); + } + return ( +
+ {Object.entries( + selectedItem + ? { [selectedItem.name]: values[selectedItem.name] } + : values, + ).map(([name, gv]) => { + const fragment = renderFragment({ kind: 'value', name }); + return ( + + + {name} + + {React.cloneElement(baseLink, { + to: `${baseLink.props.to}#${fragment}`, + })} + {gv.description && } + + + + + ); + })} +
+ ); +}; diff --git a/plugins/scaffolder/src/components/TemplatingExtensionsPage/TemplatingExtensionsPage.test.tsx b/plugins/scaffolder/src/components/TemplatingExtensionsPage/TemplatingExtensionsPage.test.tsx new file mode 100644 index 0000000000..d3d0eb7370 --- /dev/null +++ b/plugins/scaffolder/src/components/TemplatingExtensionsPage/TemplatingExtensionsPage.test.tsx @@ -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 = { + 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( + + + , + { + 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); + }); + }); +}); diff --git a/plugins/scaffolder/src/components/TemplatingExtensionsPage/TemplatingExtensionsPage.tsx b/plugins/scaffolder/src/components/TemplatingExtensionsPage/TemplatingExtensionsPage.tsx new file mode 100644 index 0000000000..720d6204ea --- /dev/null +++ b/plugins/scaffolder/src/components/TemplatingExtensionsPage/TemplatingExtensionsPage.tsx @@ -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('filter'); + const [selectedItem, setSelectedItem] = useState(null); + const [input, setInput] = useState(''); + + 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: , + label: t('templatingExtensions.content.filters.title'), + }, + function: { + icon: , + label: t('templatingExtensions.content.functions.title'), + }, + value: { + icon: , + label: t('templatingExtensions.content.values.title'), + }, + }), + [t], + ); + + const templatingExtensionsLink = useRouteRef(templatingExtensionsRouteRef); + + if (loading) { + return ; + } + if (error || !value) { + return ( +
+ {error && } + +
+ ); + } + const { filters, globals } = value; + + const baseLink = ( + + + + ); + + return ( + <> + ( + + + + ), + }} + /> + )} + getOptionLabel={option => option.name} + getOptionSelected={(lhs, rhs) => lhs === rhs} + options={listTemplatingExtensions(value)} + groupBy={option => option.kind} + renderGroup={params => ( + <> + + {extensionKinds[params.group as ExtensionKind].icon} + + {extensionKinds[params.group as ExtensionKind].label} + + +
    {params.children}
+ + )} + renderOption={(option: Extension) => ( + + )} + onChange={(_event: any, option: Extension | null) => { + selectItem(option); + }} + inputValue={input} + onInputChange={(_event: any, s: string) => setInput(s)} + loading={loading} + fullWidth + clearOnEscape + /> + + {Object.entries(extensionKinds).map(([k, v]) => ( + + ))} + + {tab === 'filter' && ( + + )} + {tab === 'function' && ( + + )} + {tab === 'value' && ( + + )} + + ); +}; + +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 ( + +
+ +
+ + + +
+ ); +}; diff --git a/plugins/scaffolder/src/components/TemplatingExtensionsPage/functionArgs.ts b/plugins/scaffolder/src/components/TemplatingExtensionsPage/functionArgs.ts new file mode 100644 index 0000000000..9acc13f1a4 --- /dev/null +++ b/plugins/scaffolder/src/components/TemplatingExtensionsPage/functionArgs.ts @@ -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]; +} diff --git a/plugins/scaffolder/src/components/TemplatingExtensionsPage/index.ts b/plugins/scaffolder/src/components/TemplatingExtensionsPage/index.ts new file mode 100644 index 0000000000..2573c9f0b5 --- /dev/null +++ b/plugins/scaffolder/src/components/TemplatingExtensionsPage/index.ts @@ -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'; diff --git a/plugins/scaffolder/src/components/TemplatingExtensionsPage/navigation.ts b/plugins/scaffolder/src/components/TemplatingExtensionsPage/navigation.ts new file mode 100644 index 0000000000..7b85e270bc --- /dev/null +++ b/plugins/scaffolder/src/components/TemplatingExtensionsPage/navigation.ts @@ -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> & + Partial<{ globals: Partial }>, +): Extension[] => { + const exts = ( + kind: ExtensionKind, + record: Record | 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); +}; diff --git a/plugins/scaffolder/src/components/TemplatingExtensionsPage/types.ts b/plugins/scaffolder/src/components/TemplatingExtensionsPage/types.ts new file mode 100644 index 0000000000..f9c32d469e --- /dev/null +++ b/plugins/scaffolder/src/components/TemplatingExtensionsPage/types.ts @@ -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 extends TranslationRef + ? TranslationFunction + : never; diff --git a/plugins/scaffolder/src/plugin.tsx b/plugins/scaffolder/src/plugin.tsx index 17592b5826..cda4ee461c 100644 --- a/plugins/scaffolder/src/plugin.tsx +++ b/plugins/scaffolder/src/plugin.tsx @@ -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, diff --git a/plugins/scaffolder/src/routes.ts b/plugins/scaffolder/src/routes.ts index 55344a8b0a..2e336757b8 100644 --- a/plugins/scaffolder/src/routes.ts +++ b/plugins/scaffolder/src/routes.ts @@ -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', +}); diff --git a/plugins/scaffolder/src/translation.ts b/plugins/scaffolder/src/translation.ts index 4efd2fe9f7..a5419dd6fa 100644 --- a/plugins/scaffolder/src/translation.ts +++ b/plugins/scaffolder/src/translation.ts @@ -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: {