diff --git a/src/components/Collapse.ts b/src/components/Collapse.ts index 8c17c0baf3..eda41da833 100644 --- a/src/components/Collapse.ts +++ b/src/components/Collapse.ts @@ -20,6 +20,7 @@ interface CollapseProps extends Omit { titleFirst?: boolean; afterTitle?: ReactNode; onFirstOpen?: () => void; + onOpenChanged?: (isOpened: boolean) => void; noTitleWrap?: boolean; disabled?: boolean; } @@ -37,6 +38,7 @@ const Collapse = (props: CollapseProps): ReactNode => { titleFirst, afterTitle, onFirstOpen = () => {}, + onOpenChanged = () => {}, noTitleWrap, disabled = false, ...rest @@ -81,7 +83,10 @@ const Collapse = (props: CollapseProps): ReactNode => { color: colors.dark(), ...(noTitleWrap ? Style.noWrapEllipsis : {}), }, - onClick: () => setIsOpened(!isOpened), + onClick: () => { + setIsOpened(!isOpened); + onOpenChanged(!isOpened); + }, hover, tooltip, tooltipDelay, diff --git a/src/libs/events.ts b/src/libs/events.ts index e4d516d481..89f0757911 100644 --- a/src/libs/events.ts +++ b/src/libs/events.ts @@ -124,6 +124,12 @@ const eventsList = { workflowsTabView: 'workflowsApp:tab:view', workspaceClone: 'workspace:clone', workspaceCreate: 'workspace:create', + workspaceDashboardToggleSection: 'workspace:dashboard:toggleSection', + workspaceDashboardAddTag: 'workspace:dashboard:addTag', + workspaceDashboardDeleteTag: 'workspace:dashboard:deleteTag', + workspaceDashboardEditDescription: 'workspace:dashboard:editDescription', + workspaceDashboardSaveDescription: 'workspace:dashboard:saveDescription', + workspaceDashboardBucketRequesterPays: 'workspace:dashboard:bucketLocationRequesterPays', workspaceOpenedBucketInBrowser: 'workspace:openedBucketInBrowser', workspaceOpenedProjectInConsole: 'workspace:openedProjectInCloudConsole', workspaceDataAddColumn: 'workspace:data:addColumn', diff --git a/src/pages/workspaces/workspace/Dashboard/BucketLocation.test.ts b/src/pages/workspaces/workspace/Dashboard/BucketLocation.test.ts index 9a4c1d9202..1c0d295e75 100644 --- a/src/pages/workspaces/workspace/Dashboard/BucketLocation.test.ts +++ b/src/pages/workspaces/workspace/Dashboard/BucketLocation.test.ts @@ -1,9 +1,11 @@ import { asMockedFn } from '@terra-ui-packages/test-utils'; import { act, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { axe } from 'jest-axe'; import _ from 'lodash/fp'; import { h } from 'react-hyperscript-helpers'; import { Ajax } from 'src/libs/ajax'; +import Events, { extractWorkspaceDetails } from 'src/libs/events'; import { GoogleWorkspace } from 'src/libs/workspace-utils'; import { BucketLocation } from 'src/pages/workspaces/workspace/Dashboard/BucketLocation'; import { renderWithAppContexts as render } from 'src/testing/test-utils'; @@ -19,6 +21,17 @@ jest.mock('src/libs/ajax'); jest.mock('src/libs/notifications'); +// Needed for bringing up the RequestPays modal +type WorkspaceProviderExports = typeof import('src/libs/ajax/workspaces/providers/WorkspaceProvider'); +jest.mock( + 'src/libs/ajax/workspaces/providers/WorkspaceProvider', + (): WorkspaceProviderExports => ({ + workspaceProvider: { + list: jest.fn(), + }, + }) +); + describe('BucketLocation', () => { const workspace: GoogleWorkspace & { workspaceInitialized: boolean } = { ...defaultGoogleWorkspace, @@ -119,6 +132,7 @@ describe('BucketLocation', () => { it('handles requester pays error', async () => { // Arrange + const user = userEvent.setup(); const props = { workspace, storageDetails: _.mergeAll([ @@ -128,9 +142,11 @@ describe('BucketLocation', () => { ]), }; const requesterPaysError = { message: 'Requester pays bucket', requesterPaysError: true }; + const captureEvent = jest.fn(); asMockedFn(Ajax).mockImplementation( () => ({ + Metrics: { captureEvent } as Partial, Workspaces: { workspace: () => ({ @@ -145,5 +161,17 @@ describe('BucketLocation', () => { // Assert expect(screen.queryByText('Loading')).toBeNull(); expect(screen.getAllByText(/bucket is requester pays/)).not.toBeNull(); + + // Act + const loadBucketLocation = screen.getByLabelText('Load bucket location'); + await user.click(loadBucketLocation); + + // Assert + expect(captureEvent).toHaveBeenCalledWith( + Events.workspaceDashboardBucketRequesterPays, + extractWorkspaceDetails(workspace) + ); + // In the RequesterPays modal (because the list method returns no workspaces). + expect(screen.getAllByText('Go to Workspaces')).not.toBeNull(); }); }); diff --git a/src/pages/workspaces/workspace/Dashboard/BucketLocation.ts b/src/pages/workspaces/workspace/Dashboard/BucketLocation.ts index 6cc7935da4..1287776399 100644 --- a/src/pages/workspaces/workspace/Dashboard/BucketLocation.ts +++ b/src/pages/workspaces/workspace/Dashboard/BucketLocation.ts @@ -9,6 +9,7 @@ import RequesterPaysModal from 'src/components/RequesterPaysModal'; import { TooltipCell } from 'src/components/table'; import { Ajax } from 'src/libs/ajax'; import { reportError } from 'src/libs/error'; +import Events, { extractWorkspaceDetails } from 'src/libs/events'; import { useCancellation } from 'src/libs/react-utils'; import { requesterPaysProjectStore } from 'src/libs/state'; import { GoogleWorkspace } from 'src/libs/workspace-utils'; @@ -91,7 +92,13 @@ export const BucketLocation = requesterPaysWrapper({ onDismiss: _.noop })((props tooltip: "This workspace's bucket is requester pays. Click to choose a workspace to bill requests to and get the bucket's location.", style: { height: '1rem', marginLeft: '1ch' }, - onClick: () => setShowRequesterPaysModal(true), + onClick: () => { + setShowRequesterPaysModal(true); + Ajax().Metrics.captureEvent( + Events.workspaceDashboardBucketRequesterPays, + extractWorkspaceDetails(workspace) + ); + }, }, [icon('sync')] ), diff --git a/src/pages/workspaces/workspace/Dashboard/RightBoxSection.test.tsx b/src/pages/workspaces/workspace/Dashboard/RightBoxSection.test.tsx index a76b71c263..c409aeeed2 100644 --- a/src/pages/workspaces/workspace/Dashboard/RightBoxSection.test.tsx +++ b/src/pages/workspaces/workspace/Dashboard/RightBoxSection.test.tsx @@ -1,15 +1,34 @@ import { act, fireEvent, screen } from '@testing-library/react'; import React from 'react'; -import { renderWithAppContexts as render } from 'src/testing/test-utils'; +import { Ajax } from 'src/libs/ajax'; +import Events, { extractWorkspaceDetails } from 'src/libs/events'; +import { asMockedFn, renderWithAppContexts as render } from 'src/testing/test-utils'; +import { defaultAzureWorkspace } from 'src/testing/workspace-fixtures'; import { RightBoxSection } from './RightBoxSection'; +type AjaxContract = ReturnType; +jest.mock('src/libs/ajax'); + describe('RightBoxSection', () => { + const workspace = defaultAzureWorkspace; + const captureEvent = jest.fn(); + + beforeEach(() => { + jest.resetAllMocks(); + asMockedFn(Ajax).mockImplementation( + () => + ({ + Metrics: { captureEvent } as Partial, + } as Partial as AjaxContract) + ); + }); + it('displays the title', async () => { // Arrange // Act await act(async () => { - render(); + render(); }); // Assert expect(screen.getByText('Test Title')).toBeInTheDocument(); @@ -19,7 +38,7 @@ describe('RightBoxSection', () => { // Arrange // Act render( - + Panel Content ); @@ -30,4 +49,29 @@ describe('RightBoxSection', () => { // Assert expect(screen.getByText('Panel Content')).toBeInTheDocument(); }); + + it('fires a metrics event when the panel is toggled', async () => { + // Arrange + // Act + render( + + Panel Content + + ); + const titleElement = screen.getByText('Test Title'); + fireEvent.click(titleElement); + fireEvent.click(titleElement); + + // Assert + expect(captureEvent).toHaveBeenNthCalledWith(1, Events.workspaceDashboardToggleSection, { + opened: true, + title: 'Test Title', + ...extractWorkspaceDetails(workspace), + }); + expect(captureEvent).toHaveBeenNthCalledWith(2, Events.workspaceDashboardToggleSection, { + opened: false, + title: 'Test Title', + ...extractWorkspaceDetails(workspace), + }); + }); }); diff --git a/src/pages/workspaces/workspace/Dashboard/RightBoxSection.ts b/src/pages/workspaces/workspace/Dashboard/RightBoxSection.ts index 61a8da6e8a..7f89761ae8 100644 --- a/src/pages/workspaces/workspace/Dashboard/RightBoxSection.ts +++ b/src/pages/workspaces/workspace/Dashboard/RightBoxSection.ts @@ -1,12 +1,16 @@ import { CSSProperties, ReactNode } from 'react'; import { div, h, h3 } from 'react-hyperscript-helpers'; import Collapse from 'src/components/Collapse'; +import { Ajax } from 'src/libs/ajax'; import colors from 'src/libs/colors'; +import Events, { extractWorkspaceDetails } from 'src/libs/events'; import * as Style from 'src/libs/style'; import { useLocalPref } from 'src/libs/useLocalPref'; +import { WorkspaceWrapper } from 'src/libs/workspace-utils'; interface RightBoxSectionProps { title: string; + workspace: WorkspaceWrapper; // used for metrics eventing info?: ReactNode; afterTitle?: ReactNode; persistenceId: string; // persists whether or not the panel is open in local storage @@ -15,7 +19,7 @@ interface RightBoxSectionProps { } export const RightBoxSection = (props: RightBoxSectionProps): ReactNode => { - const { title, info, persistenceId, afterTitle, defaultPanelOpen = false, children } = props; + const { title, info, persistenceId, afterTitle, defaultPanelOpen = false, children, workspace } = props; const [panelOpen, setPanelOpen] = useLocalPref(persistenceId, defaultPanelOpen); return div({ style: { paddingTop: '1rem' } }, [ div({ style: Style.dashboard.rightBoxContainer }, [ @@ -27,7 +31,14 @@ export const RightBoxSection = (props: RightBoxSectionProps): ReactNode => { initialOpenState: panelOpen, titleFirst: true, afterTitle, - onClick: () => setPanelOpen(!panelOpen), + onOpenChanged: (panelOpen) => { + setPanelOpen(panelOpen); + Ajax().Metrics.captureEvent(Events.workspaceDashboardToggleSection, { + title, + opened: panelOpen, + ...extractWorkspaceDetails(workspace), + }); + }, }, [children] ), diff --git a/src/pages/workspaces/workspace/Dashboard/WorkspaceDashboard.ts b/src/pages/workspaces/workspace/Dashboard/WorkspaceDashboard.ts index e8e4a1fb24..8b4048e160 100644 --- a/src/pages/workspaces/workspace/Dashboard/WorkspaceDashboard.ts +++ b/src/pages/workspaces/workspace/Dashboard/WorkspaceDashboard.ts @@ -55,18 +55,22 @@ export const WorkspaceDashboard = forwardRef( title: 'Workspace information', persistenceId: `${persistenceId}/workspaceInfoPanelOpen`, defaultPanelOpen: true, + workspace, }, [h(WorkspaceInformation, { workspace })] ), - h(RightBoxSection, { title: 'Cloud information', persistenceId: `${persistenceId}/cloudInfoPanelOpen` }, [ - h(CloudInformation, { workspace, storageDetails }), - ]), + h( + RightBoxSection, + { title: 'Cloud information', persistenceId: `${persistenceId}/cloudInfoPanelOpen`, workspace }, + [h(CloudInformation, { workspace, storageDetails })] + ), h( RightBoxSection, { title: 'Owners', persistenceId: `${persistenceId}/ownersPanelOpen`, afterTitle: OwnerNotice({ workspace }), + workspace, }, [ div( @@ -87,6 +91,7 @@ export const WorkspaceDashboard = forwardRef( { title: 'Authorization domain', persistenceId: `${persistenceId}/authDomainPanelOpen`, + workspace, }, [h(AuthDomainPanel, { workspace })] ), @@ -96,6 +101,7 @@ export const WorkspaceDashboard = forwardRef( { title: 'Notifications', persistenceId: `${persistenceId}/notificationsPanelOpen`, + workspace, }, [h(WorkspaceNotifications, { workspace })] ), diff --git a/src/pages/workspaces/workspace/Dashboard/WorkspaceDescription.test.ts b/src/pages/workspaces/workspace/Dashboard/WorkspaceDescription.test.ts index e8d3266a54..d731e57762 100644 --- a/src/pages/workspaces/workspace/Dashboard/WorkspaceDescription.test.ts +++ b/src/pages/workspaces/workspace/Dashboard/WorkspaceDescription.test.ts @@ -6,6 +6,7 @@ import _ from 'lodash/fp'; import { div, h } from 'react-hyperscript-helpers'; import { MarkdownEditor } from 'src/components/markdown'; import { Ajax } from 'src/libs/ajax'; +import Events, { extractWorkspaceDetails } from 'src/libs/events'; import { canEditWorkspace } from 'src/libs/workspace-utils'; import { WorkspaceDescription } from 'src/pages/workspaces/workspace/Dashboard/WorkspaceDescription'; import { asMockedFn, renderWithAppContexts as render } from 'src/testing/test-utils'; @@ -80,6 +81,10 @@ describe('WorkspaceDescription', () => { // Arrange const user = userEvent.setup(); asMockedFn(canEditWorkspace).mockReturnValue({ value: true }); + const captureEvent = jest.fn(); + asMockedFn(Ajax).mockReturnValue({ + Metrics: { captureEvent } as Partial, + } as DeepPartial as AjaxContract); const props = { workspace: _.merge(defaultGoogleWorkspace, { workspace: { attributes: { description: undefined } } }), refreshWorkspace: jest.fn(), @@ -98,12 +103,19 @@ describe('WorkspaceDescription', () => { }), expect.any(Object) ); + expect(captureEvent).toHaveBeenCalledWith( + Events.workspaceDashboardEditDescription, + extractWorkspaceDetails(defaultGoogleWorkspace) + ); }); it('initialized editing with the original workspace description', async () => { // Arrange const user = userEvent.setup(); asMockedFn(canEditWorkspace).mockReturnValue({ value: true }); + asMockedFn(Ajax).mockReturnValue({ + Metrics: { captureEvent: jest.fn() } as Partial, + } as DeepPartial as AjaxContract); const description = 'this is a very descriptive decription'; const props = { workspace: _.merge(defaultGoogleWorkspace, { workspace: { attributes: { description } } }), @@ -133,7 +145,9 @@ describe('WorkspaceDescription', () => { refreshWorkspace: jest.fn(), }; const mockShallowMergeNewAttributes = jest.fn().mockResolvedValue({}); + const captureEvent = jest.fn(); asMockedFn(Ajax).mockReturnValue({ + Metrics: { captureEvent } as Partial, Workspaces: { workspace: jest.fn().mockReturnValue({ shallowMergeNewAttributes: mockShallowMergeNewAttributes, @@ -162,5 +176,15 @@ describe('WorkspaceDescription', () => { // Assert expect(mockShallowMergeNewAttributes).toHaveBeenCalledWith({ description: newDescription }); + expect(captureEvent).toHaveBeenNthCalledWith( + 1, + Events.workspaceDashboardEditDescription, + extractWorkspaceDetails(defaultGoogleWorkspace) + ); + expect(captureEvent).toHaveBeenNthCalledWith( + 2, + Events.workspaceDashboardSaveDescription, + extractWorkspaceDetails(defaultGoogleWorkspace) + ); }); }); diff --git a/src/pages/workspaces/workspace/Dashboard/WorkspaceDescription.ts b/src/pages/workspaces/workspace/Dashboard/WorkspaceDescription.ts index cb6608849a..012c245465 100644 --- a/src/pages/workspaces/workspace/Dashboard/WorkspaceDescription.ts +++ b/src/pages/workspaces/workspace/Dashboard/WorkspaceDescription.ts @@ -8,6 +8,7 @@ import { icon } from 'src/components/icons'; import { MarkdownEditor, MarkdownViewer } from 'src/components/markdown'; import { Ajax } from 'src/libs/ajax'; import { reportError } from 'src/libs/error'; +import Events, { extractWorkspaceDetails } from 'src/libs/events'; import * as Style from 'src/libs/style'; import { withBusyState } from 'src/libs/utils'; import { canEditWorkspace, WorkspaceWrapper as Workspace } from 'src/libs/workspace-utils'; @@ -35,6 +36,7 @@ export const WorkspaceDescription = (props: WorkspaceDescriptionProps): ReactNod const { namespace, name } = workspace.workspace; await Ajax().Workspaces.workspace(namespace, name).shallowMergeNewAttributes({ description: editedDescription }); refreshWorkspace(); + Ajax().Metrics.captureEvent(Events.workspaceDashboardSaveDescription, extractWorkspaceDetails(workspace)); } catch (error) { reportError('Error saving workspace', error); } finally { @@ -52,7 +54,10 @@ export const WorkspaceDescription = (props: WorkspaceDescriptionProps): ReactNod style: { marginLeft: '0.5rem' }, disabled: !canEdit, tooltip: canEdit ? 'Edit description' : editErrorMessage, - onClick: () => setEditedDescription(description), + onClick: () => { + setEditedDescription(description); + Ajax().Metrics.captureEvent(Events.workspaceDashboardEditDescription, extractWorkspaceDetails(workspace)); + }, }, [icon('edit')] ), diff --git a/src/pages/workspaces/workspace/Dashboard/WorkspaceNotifications.ts b/src/pages/workspaces/workspace/Dashboard/WorkspaceNotifications.ts index f1fa3c3220..dcfd8703cd 100644 --- a/src/pages/workspaces/workspace/Dashboard/WorkspaceNotifications.ts +++ b/src/pages/workspaces/workspace/Dashboard/WorkspaceNotifications.ts @@ -6,7 +6,7 @@ import { refreshTerraProfile } from 'src/auth/auth'; import { LabeledCheckbox } from 'src/components/common'; import { Ajax } from 'src/libs/ajax'; import { withErrorReporting } from 'src/libs/error'; -import Events from 'src/libs/events'; +import Events, { extractWorkspaceDetails } from 'src/libs/events'; import { userStore } from 'src/libs/state'; import { withBusyState } from 'src/libs/utils'; import { WorkspaceWrapper as Workspace } from 'src/libs/workspace-utils'; @@ -54,6 +54,7 @@ export const WorkspaceNotifications = (props: WorkspaceNotificationsProps): Reac Ajax().Metrics.captureEvent(Events.notificationToggle, { notificationKeys: submissionNotificationKeys, enabled: value, + ...extractWorkspaceDetails(props.workspace), }); }), }, diff --git a/src/pages/workspaces/workspace/Dashboard/WorkspaceTags.test.ts b/src/pages/workspaces/workspace/Dashboard/WorkspaceTags.test.ts index 6b3e43f9c9..fa0fa088ce 100644 --- a/src/pages/workspaces/workspace/Dashboard/WorkspaceTags.test.ts +++ b/src/pages/workspaces/workspace/Dashboard/WorkspaceTags.test.ts @@ -3,6 +3,7 @@ import { act, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { h } from 'react-hyperscript-helpers'; import { Ajax } from 'src/libs/ajax'; +import Events, { extractWorkspaceDetails } from 'src/libs/events'; import { WorkspaceTags } from 'src/pages/workspaces/workspace/Dashboard/WorkspaceTags'; import { asMockedFn, renderWithAppContexts as render } from 'src/testing/test-utils'; import { defaultGoogleWorkspace } from 'src/testing/workspace-fixtures'; @@ -63,7 +64,9 @@ describe('WorkspaceTags', () => { const initialTags = ['tag a', 'tag b']; const addedTag = 'new tag'; const mockAddTagsFn = jest.fn().mockResolvedValue([...initialTags, addedTag]); + const mockCaptureEvent = jest.fn(); asMockedFn(Ajax).mockReturnValue({ + Metrics: { captureEvent: mockCaptureEvent } as Partial, Workspaces: { // the tags select component still calls this getTags: jest.fn().mockResolvedValue([initialTags]), @@ -109,6 +112,10 @@ describe('WorkspaceTags', () => { expect(screen.queryByText('tag a')).not.toBeNull(); expect(screen.queryByText('tag b')).not.toBeNull(); expect(mockAddTagsFn).toBeCalled(); + expect(mockCaptureEvent).toBeCalledWith(Events.workspaceDashboardAddTag, { + tag: addedTag, + ...extractWorkspaceDetails(defaultGoogleWorkspace), + }); }); it('updates the list of tags when deleting a tag', async () => { @@ -118,7 +125,9 @@ describe('WorkspaceTags', () => { const initialTags = [remainingTag, deletingTag]; const mockDeleteTagsFn = jest.fn().mockResolvedValue([remainingTag]); + const mockCaptureEvent = jest.fn(); asMockedFn(Ajax).mockReturnValue({ + Metrics: { captureEvent: mockCaptureEvent } as Partial, Workspaces: { // the tags select component still calls this getTags: jest.fn().mockResolvedValue([initialTags]), @@ -161,5 +170,9 @@ describe('WorkspaceTags', () => { await waitFor(() => expect(screen.queryByText(deletingTag)).toBeNull()); expect(screen.queryByText(remainingTag)).not.toBeNull(); expect(mockDeleteTagsFn).toBeCalled(); + expect(mockCaptureEvent).toBeCalledWith(Events.workspaceDashboardDeleteTag, { + tag: deletingTag, + ...extractWorkspaceDetails(defaultGoogleWorkspace), + }); }); }); diff --git a/src/pages/workspaces/workspace/Dashboard/WorkspaceTags.ts b/src/pages/workspaces/workspace/Dashboard/WorkspaceTags.ts index f207fb1334..8b48bbfc36 100644 --- a/src/pages/workspaces/workspace/Dashboard/WorkspaceTags.ts +++ b/src/pages/workspaces/workspace/Dashboard/WorkspaceTags.ts @@ -8,6 +8,7 @@ import { Ajax } from 'src/libs/ajax'; import { getEnabledBrand } from 'src/libs/brand-utils'; import colors from 'src/libs/colors'; import { withErrorReporting } from 'src/libs/error'; +import Events, { extractWorkspaceDetails } from 'src/libs/events'; import * as Style from 'src/libs/style'; import { withBusyState } from 'src/libs/utils'; import { InitializedWorkspaceWrapper as Workspace } from 'src/pages/workspaces/hooks/useWorkspace'; @@ -51,6 +52,10 @@ export const WorkspaceTags = (props: WorkspaceTagsProps): ReactNode => { withBusyState(setBusy) )(async (tag) => { setTagsList(await Ajax().Workspaces.workspace(namespace, name).addTag(tag)); + Ajax().Metrics.captureEvent(Events.workspaceDashboardAddTag, { + tag, + ...extractWorkspaceDetails(workspace), + }); }); const deleteTag = _.flow( @@ -58,6 +63,10 @@ export const WorkspaceTags = (props: WorkspaceTagsProps): ReactNode => { withBusyState(setBusy) )(async (tag) => { setTagsList(await Ajax().Workspaces.workspace(namespace, name).deleteTag(tag)); + Ajax().Metrics.captureEvent(Events.workspaceDashboardDeleteTag, { + tag, + ...extractWorkspaceDetails(workspace), + }); }); const brand = getEnabledBrand(); @@ -68,6 +77,7 @@ export const WorkspaceTags = (props: WorkspaceTagsProps): ReactNode => { title: 'Tags', info: span({}, [busy && h(Spinner, { size: 1, style: { marginLeft: '0.5rem' } })]), persistenceId, + workspace, }, [ div({ style: { margin: '0.5rem' } }, [