diff --git a/.changeset/long-humans-hunt.md b/.changeset/long-humans-hunt.md new file mode 100644 index 0000000000..e27ebfbd3d --- /dev/null +++ b/.changeset/long-humans-hunt.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder': minor +--- + +Added ability to create a new local scaffolder template to ease onboarding when creating new templates. diff --git a/plugins/scaffolder/api-report-alpha.md b/plugins/scaffolder/api-report-alpha.md index 6a15507858..4e8ded2644 100644 --- a/plugins/scaffolder/api-report-alpha.md +++ b/plugins/scaffolder/api-report-alpha.md @@ -252,6 +252,9 @@ export const scaffolderTranslationRef: TranslationRef< readonly 'templateEditorPage.templateEditorIntro.loadLocal.title': 'Load Template Directory'; readonly 'templateEditorPage.templateEditorIntro.loadLocal.description': 'Load a local template directory, allowing you to both edit and try executing your own template.'; readonly 'templateEditorPage.templateEditorIntro.loadLocal.unsupportedTooltip': 'Only supported in some Chromium-based browsers'; + readonly 'templateEditorPage.templateEditorIntro.createLocal.title': 'Create New Template'; + readonly 'templateEditorPage.templateEditorIntro.createLocal.description': 'Create a local template directory, allowing you to both edit and try executing your own template.'; + readonly 'templateEditorPage.templateEditorIntro.createLocal.unsupportedTooltip': 'Only supported in some Chromium-based browsers'; readonly 'templateEditorPage.templateEditorIntro.formEditor.title': 'Edit Template Form'; readonly 'templateEditorPage.templateEditorIntro.formEditor.description': 'Preview and edit a template form, either using a sample template or by loading a template from the catalog.'; readonly 'templateEditorPage.templateEditorIntro.fieldExplorer.title': 'Custom Field Explorer'; diff --git a/plugins/scaffolder/src/lib/filesystem/MockFileSystemAccess.ts b/plugins/scaffolder/src/lib/filesystem/MockFileSystemAccess.ts index d466543dbd..c84d557a2e 100644 --- a/plugins/scaffolder/src/lib/filesystem/MockFileSystemAccess.ts +++ b/plugins/scaffolder/src/lib/filesystem/MockFileSystemAccess.ts @@ -42,6 +42,10 @@ class MockDirectoryAccess implements TemplateDirectoryAccess { ); } + createFile(): Promise { + throw new Error('Method not implemented.'); + } + async listFiles(): Promise { return this.files; } diff --git a/plugins/scaffolder/src/lib/filesystem/WebFileSystemAccess.ts b/plugins/scaffolder/src/lib/filesystem/WebFileSystemAccess.ts index 3342ab6744..fe5c69b452 100644 --- a/plugins/scaffolder/src/lib/filesystem/WebFileSystemAccess.ts +++ b/plugins/scaffolder/src/lib/filesystem/WebFileSystemAccess.ts @@ -80,6 +80,27 @@ class WebDirectoryAccess implements TemplateDirectoryAccess { } } } + + async createFile(options: { name: string; data: string }): Promise { + const { name, data } = options; + let file: FileSystemFileHandle; + + // Current create template does not require support for nested directories + if (name.includes('/')) { + const [dir, path] = name.split('/'); + const handle = await this.handle.getDirectoryHandle(dir, { + create: true, + }); + file = await handle.getFileHandle(path, { create: true }); + } else { + file = await this.handle.getFileHandle(name, { + create: true, + }); + } + const writable = await file.createWritable(); + await writable.write(data); + await writable.close(); + } } /** @internal */ diff --git a/plugins/scaffolder/src/lib/filesystem/createExampleTemplate.ts b/plugins/scaffolder/src/lib/filesystem/createExampleTemplate.ts new file mode 100644 index 0000000000..b93d678d5e --- /dev/null +++ b/plugins/scaffolder/src/lib/filesystem/createExampleTemplate.ts @@ -0,0 +1,94 @@ +/* + * Copyright 2024 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 { TemplateDirectoryAccess } from './types'; + +const files = { + 'template.yaml': ` +apiVersion: scaffolder.backstage.io/v1beta3 +# https://backstage.io/docs/features/software-catalog/descriptor-format#kind-template +kind: Template +metadata: + name: generated-example-template + title: Scaffolder Example Template + description: An example template for the scaffolder +spec: + owner: user:guest + type: service + # These parameters are used to generate the input form in the frontend, and are + # used to gather input data for the execution of the template. + parameters: + - title: Fill in some steps + required: + - name + properties: + name: + title: Name + type: string + description: Unique name of the component + owner: + title: Owner + type: string + description: Owner of the component + ui:field: OwnerPicker + ui:options: + catalogFilter: + kind: Group + - title: Choose a location + required: + - repoUrl + properties: + repoUrl: + title: Repository Location + type: string + ui:field: RepoUrlPicker + ui:options: + allowedHosts: + - github.com + steps: + - id: fetch-base + name: Fetch Base + action: fetch:template + input: + url: ./skeleton + values: + name: \${{parameters.name}} + owner: \${{parameters.owner}} + destination: \${{ parameters.repoUrl | parseRepoUrl }}`, + 'skeleton/README.md': `# This service is named \${{values.name}}!`, + 'skeleton/catalog-info.yaml': `apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: \${{values.component_id | dump}} + {%- if values.description %} + description: \${{values.description | dump}} + {%- endif %} + annotations: + github.com/project-slug: \${{values.destination.owner + "/" + values.destination.repo}} + backstage.io/techdocs-ref: dir:. +spec: + type: service + lifecycle: experimental + owner: \${{values.owner | dump}}`, +}; + +export async function createExampleTemplate( + directory: TemplateDirectoryAccess, +) { + for (const [name, data] of Object.entries(files)) { + await directory.createFile({ name, data }); + } +} diff --git a/plugins/scaffolder/src/lib/filesystem/types.ts b/plugins/scaffolder/src/lib/filesystem/types.ts index 11de159b2a..cd66c58130 100644 --- a/plugins/scaffolder/src/lib/filesystem/types.ts +++ b/plugins/scaffolder/src/lib/filesystem/types.ts @@ -22,4 +22,5 @@ export interface TemplateFileAccess { export interface TemplateDirectoryAccess { listFiles(): Promise>; + createFile(options: { name: string; data: string }): Promise; } diff --git a/plugins/scaffolder/src/next/TemplateEditorPage/TemplateEditorIntro.tsx b/plugins/scaffolder/src/next/TemplateEditorPage/TemplateEditorIntro.tsx index d785708a51..465f780a2f 100644 --- a/plugins/scaffolder/src/next/TemplateEditorPage/TemplateEditorIntro.tsx +++ b/plugins/scaffolder/src/next/TemplateEditorPage/TemplateEditorIntro.tsx @@ -46,7 +46,9 @@ const useStyles = makeStyles(theme => ({ interface EditorIntroProps { style?: JSX.IntrinsicElements['div']['style']; - onSelect?: (option: 'local' | 'form' | 'field-explorer') => void; + onSelect?: ( + option: 'create-template' | 'local' | 'form' | 'field-explorer', + ) => void; } export function TemplateEditorIntro(props: EditorIntroProps) { @@ -93,6 +95,47 @@ export function TemplateEditorIntro(props: EditorIntroProps) { ); + const cardCreateLocal = ( + + props.onSelect?.('create-template')} + > + + + {t('templateEditorPage.templateEditorIntro.createLocal.title')} + + + {t( + 'templateEditorPage.templateEditorIntro.createLocal.description', + )} + + + + {!supportsLoad && ( +
+ + + +
+ )} +
+ ); + const cardFormEditor = ( props.onSelect?.('form')}> @@ -140,6 +183,7 @@ export function TemplateEditorIntro(props: EditorIntroProps) { }} > {supportsLoad && cardLoadLocal} + {supportsLoad && cardCreateLocal} {cardFormEditor} {!supportsLoad && cardLoadLocal} {cardFieldExplorer} diff --git a/plugins/scaffolder/src/next/TemplateEditorPage/TemplateEditorPage.tsx b/plugins/scaffolder/src/next/TemplateEditorPage/TemplateEditorPage.tsx index 70d61b9e2c..19eb1d6453 100644 --- a/plugins/scaffolder/src/next/TemplateEditorPage/TemplateEditorPage.tsx +++ b/plugins/scaffolder/src/next/TemplateEditorPage/TemplateEditorPage.tsx @@ -39,12 +39,16 @@ import { import { useTranslationRef } from '@backstage/core-plugin-api/alpha'; import { scaffolderTranslationRef } from '../../translation'; import { WebFileSystemStore } from '../../lib/filesystem/WebFileSystemAccess'; +import { createExampleTemplate } from '../../lib/filesystem/createExampleTemplate'; type Selection = | { type: 'local'; directory: TemplateDirectoryAccess; } + | { + type: 'create-template'; + } | { type: 'form'; } @@ -103,6 +107,15 @@ export function TemplateEditorPage(props: TemplateEditorPageProps) { .then(directory => WebFileSystemStore.setDirectory(directory)) .then(() => navigate(editorLink())) .catch(() => {}); + } else if (option === 'create-template') { + WebFileSystemAccess.requestDirectoryAccess() + .then(directory => { + createExampleTemplate(directory).then(() => { + WebFileSystemStore.setDirectory(directory); + navigate(editorLink()); + }); + }) + .catch(() => {}); } else if (option === 'form') { setSelection({ type: 'form' }); } else if (option === 'field-explorer') { diff --git a/plugins/scaffolder/src/translation.ts b/plugins/scaffolder/src/translation.ts index 5af634a44e..bb0dc3ebad 100644 --- a/plugins/scaffolder/src/translation.ts +++ b/plugins/scaffolder/src/translation.ts @@ -240,6 +240,12 @@ export const scaffolderTranslationRef = createTranslationRef({ 'Load a local template directory, allowing you to both edit and try executing your own template.', unsupportedTooltip: 'Only supported in some Chromium-based browsers', }, + createLocal: { + title: 'Create New Template', + description: + 'Create a local template directory, allowing you to both edit and try executing your own template.', + unsupportedTooltip: 'Only supported in some Chromium-based browsers', + }, formEditor: { title: 'Edit Template Form', description: