Translate backstage.io/time-saved annotation into minutes saved and apply to create event

Signed-off-by: Eric Peterson <ericpeterson@spotify.com>
This commit is contained in:
Eric Peterson
2024-03-11 15:08:44 +01:00
parent f34a9b178d
commit df99f629b1
9 changed files with 148 additions and 3 deletions
@@ -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.
+1 -1
View File
@@ -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. |
@@ -157,6 +157,7 @@ export type StepperProps = {
manifest: TemplateParameterSchema;
extensions: FieldExtensionOptions<any, any>[];
templateName?: string;
minutesSaved?: number;
formProps?: FormProps;
initialState?: Record<string, JsonValue>;
onCreate: (values: Record<string, JsonValue>) => Promise<void>;
@@ -76,6 +76,7 @@ export type StepperProps = {
manifest: TemplateParameterSchema;
extensions: FieldExtensionOptions<any, any>[];
templateName?: string;
minutesSaved?: number;
formProps?: FormProps;
initialState?: Record<string, JsonValue>;
onCreate: (values: Record<string, JsonValue>) => Promise<void>;
@@ -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 });
@@ -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<ScaffolderApi> = {
cancelTask: jest.fn(),
@@ -36,10 +37,14 @@ const scaffolderApiMock: jest.Mocked<ScaffolderApi> = {
listActions: jest.fn(),
listTasks: jest.fn(),
};
const catalogApiMock: jest.Mocked<CatalogApi> = {
getEntityByRef: jest.fn(),
} as any;
const analyticsMock = new MockAnalyticsApi();
const apis = TestApiRegistry.from(
[scaffolderApiRef, scaffolderApiMock],
[catalogApiRef, catalogApiMock],
[analyticsApiRef, analyticsMock],
);
@@ -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 => {
<Stepper
manifest={sortedManifest}
templateName={templateName}
minutesSaved={minutesSaved}
{...props}
/>
</InfoCard>
@@ -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<{}>) => (
<TestApiProvider
apis={[[catalogApiRef, { getEntityByRef: async () => template }]]}
>
{children}
</TestApiProvider>
),
},
);
await waitFor(() => {
expect(result.current).toEqual(expected);
});
},
);
});
@@ -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;
};
@@ -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<CatalogApi> = {
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', () => {