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', () => {