Add create and click analytics events to 'next' create page
Signed-off-by: Eric Peterson <ericpeterson@spotify.com>
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user