diff --git a/DEVNOTES.md b/DEVNOTES.md index 4173d00bb..de23c8622 100644 --- a/DEVNOTES.md +++ b/DEVNOTES.md @@ -69,6 +69,8 @@ docker build . -t duos docker compose up -d ``` +Visit https://local.dsde-dev.broadinstitute.org/ to see the instance running under docker. + # Testing ## Cypress Tests diff --git a/Dockerfile b/Dockerfile index 41ce0894b..4f3282e5b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,6 +11,7 @@ ENV PATH /usr/src/app/node_modules/.bin:$PATH # install and cache app dependencies COPY src /usr/src/app/src +COPY types /usr/src/app/types COPY public /usr/src/app/public COPY package.json /usr/src/app/package.json COPY package-lock.json /usr/src/app/package-lock.json diff --git a/cypress/component/utils/metrics.spec.ts b/cypress/component/utils/metrics.spec.ts new file mode 100644 index 000000000..b927a4cb6 --- /dev/null +++ b/cypress/component/utils/metrics.spec.ts @@ -0,0 +1,42 @@ +/* eslint-disable no-undef */ +import {Metrics} from '../../../src/libs/ajax/Metrics'; +import eventList from '../../../src/libs/events'; + +describe('Metrics Tests', function () { + + // Intercept configuration calls + beforeEach(() => { + cy.intercept({ + method: 'GET', + url: '/config.json', + hostname: 'localhost', + }, {'env': 'ci'}); + }); + + Cypress._.each(Object.keys(eventList), (eventType) => { + it(`Captures ${eventType} Event`, function () { + cy.intercept('**/event').as('event'); + Metrics.captureEvent(eventType); + cy.wait('@event').then(interception => { + expect(interception).to.exist; + }); + }); + }); + + it('Sync Profile', function () { + cy.intercept('**/syncProfile').as('sync'); + Metrics.syncProfile(); + cy.wait('@sync').then(interception => { + expect(interception).to.exist; + }); + }); + + it('Identify', function () { + cy.intercept('**/identify').as('identify'); + Metrics.identify('anonymousId'); + cy.wait('@identify').then(interception => { + expect(interception).to.exist; + }); + }); + +}); diff --git a/cypress/support/component-index.html b/cypress/support/component-index.html index 23b2efe9d..f00558ec8 100644 --- a/cypress/support/component-index.html +++ b/cypress/support/component-index.html @@ -5,8 +5,11 @@ - Components App + + diff --git a/public/index.html b/public/index.html index b5fcc0215..0de336d71 100644 --- a/public/index.html +++ b/public/index.html @@ -22,7 +22,6 @@ - + + + + + + Broad Data Use Oversight System diff --git a/src/components/SignInButton.tsx b/src/components/SignInButton.tsx index 6de3cd37f..46c78ce38 100644 --- a/src/components/SignInButton.tsx +++ b/src/components/SignInButton.tsx @@ -9,7 +9,7 @@ import {Storage} from '../libs/storage'; import {Navigation, setUserRoleStatuses} from '../libs/utils'; import loadingIndicator from '../images/loading-indicator.svg'; import ReactTooltip from 'react-tooltip'; -import eventList from '../libs/events'; +import eventList, {MetricsEventName} from '../libs/events'; import {StackdriverReporter} from '../libs/stackdriverReporter'; import {History} from 'history'; import {OidcUser} from '../libs/auth/oidcBroker'; @@ -107,10 +107,10 @@ export const SignInButton = (props: SignInButtonProps) => { history.push(`/tos_acceptance${shouldRedirect ? `?redirectTo=${redirectTo}` : ''}`); }; - const syncSignInOrRegistrationEvent = async (event: String) => { + const syncSignInOrRegistrationEvent = async (event: MetricsEventName) => { Storage.setAnonymousId(); // noinspection ES6MissingAwait - Metrics.identify(Storage.getAnonymousId()); + Metrics.identify(`${Storage.getAnonymousId()}`); // noinspection ES6MissingAwait Metrics.syncProfile(); // noinspection ES6MissingAwait @@ -198,10 +198,10 @@ export const SignInButton = (props: SignInButtonProps) => { className='navbar-duos-icon-help' style={{color: 'white', height: 16, width: 16, marginLeft: 5}} href='https://support.terra.bio/hc/en-us/articles/28504837523995-How-to-Register-for-DUOS' - data-for="tip_google-help" - data-tip="Need account help? Click here!" + data-for='tip_google-help' + data-tip='Need account help? Click here!' /> - + ); }; @@ -212,10 +212,10 @@ export const SignInButton = (props: SignInButtonProps) => { ?
{signInElement()}
- :
+ :
setErrorDisplay({})} diff --git a/src/libs/ajax/Metrics.js b/src/libs/ajax/Metrics.ts similarity index 55% rename from src/libs/ajax/Metrics.js rename to src/libs/ajax/Metrics.ts index 305781817..a82b38e8b 100644 --- a/src/libs/ajax/Metrics.js +++ b/src/libs/ajax/Metrics.ts @@ -1,15 +1,24 @@ -import axios from 'axios'; +import axios, {AxiosRequestConfig} from 'axios'; import {getDefaultProperties} from '@databiosphere/bard-client'; import {Storage} from '../storage'; import {getBardApiUrl} from '../ajax'; import {Token} from '../config'; +import {MetricsEventName} from 'src/libs/events'; + +// Set default timeout for all metrics calls to 30 seconds +const defaultSignal: AbortSignal = AbortSignal.timeout(30000); export const Metrics = { - captureEvent: (event, details, signal) => captureEventFn(event, details, signal).catch(() => { + captureEvent: ( + event: MetricsEventName, + details: Record = {}, + signal: AbortSignal = defaultSignal, + refreshAppcues: boolean = true + ) => captureEventFn(event, details, signal, refreshAppcues).catch(() => { }), - syncProfile: (signal) => syncProfile(signal), - identify: (anonId, signal) => identify(anonId, signal), + syncProfile: (signal: AbortSignal = defaultSignal) => syncProfile(signal), + identify: (anonId: String, signal: AbortSignal = defaultSignal) => identify(anonId, signal), }; /** @@ -18,12 +27,19 @@ export const Metrics = { * @param {string} event - The event name. * @param {Object} [details={}] - The event details. * @param {AbortSignal} [signal] - The abort signal. + * @param refreshAppcues - The refresh Appcues flag. * @returns {Promise} - A Promise that resolves when the event is captured. */ -const captureEventFn = async (event, details = {}, signal) => { +const captureEventFn = async (event: MetricsEventName, details: {} = {}, signal: AbortSignal, refreshAppcues: boolean): Promise => { const isSignedIn = Storage.userIsLogged(); const isRegistered = isSignedIn && Storage.getCurrentUser(); + // Send event to Appcues and refresh Appcues state + window.Appcues?.track(event); + if (refreshAppcues) { + window.Appcues?.page(); + } + if (!isRegistered && !Storage.getAnonymousId()) { Storage.setAnonymousId(); } @@ -40,7 +56,7 @@ const captureEventFn = async (event, details = {}, signal) => { }, }; - const config = { + const config: AxiosRequestConfig = { method: 'POST', url: `${await getBardApiUrl()}/api/event`, data: body, @@ -57,8 +73,8 @@ const captureEventFn = async (event, details = {}, signal) => { * @param {AbortSignal} [signal] - The abort signal. * @returns {Promise} - A Promise that resolves when the profile is synced. */ -const syncProfile = async (signal) => { - const config = { +const syncProfile = async (signal: AbortSignal): Promise => { + const config: AxiosRequestConfig = { method: 'POST', url: `${await getBardApiUrl()}/api/syncProfile`, headers: {Authorization: `Bearer ${Token.getToken()}`}, @@ -76,10 +92,21 @@ const syncProfile = async (signal) => { * @param {AbortSignal} [signal] - The abort signal. * @returns {Promise} - A Promise that resolves when the user is identified. */ -const identify = async (anonId, signal) => { +const identify = async (anonId: String, signal: AbortSignal): Promise => { const body = {anonId}; - const config = { + if (window.Appcues) { + const user = Storage.getCurrentUser(); + const oidcSub = Storage.getOidcUser()?.profile?.sub || Storage.getAnonymousId(); + const createDate = user.createDate ? user.createDate : new Date().getTime(); + const appcuesProps = { + dateJoined: createDate, + app: 'DUOS' + }; + window.Appcues.identify(oidcSub, appcuesProps); + } + + const config: AxiosRequestConfig = { method: 'POST', url: `${await getBardApiUrl()}/api/identify`, data: body, diff --git a/src/libs/auth/auth.ts b/src/libs/auth/auth.ts index 2b3ff8272..bc1b9e61c 100644 --- a/src/libs/auth/auth.ts +++ b/src/libs/auth/auth.ts @@ -5,6 +5,7 @@ import {OidcBroker, OidcUser} from './oidcBroker'; import {Storage} from './../storage'; import {UserManager} from 'oidc-client-ts'; +import {MetricsEventName} from '../events'; export const Auth = { signInError: () => { @@ -42,3 +43,25 @@ export const Auth = { await OidcBroker.signOut(); }, }; + +// extending Window interface to access Appcues +declare global { + interface Window { + Appcues?: { + /** Identifies the current user with an ID and an optional set of properties. */ + identify: (userId: string, properties?: any) => void; + /** Notifies the SDK that the state of the application has changed. */ + page: () => void; + /** Forces specific Appcues content to appear for the current user by passing in the ID. */ + show: (contentId: string) => void; + /** Fire the callback function when the given event is triggered by the SDK */ + on: ((eventName: Exclude, callbackFn: (event: any) => void | Promise) => void) & + ((eventName: 'all', callbackFn: (eventName: string, event: any) => void | Promise) => void); + /** Clears all known information about the current user in this session */ + reset: () => void; + /** Tracks a custom event (by name) taken by the current user. */ + track: (eventName: MetricsEventName) => void; + }; + forceSignIn: any; + } +} diff --git a/src/libs/events.js b/src/libs/events.js deleted file mode 100644 index cce8efa84..000000000 --- a/src/libs/events.js +++ /dev/null @@ -1,14 +0,0 @@ -/* - * NOTE: See the Mixpanel guide in the terra-ui GitHub Wiki for more details: - * https://github.com/DataBiosphere/terra-ui/wiki/Mixpanel - */ -const eventList = { - userRegister: 'user:register', - userSignIn: 'user:signin', - - pageView: 'page:view', - dataLibrary: 'page:view:data-library', - dar: 'page:view:dar' -}; - -export default eventList; diff --git a/src/libs/events.ts b/src/libs/events.ts new file mode 100644 index 000000000..6929c1722 --- /dev/null +++ b/src/libs/events.ts @@ -0,0 +1,26 @@ +/* + * NOTE: See the Mixpanel guide in the terra-ui GitHub Wiki for more details: + * https://github.com/DataBiosphere/terra-ui/wiki/Mixpanel + */ +const eventList = { + userRegister: 'user:register', + userSignIn: 'user:signin', + + pageView: 'page:view', + dataLibrary: 'page:view:data-library', + dar: 'page:view:dar' +}; + +export default eventList; + +// Helper type to create BaseMetricsEventName. +type MetricsEventsMap = { [key: string]: EventName | MetricsEventsMap }; +// Union type of all event names configured in eventsList. +type BaseMetricsEventName = typeof eventList extends MetricsEventsMap ? EventName : never; +// Each route has its own page view event, where the event name includes the name of the route. +type PageViewMetricsEventName = `${typeof eventList.pageView}:${string}`; + +/** + * Union type of all metrics event names. + */ +export type MetricsEventName = BaseMetricsEventName | PageViewMetricsEventName; diff --git a/tsconfig.json b/tsconfig.json index 99002350f..f3d6e5ce6 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -21,7 +21,8 @@ } }, "include": [ - "src" + "src", + "types/index.d.ts" ], "plugins": ["@typescript-eslint", "import"] } diff --git a/types/index.d.ts b/types/index.d.ts new file mode 100644 index 000000000..5587585ee --- /dev/null +++ b/types/index.d.ts @@ -0,0 +1,3 @@ +declare module "@databiosphere/bard-client" { + export function getDefaultProperties(): any +}