From 39bbb5279e46025ab76bbc08248677144bd429f1 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Tue, 5 Mar 2024 16:25:42 +0100 Subject: [PATCH 1/8] Propose two new annotations to help quantify scaffolder ROI Signed-off-by: Eric Peterson --- .../software-catalog/descriptor-format.md | 11 +++++++++++ .../well-known-annotations.md | 19 +++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/docs/features/software-catalog/descriptor-format.md b/docs/features/software-catalog/descriptor-format.md index b2089eccaa..c7f2998e79 100644 --- a/docs/features/software-catalog/descriptor-format.md +++ b/docs/features/software-catalog/descriptor-format.md @@ -659,6 +659,8 @@ metadata: name: v1beta2-demo title: Test Action template description: scaffolder v1beta2 template demo + annotations: + backstage.io/hours-saved: '4' spec: owner: backstage/techdocs-core type: service @@ -736,6 +738,15 @@ A list of strings that can be associated with the template, e.g. This list will also be used in the frontend to display to the user so you can potentially search and group templates by these tags. +### `metadata.annotations.[backstage.io/hours-saved]` [optional] + +The approximate number of hours (as a YAML string) representing the amount of +time saved when someone uses this template. + +Can be used in combination with the `backstage.io/source-template` annotation, +or analytics data, to calculate how much time has been saved through the use +of the Scaffolder plugin. + ### `spec.type` [required] The type of component created by the template, e.g. `website`. This is used for diff --git a/docs/features/software-catalog/well-known-annotations.md b/docs/features/software-catalog/well-known-annotations.md index 70f5ff5e14..974fa80993 100644 --- a/docs/features/software-catalog/well-known-annotations.md +++ b/docs/features/software-catalog/well-known-annotations.md @@ -146,6 +146,25 @@ repository itself. If the URL points to a folder, it is important that it is suffixed with a `'/'` in order for relative path resolution to work consistently. +### backstage.io/source-template + +```yaml +# Example: +metadata: + annotations: + backstage.io/source-template: template:default/create-react-app-template +``` + +Represents the entity ref of the Scaffolder template that was originally used +to create the given entity. Useful to power "create something similar" +experiences, as well as to track adherence to software standards across the +Catalog. + +Note that this value is only automatically added to an entity when the +`catalog:write` action is used to create the `catalog-info.yaml` file. It is +otherwise the template author's responsibility to ensure that any entity +definition included as part of the template contains this annotation. + ### jenkins.io/job-full-name ```yaml From 2e91e74e89e171d67589dde0bc0d9756feee567a Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Tue, 5 Mar 2024 16:31:42 +0100 Subject: [PATCH 2/8] Provide sample data Signed-off-by: Eric Peterson --- .../examples/components/artist-lookup-component.yaml | 1 + .../catalog-model/examples/components/www-artist-component.yaml | 2 ++ 2 files changed, 3 insertions(+) diff --git a/packages/catalog-model/examples/components/artist-lookup-component.yaml b/packages/catalog-model/examples/components/artist-lookup-component.yaml index a3a3780c84..c982d46c3f 100644 --- a/packages/catalog-model/examples/components/artist-lookup-component.yaml +++ b/packages/catalog-model/examples/components/artist-lookup-component.yaml @@ -29,6 +29,7 @@ metadata: title: Alerts icon: alert annotations: + backstage.io/source-template: template:default/springboot-template backstage.io/linguist: 'https://github.com/backstage/backstage/tree/master/plugins/playlist' spec: type: service diff --git a/packages/catalog-model/examples/components/www-artist-component.yaml b/packages/catalog-model/examples/components/www-artist-component.yaml index 1ca394ecbc..1c510e876a 100644 --- a/packages/catalog-model/examples/components/www-artist-component.yaml +++ b/packages/catalog-model/examples/components/www-artist-component.yaml @@ -3,6 +3,8 @@ kind: Component metadata: name: www-artist description: Artist main website + annotations: + backstage.io/source-template: template:default/react-ssr-template spec: type: website lifecycle: production From bcb26747f3e9ca11cf50f4642bf226f11fdc7b1d Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Tue, 5 Mar 2024 16:39:31 +0100 Subject: [PATCH 3/8] Make use of proposed metadata Signed-off-by: Eric Peterson --- .../catalog-create-something-similar.md | 5 + .../components/AboutCard/AboutCard.test.tsx | 135 +++++++++++++++++- .../src/components/AboutCard/AboutCard.tsx | 15 ++ .../catalog/src/components/AboutCard/hooks.ts | 56 ++++++++ 4 files changed, 210 insertions(+), 1 deletion(-) create mode 100644 .changeset/catalog-create-something-similar.md create mode 100644 plugins/catalog/src/components/AboutCard/hooks.ts diff --git a/.changeset/catalog-create-something-similar.md b/.changeset/catalog-create-something-similar.md new file mode 100644 index 0000000000..cbd1632d42 --- /dev/null +++ b/.changeset/catalog-create-something-similar.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog': patch +--- + +Added a "create something similar" button to the `` that is visible and links to the scaffolder template corresponding to the entity's `backstage.io/source-template` annotation, if present. diff --git a/plugins/catalog/src/components/AboutCard/AboutCard.test.tsx b/plugins/catalog/src/components/AboutCard/AboutCard.test.tsx index e27de38913..481d5b4f64 100644 --- a/plugins/catalog/src/components/AboutCard/AboutCard.test.tsx +++ b/plugins/catalog/src/components/AboutCard/AboutCard.test.tsx @@ -31,7 +31,7 @@ import { AboutCard } from './AboutCard'; import { ConfigReader } from '@backstage/core-app-api'; import { RELATION_OWNED_BY } from '@backstage/catalog-model'; import React from 'react'; -import { screen } from '@testing-library/react'; +import { screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { permissionApiRef } from '@backstage/plugin-permission-react'; import { AuthorizeResult } from '@backstage/plugin-permission-common'; @@ -44,6 +44,7 @@ describe('', () => { const catalogApi: jest.Mocked = { getLocationById: jest.fn(), getEntityByName: jest.fn(), + getEntityByRef: jest.fn(), getEntities: jest.fn(), addLocation: jest.fn(), getLocationByRef: jest.fn(), @@ -266,6 +267,138 @@ describe('', () => { expect(screen.getByText('View Source').closest('a')).toBeNull(); }); + it('renders "create something similar" button', async () => { + const entity = { + apiVersion: 'v1', + kind: 'Component', + metadata: { + name: 'software', + annotations: { + 'backstage.io/source-template': 'template:default/foo-template', + }, + }, + spec: { + owner: 'guest', + type: 'service', + lifecycle: 'production', + }, + }; + + // Return any valid value to indicate access to the template is okay. + catalogApi.getEntityByRef.mockImplementation(async ref => { + expect(ref).toBe('template:default/foo-template'); + return entity; + }); + + await renderInTestApp( + + + + + , + { + mountedRoutes: { + '/catalog/:namespace/:kind/:name': entityRouteRef, + '/create/templates/:namespace/:templateName': + createFromTemplateRouteRef, + }, + }, + ); + + await waitFor(() => { + const editLink = screen + .getByTitle('Create something similar') + .closest('a'); + expect(editLink).toHaveAttribute( + 'href', + '/create/templates/default/foo-template', + ); + }); + }); + + it('should not render "create something similar" button if template does not exist', async () => { + const entity = { + apiVersion: 'v1', + kind: 'Component', + metadata: { + name: 'software', + annotations: { + 'backstage.io/source-template': 'template:default/gone-template', + }, + }, + spec: { + owner: 'guest', + type: 'service', + lifecycle: 'production', + }, + }; + + // Return any valid value to indicate access to the template is okay. + catalogApi.getEntityByRef.mockImplementation(async ref => { + expect(ref).toBe('template:default/gone-template'); + return undefined; + }); + + await renderInTestApp( + + + + + , + { + mountedRoutes: { + '/catalog/:namespace/:kind/:name': entityRouteRef, + '/create/templates/:namespace/:templateName': + createFromTemplateRouteRef, + }, + }, + ); + + expect( + screen.queryByTitle('Create something similar'), + ).not.toBeInTheDocument(); + }); + it.each([ 'url:https://backstage.io/catalog-info.yaml', 'file:../../catalog-info.yaml', diff --git a/plugins/catalog/src/components/AboutCard/AboutCard.tsx b/plugins/catalog/src/components/AboutCard/AboutCard.tsx index 09e1790f3b..7c26a567a1 100644 --- a/plugins/catalog/src/components/AboutCard/AboutCard.tsx +++ b/plugins/catalog/src/components/AboutCard/AboutCard.tsx @@ -54,12 +54,14 @@ import { createFromTemplateRouteRef, viewTechDocRouteRef } from '../../routes'; import { AboutContent } from './AboutContent'; import CachedIcon from '@material-ui/icons/Cached'; import CreateComponentIcon from '@material-ui/icons/AddCircleOutline'; +import CopyIcon from '@material-ui/icons/FileCopy'; import DocsIcon from '@material-ui/icons/Description'; import EditIcon from '@material-ui/icons/Edit'; import { isTemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common'; import { parseEntityRef } from '@backstage/catalog-model'; import { useEntityPermission } from '@backstage/plugin-catalog-react/alpha'; import { catalogEntityRefreshPermission } from '@backstage/plugin-catalog-common/alpha'; +import { useSourceTemplateCompoundEntityRef } from './hooks'; const TECHDOCS_ANNOTATION = 'backstage.io/techdocs-ref'; @@ -108,6 +110,7 @@ export function AboutCard(props: AboutCardProps) { const errorApi = useApi(errorApiRef); const viewTechdocLink = useRouteRef(viewTechDocRouteRef); const templateRoute = useRouteRef(createFromTemplateRouteRef); + const sourceTemplateRef = useSourceTemplateCompoundEntityRef(entity); const { allowed: canRefresh } = useEntityPermission( catalogEntityRefreshPermission, ); @@ -236,6 +239,18 @@ export function AboutCard(props: AboutCardProps) { > + {sourceTemplateRef && templateRoute && ( + + + + )} } subheader={} diff --git a/plugins/catalog/src/components/AboutCard/hooks.ts b/plugins/catalog/src/components/AboutCard/hooks.ts new file mode 100644 index 0000000000..412175ff87 --- /dev/null +++ b/plugins/catalog/src/components/AboutCard/hooks.ts @@ -0,0 +1,56 @@ +/* + * Copyright 2020 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 { + CompoundEntityRef, + Entity, + parseEntityRef, +} from '@backstage/catalog-model'; +import { useApi } from '@backstage/core-plugin-api'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; +import useAsync from 'react-use/lib/useAsync'; + +// todo: should this be a constant in a scaffolder package? +const SOURCE_TEMPLATE_ANNOTATION = 'backstage.io/source-template'; + +/** + * Returns the compound entity ref of the source template that was used to + * create this entity (assuming that it still exists and the user has access + * to it). + */ +export const useSourceTemplateCompoundEntityRef = (entity: Entity) => { + const catalogApi = useApi(catalogApiRef); + const { value: sourceTemplateRef } = useAsync(async () => { + const refCandidate = + entity.metadata.annotations?.[SOURCE_TEMPLATE_ANNOTATION]; + let compoundRefCandidate: CompoundEntityRef | undefined; + + if (!refCandidate) { + return undefined; + } + + try { + // Check for access and that this template still exists. + const template = await catalogApi.getEntityByRef(refCandidate); + compoundRefCandidate = parseEntityRef(refCandidate); + + return template !== undefined ? compoundRefCandidate : undefined; + } catch { + return undefined; + } + }, [catalogApi, entity]); + + return sourceTemplateRef; +}; From ea0e006e849cb2fe444277a2d3e154b7f7edfd99 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Wed, 6 Mar 2024 10:02:27 +0100 Subject: [PATCH 4/8] Use ISO durations for time-saved annotation. Co-authored-by: Harrison Hogg Signed-off-by: Eric Peterson --- docs/features/software-catalog/descriptor-format.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/features/software-catalog/descriptor-format.md b/docs/features/software-catalog/descriptor-format.md index c7f2998e79..afa296d2f9 100644 --- a/docs/features/software-catalog/descriptor-format.md +++ b/docs/features/software-catalog/descriptor-format.md @@ -660,7 +660,7 @@ metadata: title: Test Action template description: scaffolder v1beta2 template demo annotations: - backstage.io/hours-saved: '4' + backstage.io/time-saved: PT4H spec: owner: backstage/techdocs-core type: service @@ -738,10 +738,10 @@ A list of strings that can be associated with the template, e.g. This list will also be used in the frontend to display to the user so you can potentially search and group templates by these tags. -### `metadata.annotations.[backstage.io/hours-saved]` [optional] +### `metadata.annotations.[backstage.io/time-saved]` [optional] -The approximate number of hours (as a YAML string) representing the amount of -time saved when someone uses this template. +An ISO 8601 duration representing the approximate amount of time saved when +someone uses this template. Can be used in combination with the `backstage.io/source-template` annotation, or analytics data, to calculate how much time has been saved through the use From f34a9b178d254ecdb026c8f6523198aa4d92fdc6 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Mon, 11 Mar 2024 12:55:08 +0100 Subject: [PATCH 5/8] Add backstage.io/template-source annotation via the catalog:write action Signed-off-by: Eric Peterson --- .changeset/scaffolder-source-of-truth.md | 5 ++++ .../actions/builtin/catalog/write.test.ts | 30 +++++++++++++++++++ .../actions/builtin/catalog/write.ts | 16 +++++++++- 3 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 .changeset/scaffolder-source-of-truth.md diff --git a/.changeset/scaffolder-source-of-truth.md b/.changeset/scaffolder-source-of-truth.md new file mode 100644 index 0000000000..43ea2ff954 --- /dev/null +++ b/.changeset/scaffolder-source-of-truth.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend': patch +--- + +The `catalog:write` action now automatically adds a `backstage.io/template-source` annotation, indicating which Scaffolder template was used to create the entity. diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/write.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/write.test.ts index cc22b75da2..658e4365bf 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/write.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/write.test.ts @@ -94,4 +94,34 @@ describe('catalog:write', () => { yaml.stringify(entity), ); }); + + it('should add backstage.io/source-template if provided', async () => { + const entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'n', + namespace: 'ns', + annotations: {}, + }, + spec: {}, + }; + + await action.handler({ + ...mockContext, + templateInfo: { entityRef: 'template:default/test-skeleton' }, + input: { entity }, + }); + + const expectedEntity = JSON.parse(JSON.stringify(entity)); + expectedEntity.metadata.annotations = { + 'backstage.io/source-template': 'template:default/test-skeleton', + }; + + expect(fsMock.writeFile).toHaveBeenCalledTimes(1); + expect(fsMock.writeFile).toHaveBeenCalledWith( + resolvePath(mockContext.workspacePath, 'catalog-info.yaml'), + yaml.stringify(expectedEntity), + ); + }); }); diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/write.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/write.ts index 3a11fcd946..27c39dd098 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/write.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/write.ts @@ -51,11 +51,25 @@ export function createCatalogWriteAction() { async handler(ctx) { ctx.logger.info(`Writing catalog-info.yaml`); const { filePath, entity } = ctx.input; + const entityRef = ctx.templateInfo?.entityRef; const path = filePath ?? 'catalog-info.yaml'; await fs.writeFile( resolveSafeChildPath(ctx.workspacePath, path), - yaml.stringify(entity), + yaml.stringify({ + ...entity, + metadata: { + ...entity.metadata, + ...(entityRef + ? { + annotations: { + ...entity.metadata.annotations, + 'backstage.io/source-template': entityRef, + }, + } + : undefined), + }, + }), ); }, }); From df99f629b1fb984b98b5f38819f3cee27d223eea Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Mon, 11 Mar 2024 15:08:44 +0100 Subject: [PATCH 6/8] Translate backstage.io/time-saved annotation into minutes saved and apply to create event Signed-off-by: Eric Peterson --- .changeset/scaffolder-create-time-saved.md | 5 ++ docs/plugins/analytics.md | 2 +- plugins/scaffolder-react/api-report-alpha.md | 1 + .../src/next/components/Stepper/Stepper.tsx | 5 +- .../components/Workflow/Workflow.test.tsx | 5 ++ .../src/next/components/Workflow/Workflow.tsx | 4 + .../next/hooks/useTemplateTimeSaved.test.tsx | 74 +++++++++++++++++++ .../src/next/hooks/useTemplateTimeSaved.ts | 49 ++++++++++++ .../TemplateWizardPage.test.tsx | 6 +- 9 files changed, 148 insertions(+), 3 deletions(-) create mode 100644 .changeset/scaffolder-create-time-saved.md create mode 100644 plugins/scaffolder-react/src/next/hooks/useTemplateTimeSaved.test.tsx create mode 100644 plugins/scaffolder-react/src/next/hooks/useTemplateTimeSaved.ts diff --git a/.changeset/scaffolder-create-time-saved.md b/.changeset/scaffolder-create-time-saved.md new file mode 100644 index 0000000000..c975783a50 --- /dev/null +++ b/.changeset/scaffolder-create-time-saved.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-react': patch +--- + +The `value` sent on the `create` analytics event (fired when a Scaffolder template is executed) is now set to the number of minutes saved by executing the template. This value is derived from the `backstage.io/time-saved` annotation on the template entity. diff --git a/docs/plugins/analytics.md b/docs/plugins/analytics.md index a4dfafbf87..6b1f0a8254 100644 --- a/docs/plugins/analytics.md +++ b/docs/plugins/analytics.md @@ -64,7 +64,7 @@ installed, may be captured. | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `navigate` | The URL of the page that was navigated to. | Fired immediately when route location changes (unless associated plugin/route data is ambiguous, in which case the event is fired after plugin/route data becomes known, immediately before the next event or document unload). The parameters of the current route will be included as attributes. | | `click` | The text of the link that was clicked on. | The `to` attribute represents the URL clicked to. | -| `create` | The `name` of the software being created; if no `name` property is requested by the given Software Template, then the string `new {templateName}` is used instead. | The context holds an `entityRef`, set to the template's ref (e.g. `template:default/template-name`). | +| `create` | The `name` of the software being created; if no `name` property is requested by the given Software Template, then the string `new {templateName}` is used instead. | The context holds an `entityRef`, set to the template's ref (e.g. `template:default/template-name`). The `value` represents the number of minutes saved by running the template (based on the template's `backstage.io/time-saved` annotation, if available). | | `search` | The search term entered in any search bar component. | The context holds `searchTypes`, representing `types` constraining the search. The `value` represents the total number of search results for the query. This may not be visible if the permission framework is being used. | | `discover` | The title of the search result that was clicked on | The `value` is the result rank. A `to` attribute is also provided. | | `not-found` | The path of the resource that resulted in a not found page | Fired by at least TechDocs. | diff --git a/plugins/scaffolder-react/api-report-alpha.md b/plugins/scaffolder-react/api-report-alpha.md index 683b8cf8be..6674592920 100644 --- a/plugins/scaffolder-react/api-report-alpha.md +++ b/plugins/scaffolder-react/api-report-alpha.md @@ -157,6 +157,7 @@ export type StepperProps = { manifest: TemplateParameterSchema; extensions: FieldExtensionOptions[]; templateName?: string; + minutesSaved?: number; formProps?: FormProps; initialState?: Record; onCreate: (values: Record) => Promise; diff --git a/plugins/scaffolder-react/src/next/components/Stepper/Stepper.tsx b/plugins/scaffolder-react/src/next/components/Stepper/Stepper.tsx index 9b475870d2..efa100b578 100644 --- a/plugins/scaffolder-react/src/next/components/Stepper/Stepper.tsx +++ b/plugins/scaffolder-react/src/next/components/Stepper/Stepper.tsx @@ -76,6 +76,7 @@ export type StepperProps = { manifest: TemplateParameterSchema; extensions: FieldExtensionOptions[]; templateName?: string; + minutesSaved?: number; formProps?: FormProps; initialState?: Record; onCreate: (values: Record) => Promise; @@ -149,7 +150,9 @@ export const Stepper = (stepperProps: StepperProps) => { props.onCreate(formState); const name = typeof formState.name === 'string' ? formState.name : undefined; - analytics.captureEvent('create', name ?? props.templateName ?? 'unknown'); + analytics.captureEvent('create', name ?? props.templateName ?? 'unknown', { + value: props.minutesSaved, + }); }, [props, formState, analytics]); const currentStep = useTransformSchemaToProps(steps[activeStep], { layouts }); diff --git a/plugins/scaffolder-react/src/next/components/Workflow/Workflow.test.tsx b/plugins/scaffolder-react/src/next/components/Workflow/Workflow.test.tsx index 81c5a697e0..27fde2083f 100644 --- a/plugins/scaffolder-react/src/next/components/Workflow/Workflow.test.tsx +++ b/plugins/scaffolder-react/src/next/components/Workflow/Workflow.test.tsx @@ -25,6 +25,7 @@ import React from 'react'; import { Workflow } from './Workflow'; import { analyticsApiRef } from '@backstage/core-plugin-api'; import { ScaffolderApi, scaffolderApiRef } from '../../../api'; +import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog-react'; const scaffolderApiMock: jest.Mocked = { cancelTask: jest.fn(), @@ -36,10 +37,14 @@ const scaffolderApiMock: jest.Mocked = { listActions: jest.fn(), listTasks: jest.fn(), }; +const catalogApiMock: jest.Mocked = { + getEntityByRef: jest.fn(), +} as any; const analyticsMock = new MockAnalyticsApi(); const apis = TestApiRegistry.from( [scaffolderApiRef, scaffolderApiMock], + [catalogApiRef, catalogApiMock], [analyticsApiRef, analyticsMock], ); diff --git a/plugins/scaffolder-react/src/next/components/Workflow/Workflow.tsx b/plugins/scaffolder-react/src/next/components/Workflow/Workflow.tsx index c6030930c5..d0d818f772 100644 --- a/plugins/scaffolder-react/src/next/components/Workflow/Workflow.tsx +++ b/plugins/scaffolder-react/src/next/components/Workflow/Workflow.tsx @@ -29,6 +29,7 @@ import { Stepper, type StepperProps } from '../Stepper/Stepper'; import { SecretsContextProvider } from '../../../secrets/SecretsContext'; import { useFilteredSchemaProperties } from '../../hooks/useFilteredSchemaProperties'; import { ReviewStepProps } from '@backstage/plugin-scaffolder-react'; +import { useTemplateTimeSavedMinutes } from '../../hooks/useTemplateTimeSaved'; const useStyles = makeStyles({ markdown: { @@ -84,6 +85,8 @@ export const Workflow = (workflowProps: WorkflowProps): JSX.Element | null => { const sortedManifest = useFilteredSchemaProperties(manifest); + const minutesSaved = useTemplateTimeSavedMinutes(templateRef); + useEffect(() => { if (error) { errorApi.post(new Error(`Failed to load template, ${error}`)); @@ -114,6 +117,7 @@ export const Workflow = (workflowProps: WorkflowProps): JSX.Element | null => { diff --git a/plugins/scaffolder-react/src/next/hooks/useTemplateTimeSaved.test.tsx b/plugins/scaffolder-react/src/next/hooks/useTemplateTimeSaved.test.tsx new file mode 100644 index 0000000000..ed54335c7e --- /dev/null +++ b/plugins/scaffolder-react/src/next/hooks/useTemplateTimeSaved.test.tsx @@ -0,0 +1,74 @@ +/* + * 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 { useTemplateTimeSavedMinutes } from './useTemplateTimeSaved'; +import { renderHook, waitFor } from '@testing-library/react'; +import { TestApiProvider } from '@backstage/test-utils'; +import React from 'react'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; +import { TemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common'; + +const getTemplateWithTimeSaved = ( + timeSaved: string | undefined, +): TemplateEntityV1beta3 => ({ + apiVersion: 'scaffolder.backstage.io/v1beta3', + kind: 'Template', + metadata: { + name: 'test-fixture', + ...(timeSaved + ? { + annotations: { + 'backstage.io/time-saved': timeSaved, + }, + } + : undefined), + }, + spec: { + type: 'test-fixture', + steps: [], + }, +}); + +describe('useTemplateTimeSavedMinutes', () => { + it.each([ + ['PT2H', 120], + ['P3D', 4320], + ['2 hours', undefined], + [undefined, undefined], + ])( + 'should return the expected duration given "%s"', + async (given, expected) => { + const templateRef = 'template:default/happy-path'; + const template = getTemplateWithTimeSaved(given); + + const { result } = renderHook( + () => useTemplateTimeSavedMinutes(templateRef), + { + wrapper: ({ children }: React.PropsWithChildren<{}>) => ( + template }]]} + > + {children} + + ), + }, + ); + + await waitFor(() => { + expect(result.current).toEqual(expected); + }); + }, + ); +}); diff --git a/plugins/scaffolder-react/src/next/hooks/useTemplateTimeSaved.ts b/plugins/scaffolder-react/src/next/hooks/useTemplateTimeSaved.ts new file mode 100644 index 0000000000..df87a8bdfa --- /dev/null +++ b/plugins/scaffolder-react/src/next/hooks/useTemplateTimeSaved.ts @@ -0,0 +1,49 @@ +/* + * 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 { useApi } from '@backstage/core-plugin-api'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; +import { Duration } from 'luxon'; +import useAsync from 'react-use/lib/useAsync'; + +/** + * Returns the backstage.io/time-saved annotation (as a number of minutes) for + * a given template entity ref. + */ +export const useTemplateTimeSavedMinutes = (templateRef: string) => { + const catalogApi = useApi(catalogApiRef); + + const { value: timeSavedMinutes } = useAsync(async () => { + const entity = await catalogApi.getEntityByRef(templateRef); + const timeSaved = entity?.metadata.annotations?.['backstage.io/time-saved']; + + // This is not a valid template or the template has no time-saved value. + if (!entity || !timeSaved) { + return undefined; + } + + const durationMs = Duration.fromISO(timeSaved).as('minutes'); + + // The time-saved annotation has an invalid value. Ignore. + if (Number.isNaN(durationMs)) { + return undefined; + } + + return durationMs; + }, [catalogApi, templateRef]); + + return timeSavedMinutes; +}; diff --git a/plugins/scaffolder/src/next/TemplateWizardPage/TemplateWizardPage.test.tsx b/plugins/scaffolder/src/next/TemplateWizardPage/TemplateWizardPage.test.tsx index 5cdae85e8a..269ff81271 100644 --- a/plugins/scaffolder/src/next/TemplateWizardPage/TemplateWizardPage.test.tsx +++ b/plugins/scaffolder/src/next/TemplateWizardPage/TemplateWizardPage.test.tsx @@ -29,8 +29,9 @@ import { } from '@backstage/plugin-scaffolder-react'; import { TemplateWizardPage } from './TemplateWizardPage'; import { rootRouteRef } from '../../routes'; -import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog-react'; import { ANNOTATION_EDIT_URL } from '@backstage/catalog-model'; +import { CatalogApi } from '@backstage/catalog-client'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; jest.mock('react-router-dom', () => { return { @@ -59,6 +60,7 @@ const catalogApiMock: jest.Mocked = { const analyticsMock = new MockAnalyticsApi(); const apis = TestApiRegistry.from( [scaffolderApiRef, scaffolderApiMock], + [catalogApiRef, catalogApiMock], [analyticsApiRef, analyticsMock], [catalogApiRef, catalogApiMock], ); @@ -70,6 +72,7 @@ const entityRefResponse = { name: 'test', annotations: { [ANNOTATION_EDIT_URL]: 'http://localhost:3000', + 'backstage.io/time-saved': 'PT2H', }, }, spec: { @@ -138,6 +141,7 @@ describe('TemplateWizardPage', () => { action: 'create', subject: 'expected-name', context: { entityRef: 'template:default/test' }, + value: 120, }); }); describe('scaffolder page context menu', () => { From 7a2c9032c768f2ce317db9d32fdc1412f77d8147 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Tue, 2 Apr 2024 13:44:39 +0200 Subject: [PATCH 7/8] Address review feedback: - Scaffolder icon instead of copy icon - Examples of ISO 8601 duration strings Signed-off-by: Eric Peterson --- docs/features/software-catalog/descriptor-format.md | 3 ++- plugins/catalog/src/components/AboutCard/AboutCard.test.tsx | 4 ++-- plugins/catalog/src/components/AboutCard/AboutCard.tsx | 4 ++-- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/features/software-catalog/descriptor-format.md b/docs/features/software-catalog/descriptor-format.md index afa296d2f9..038e94bec3 100644 --- a/docs/features/software-catalog/descriptor-format.md +++ b/docs/features/software-catalog/descriptor-format.md @@ -741,7 +741,8 @@ potentially search and group templates by these tags. ### `metadata.annotations.[backstage.io/time-saved]` [optional] An ISO 8601 duration representing the approximate amount of time saved when -someone uses this template. +someone uses this template (e.g. `PT8H` to mean "8 hours saved" or `PT15M` to +mean "15 minutes saved"). Can be used in combination with the `backstage.io/source-template` annotation, or analytics data, to calculate how much time has been saved through the use diff --git a/plugins/catalog/src/components/AboutCard/AboutCard.test.tsx b/plugins/catalog/src/components/AboutCard/AboutCard.test.tsx index 481d5b4f64..f90942dcd8 100644 --- a/plugins/catalog/src/components/AboutCard/AboutCard.test.tsx +++ b/plugins/catalog/src/components/AboutCard/AboutCard.test.tsx @@ -326,10 +326,10 @@ describe('', () => { ); await waitFor(() => { - const editLink = screen + const createSimilarLink = screen .getByTitle('Create something similar') .closest('a'); - expect(editLink).toHaveAttribute( + expect(createSimilarLink).toHaveAttribute( 'href', '/create/templates/default/foo-template', ); diff --git a/plugins/catalog/src/components/AboutCard/AboutCard.tsx b/plugins/catalog/src/components/AboutCard/AboutCard.tsx index 7c26a567a1..a24a132bc4 100644 --- a/plugins/catalog/src/components/AboutCard/AboutCard.tsx +++ b/plugins/catalog/src/components/AboutCard/AboutCard.tsx @@ -27,6 +27,7 @@ import Divider from '@material-ui/core/Divider'; import IconButton from '@material-ui/core/IconButton'; import { makeStyles } from '@material-ui/core/styles'; import { + AppIcon, HeaderIconLinkRow, IconLinkVerticalProps, InfoCardVariants, @@ -54,7 +55,6 @@ import { createFromTemplateRouteRef, viewTechDocRouteRef } from '../../routes'; import { AboutContent } from './AboutContent'; import CachedIcon from '@material-ui/icons/Cached'; import CreateComponentIcon from '@material-ui/icons/AddCircleOutline'; -import CopyIcon from '@material-ui/icons/FileCopy'; import DocsIcon from '@material-ui/icons/Description'; import EditIcon from '@material-ui/icons/Edit'; import { isTemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common'; @@ -248,7 +248,7 @@ export function AboutCard(props: AboutCardProps) { templateName: sourceTemplateRef.name, })} > - + )} From 996611aa839e9fba0f76ddc5fdeec5bff341e4db Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Tue, 2 Apr 2024 16:21:36 +0200 Subject: [PATCH 8/8] Avoid leaking analytics details to external consumers Signed-off-by: Eric Peterson --- .changeset/scaffolder-create-time-saved.md | 4 +++- plugins/scaffolder-react/api-report-alpha.md | 1 - .../src/next/components/Stepper/Stepper.tsx | 13 +++++----- .../src/next/components/Workflow/Workflow.tsx | 24 +++++++++++++++---- 4 files changed, 28 insertions(+), 14 deletions(-) diff --git a/.changeset/scaffolder-create-time-saved.md b/.changeset/scaffolder-create-time-saved.md index c975783a50..44739bf0f1 100644 --- a/.changeset/scaffolder-create-time-saved.md +++ b/.changeset/scaffolder-create-time-saved.md @@ -2,4 +2,6 @@ '@backstage/plugin-scaffolder-react': patch --- -The `value` sent on the `create` analytics event (fired when a Scaffolder template is executed) is now set to the number of minutes saved by executing the template. This value is derived from the `backstage.io/time-saved` annotation on the template entity. +The `value` sent on the `create` analytics event (fired when a Scaffolder template is executed) is now set to the number of minutes saved by executing the template. This value is derived from the `backstage.io/time-saved` annotation on the template entity, if available. + +Note: the `create` event is now captured in the `` component. If you are directly making use of the alpha-exported `` component, an analytics `create` event will no longer be captured on your behalf. diff --git a/plugins/scaffolder-react/api-report-alpha.md b/plugins/scaffolder-react/api-report-alpha.md index 6674592920..683b8cf8be 100644 --- a/plugins/scaffolder-react/api-report-alpha.md +++ b/plugins/scaffolder-react/api-report-alpha.md @@ -157,7 +157,6 @@ export type StepperProps = { manifest: TemplateParameterSchema; extensions: FieldExtensionOptions[]; templateName?: string; - minutesSaved?: number; formProps?: FormProps; initialState?: Record; onCreate: (values: Record) => Promise; diff --git a/plugins/scaffolder-react/src/next/components/Stepper/Stepper.tsx b/plugins/scaffolder-react/src/next/components/Stepper/Stepper.tsx index efa100b578..d0ce60fd79 100644 --- a/plugins/scaffolder-react/src/next/components/Stepper/Stepper.tsx +++ b/plugins/scaffolder-react/src/next/components/Stepper/Stepper.tsx @@ -75,8 +75,12 @@ const useStyles = makeStyles(theme => ({ export type StepperProps = { manifest: TemplateParameterSchema; extensions: FieldExtensionOptions[]; + /** + * @deprecated This was only ever used for analytics tracking purposes, which + * is now handled in the `` component. Passing it in will have no + * effect. + */ templateName?: string; - minutesSaved?: number; formProps?: FormProps; initialState?: Record; onCreate: (values: Record) => Promise; @@ -148,12 +152,7 @@ export const Stepper = (stepperProps: StepperProps) => { const handleCreate = useCallback(() => { props.onCreate(formState); - const name = - typeof formState.name === 'string' ? formState.name : undefined; - analytics.captureEvent('create', name ?? props.templateName ?? 'unknown', { - value: props.minutesSaved, - }); - }, [props, formState, analytics]); + }, [props, formState]); const currentStep = useTransformSchemaToProps(steps[activeStep], { layouts }); diff --git a/plugins/scaffolder-react/src/next/components/Workflow/Workflow.tsx b/plugins/scaffolder-react/src/next/components/Workflow/Workflow.tsx index d0d818f772..e16af5abb9 100644 --- a/plugins/scaffolder-react/src/next/components/Workflow/Workflow.tsx +++ b/plugins/scaffolder-react/src/next/components/Workflow/Workflow.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React, { useEffect } from 'react'; +import React, { useCallback, useEffect } from 'react'; import { Content, InfoCard, @@ -23,13 +23,14 @@ import { } from '@backstage/core-components'; import { stringifyEntityRef } from '@backstage/catalog-model'; import { makeStyles } from '@material-ui/core'; -import { errorApiRef, useApi } from '@backstage/core-plugin-api'; +import { errorApiRef, useAnalytics, useApi } from '@backstage/core-plugin-api'; import { useTemplateParameterSchema } from '../../hooks/useTemplateParameterSchema'; import { Stepper, type StepperProps } from '../Stepper/Stepper'; import { SecretsContextProvider } from '../../../secrets/SecretsContext'; import { useFilteredSchemaProperties } from '../../hooks/useFilteredSchemaProperties'; import { ReviewStepProps } from '@backstage/plugin-scaffolder-react'; import { useTemplateTimeSavedMinutes } from '../../hooks/useTemplateTimeSaved'; +import { JsonValue } from '@backstage/types'; const useStyles = makeStyles({ markdown: { @@ -69,9 +70,10 @@ export type WorkflowProps = { * @alpha */ export const Workflow = (workflowProps: WorkflowProps): JSX.Element | null => { - const { title, description, namespace, templateName, ...props } = + const { title, description, namespace, templateName, onCreate, ...props } = workflowProps; + const analytics = useAnalytics(); const styles = useStyles(); const templateRef = stringifyEntityRef({ kind: 'Template', @@ -87,6 +89,19 @@ export const Workflow = (workflowProps: WorkflowProps): JSX.Element | null => { const minutesSaved = useTemplateTimeSavedMinutes(templateRef); + const workflowOnCreate = useCallback( + async (formState: Record) => { + onCreate(formState); + + const name = + typeof formState.name === 'string' ? formState.name : undefined; + analytics.captureEvent('create', name ?? templateName ?? 'unknown', { + value: minutesSaved, + }); + }, + [onCreate, analytics, templateName, minutesSaved], + ); + useEffect(() => { if (error) { errorApi.post(new Error(`Failed to load template, ${error}`)); @@ -116,8 +131,7 @@ export const Workflow = (workflowProps: WorkflowProps): JSX.Element | null => { >