Merge pull request #23401 from backstage/roi/scaffolder-metadata

Standard entity metadata to help measure Scaffolder ROI
This commit is contained in:
Ben Lambert
2024-04-03 10:38:11 +02:00
committed by GitHub
19 changed files with 463 additions and 12 deletions
@@ -75,6 +75,11 @@ const useStyles = makeStyles(theme => ({
export type StepperProps = {
manifest: TemplateParameterSchema;
extensions: FieldExtensionOptions<any, any>[];
/**
* @deprecated This was only ever used for analytics tracking purposes, which
* is now handled in the `<Workflow />` component. Passing it in will have no
* effect.
*/
templateName?: string;
formProps?: FormProps;
initialState?: Record<string, JsonValue>;
@@ -147,10 +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');
}, [props, formState, analytics]);
}, [props, formState]);
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],
);
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import React, { useEffect } from 'react';
import React, { useCallback, useEffect } from 'react';
import {
Content,
InfoCard,
@@ -23,12 +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: {
@@ -68,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',
@@ -84,6 +87,21 @@ export const Workflow = (workflowProps: WorkflowProps): JSX.Element | null => {
const sortedManifest = useFilteredSchemaProperties(manifest);
const minutesSaved = useTemplateTimeSavedMinutes(templateRef);
const workflowOnCreate = useCallback(
async (formState: Record<string, JsonValue>) => {
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}`));
@@ -113,7 +131,7 @@ export const Workflow = (workflowProps: WorkflowProps): JSX.Element | null => {
>
<Stepper
manifest={sortedManifest}
templateName={templateName}
onCreate={workflowOnCreate}
{...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;
};