Add create and click analytics events to 'next' create page

Signed-off-by: Eric Peterson <ericpeterson@spotify.com>
This commit is contained in:
Eric Peterson
2022-10-25 21:05:05 -04:00
parent 4f9607f09e
commit 580285787d
4 changed files with 182 additions and 34 deletions
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder': patch
---
The `create` and `click` analytics events are now also captured on the "next" version of the component creation page.
@@ -13,8 +13,12 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useApiHolder } from '@backstage/core-plugin-api';
import { JsonObject, JsonValue } from '@backstage/types';
import {
useAnalytics,
useApiHolder,
useRouteRefParams,
} from '@backstage/core-plugin-api';
import { JsonValue } from '@backstage/types';
import {
Stepper as MuiStepper,
Step as MuiStep,
@@ -31,6 +35,7 @@ import { createAsyncValidators } from './createAsyncValidators';
import { useTemplateSchema } from './useTemplateSchema';
import { ReviewState } from './ReviewState';
import validator from '@rjsf/validator-ajv8';
import { selectedTemplateRouteRef } from '../../../routes';
const useStyles = makeStyles(theme => ({
backButton: {
@@ -59,10 +64,12 @@ export interface StepperProps {
const Form = withTheme(require('@rjsf/material-ui-v5').Theme);
export const Stepper = (props: StepperProps) => {
const { templateName } = useRouteRefParams(selectedTemplateRouteRef);
const analytics = useAnalytics();
const { steps } = useTemplateSchema(props.manifest);
const apiHolder = useApiHolder();
const [activeStep, setActiveStep] = useState(0);
const [formState, setFormState] = useState({});
const [formState, setFormState] = useState<Record<string, JsonValue>>({});
const [errors, setErrors] = useState<
undefined | Record<string, FieldValidation>
>();
@@ -90,7 +97,11 @@ export const Stepper = (props: StepperProps) => {
setActiveStep(prevActiveStep => prevActiveStep - 1);
};
const handleNext = async ({ formData }: { formData: JsonObject }) => {
const handleNext = async ({
formData,
}: {
formData: Record<string, JsonValue>;
}) => {
// TODO(blam): What do we do about loading states, does each field extension get a chance
// to display it's own loading? Or should we grey out the entire form.
setErrors(undefined);
@@ -105,7 +116,11 @@ export const Stepper = (props: StepperProps) => {
setErrors(returnedValidation);
} else {
setErrors(undefined);
setActiveStep(prevActiveStep => prevActiveStep + 1);
setActiveStep(prevActiveStep => {
const stepNum = prevActiveStep + 1;
analytics.captureEvent('click', `Next Step (${stepNum})`);
return stepNum;
});
}
setFormState(current => ({ ...current, ...formData }));
};
@@ -160,7 +175,17 @@ export const Stepper = (props: StepperProps) => {
</Button>
<Button
variant="contained"
onClick={() => props.onComplete(formState)}
onClick={() => {
props.onComplete(formState);
const name =
typeof formState.name === 'string'
? formState.name
: undefined;
analytics.captureEvent(
'create',
name || `new ${templateName}`,
);
}}
>
Create
</Button>
@@ -0,0 +1,115 @@
/*
* Copyright 2022 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 { ApiProvider } from '@backstage/core-app-api';
import { analyticsApiRef } from '@backstage/core-plugin-api';
import {
MockAnalyticsApi,
renderInTestApp,
TestApiRegistry,
} from '@backstage/test-utils';
import { act, fireEvent } from '@testing-library/react';
import React from 'react';
import { scaffolderApiRef } from '../../api';
import { nextRouteRef, rootRouteRef } from '../../routes';
import { ScaffolderApi } from '../../types';
import { TemplateWizardPage } from './TemplateWizardPage';
jest.mock('react-router-dom', () => {
return {
...(jest.requireActual('react-router-dom') as any),
useParams: () => ({
templateName: 'test',
}),
};
});
const scaffolderApiMock: jest.Mocked<ScaffolderApi> = {
scaffold: jest.fn(),
getTemplateParameterSchema: jest.fn(),
getIntegrationsList: jest.fn(),
getTask: jest.fn(),
streamLogs: jest.fn(),
listActions: jest.fn(),
listTasks: jest.fn(),
};
const analyticsMock = new MockAnalyticsApi();
const apis = TestApiRegistry.from(
[scaffolderApiRef, scaffolderApiMock],
[analyticsApiRef, analyticsMock],
);
describe('TemplateWizardPage', () => {
it('captures expected analytics events', async () => {
scaffolderApiMock.scaffold.mockResolvedValue({ taskId: 'xyz' });
scaffolderApiMock.getTemplateParameterSchema.mockResolvedValue({
steps: [
{
title: 'Step 1',
schema: {
properties: {
name: {
type: 'string',
},
},
},
},
],
title: 'React JSON Schema Form Test',
});
const { findByRole, getByRole } = await renderInTestApp(
<ApiProvider apis={apis}>
<TemplateWizardPage customFieldExtensions={[]} />,
</ApiProvider>,
{
mountedRoutes: {
'/create': nextRouteRef,
'/create-legacy': rootRouteRef,
},
},
);
// Fill out the name field
fireEvent.change(getByRole('textbox', { name: 'name' }), {
target: { value: 'expected-name' },
});
// Go to the final page
await act(async () => {
fireEvent.click(await findByRole('button', { name: 'Review' }));
});
// Create the software
await act(async () => {
fireEvent.click(await findByRole('button', { name: 'Create' }));
});
// The "Next Step" button should have fired an event
expect(analyticsMock.getEvents()[0]).toMatchObject({
action: 'click',
subject: 'Next Step (1)',
context: { entityRef: 'template:default/test' },
});
// And the "Create" button should have fired an event
expect(analyticsMock.getEvents()[1]).toMatchObject({
action: 'create',
subject: 'expected-name',
context: { entityRef: 'template:default/test' },
});
});
});
@@ -26,6 +26,7 @@ import { NextFieldExtensionOptions } from '../../extensions';
import { Navigate, useNavigate } from 'react-router';
import { stringifyEntityRef } from '@backstage/catalog-model';
import {
AnalyticsContext,
errorApiRef,
useApi,
useRouteRef,
@@ -111,34 +112,36 @@ export const TemplateWizardPage = (props: TemplateWizardPageProps) => {
}
return (
<Page themeId="website">
<Header
pageTitleOverride="Create a new component"
title="Create a new component"
subtitle="Create new software components using standard templates in your organization"
/>
<Content>
{loading && <Progress />}
{manifest && (
<InfoCard
title={manifest.title}
subheader={
<MarkdownContent
className={styles.markdown}
content={manifest.description ?? 'No description'}
<AnalyticsContext attributes={{ entityRef: templateRef }}>
<Page themeId="website">
<Header
pageTitleOverride="Create a new component"
title="Create a new component"
subtitle="Create new software components using standard templates in your organization"
/>
<Content>
{loading && <Progress />}
{manifest && (
<InfoCard
title={manifest.title}
subheader={
<MarkdownContent
className={styles.markdown}
content={manifest.description ?? 'No description'}
/>
}
noPadding
titleTypographyProps={{ component: 'h2' }}
>
<Stepper
manifest={manifest}
extensions={props.customFieldExtensions}
onComplete={onComplete}
/>
}
noPadding
titleTypographyProps={{ component: 'h2' }}
>
<Stepper
manifest={manifest}
extensions={props.customFieldExtensions}
onComplete={onComplete}
/>
</InfoCard>
)}
</Content>
</Page>
</InfoCard>
)}
</Content>
</Page>
</AnalyticsContext>
);
};