Merge pull request #23401 from backstage/roi/scaffolder-metadata
Standard entity metadata to help measure Scaffolder ROI
This commit is contained in:
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user