Merge pull request #10133 from mabashian/ui-next-pendo

Adds support for pendo initialization across the app

SUMMARY
We were already bootstrapping pendo as part of the subscription code I just moved that code to a more general place.  When the app container mounts (after login or on refresh) we check to see if the pendo flag is turned on.  If it is, we initialize pendo.  If it's not then we do nothing.  If a user goes into settings and manually changes the pendo tracking setting then we trigger a hard reload of the browser tab (to take the new setting into account and either initialize or not).  This functionality existed in the old UI as well.
ISSUE TYPE

Feature Pull Request

COMPONENT NAME

UI

Reviewed-by: Michael Abashian <None>
Reviewed-by: Kersom <None>
Reviewed-by: Tiago Góes <tiago.goes2009@gmail.com>
This commit is contained in:
softwarefactory-project-zuul[bot]
2021-05-18 14:51:07 +00:00
committed by GitHub
10 changed files with 122 additions and 59 deletions

View File

@@ -19,6 +19,7 @@ import { MeAPI, RootAPI } from '../../api';
import { useConfig, useAuthorizedPath } from '../../contexts/Config'; import { useConfig, useAuthorizedPath } from '../../contexts/Config';
import { SESSION_TIMEOUT_KEY } from '../../constants'; import { SESSION_TIMEOUT_KEY } from '../../constants';
import { isAuthenticated } from '../../util/auth'; import { isAuthenticated } from '../../util/auth';
import issuePendoIdentity from '../../util/issuePendoIdentity';
import About from '../About'; import About from '../About';
import AlertModal from '../AlertModal'; import AlertModal from '../AlertModal';
import BrandLogo from './BrandLogo'; import BrandLogo from './BrandLogo';
@@ -138,6 +139,13 @@ function AppContainer({ navRouteConfig = [], children }) {
} }
}, [handleLogout, timeRemaining]); }, [handleLogout, timeRemaining]);
useEffect(() => {
if ('analytics_status' in config) {
issuePendoIdentity(config);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [config.analytics_status]);
const brandName = config?.license_info?.product_name; const brandName = config?.license_info?.product_name;
const alt = brandName ? t`${brandName} logo` : t`brand logo`; const alt = brandName ? t`${brandName} logo` : t`brand logo`;

View File

@@ -4,19 +4,25 @@ import {
mountWithContexts, mountWithContexts,
waitForElement, waitForElement,
} from '../../../testUtils/enzymeHelpers'; } from '../../../testUtils/enzymeHelpers';
import { ConfigAPI, MeAPI, RootAPI } from '../../api'; import { MeAPI, RootAPI } from '../../api';
import { useAuthorizedPath } from '../../contexts/Config'; import { useAuthorizedPath } from '../../contexts/Config';
import AppContainer from './AppContainer'; import AppContainer from './AppContainer';
jest.mock('../../api'); jest.mock('../../api');
jest.mock('../../util/bootstrapPendo');
global.pendo = {
initialize: jest.fn(),
};
describe('<AppContainer />', () => { describe('<AppContainer />', () => {
const version = '222'; const version = '222';
beforeEach(() => { beforeEach(() => {
ConfigAPI.read.mockResolvedValue({ RootAPI.readAssetVariables.mockResolvedValue({
data: { data: {
version, BRAND_NAME: 'AWX',
PENDO_API_KEY: '',
}, },
}); });
MeAPI.read.mockResolvedValue({ data: { results: [{}] } }); MeAPI.read.mockResolvedValue({ data: { results: [{}] } });
@@ -52,7 +58,22 @@ describe('<AppContainer />', () => {
{routeConfig.map(({ groupId }) => ( {routeConfig.map(({ groupId }) => (
<div key={groupId} id={groupId} /> <div key={groupId} id={groupId} />
))} ))}
</AppContainer> </AppContainer>,
{
context: {
config: {
analytics_status: 'detailed',
ansible_version: null,
custom_virtualenvs: [],
version: '9000',
me: { is_superuser: true },
toJSON: () => '/config/',
license_info: {
valid_key: true,
},
},
},
}
); );
}); });
wrapper.update(); wrapper.update();
@@ -70,6 +91,31 @@ describe('<AppContainer />', () => {
expect(wrapper.find('#group_one').length).toBe(1); expect(wrapper.find('#group_one').length).toBe(1);
expect(wrapper.find('#group_two').length).toBe(1); expect(wrapper.find('#group_two').length).toBe(1);
expect(global.pendo.initialize).toHaveBeenCalledTimes(1);
});
test('expected content is rendered', async () => {
let wrapper;
await act(async () => {
wrapper = mountWithContexts(<AppContainer />, {
context: {
config: {
analytics_status: 'off',
ansible_version: null,
custom_virtualenvs: [],
version: '9000',
me: { is_superuser: true },
toJSON: () => '/config/',
license_info: {
valid_key: true,
},
},
},
});
});
wrapper.update();
expect(global.pendo.initialize).toHaveBeenCalledTimes(0);
}); });
test('opening the about modal renders prefetched config data', async () => { test('opening the about modal renders prefetched config data', async () => {

View File

@@ -1,5 +1,5 @@
import React, { useCallback, useContext, useEffect, useMemo } from 'react'; import React, { useCallback, useContext, useEffect, useMemo } from 'react';
import { useLocation, useRouteMatch } from 'react-router-dom'; import { useRouteMatch } from 'react-router-dom';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
@@ -22,15 +22,7 @@ export const useConfig = () => {
}; };
export const ConfigProvider = ({ children }) => { export const ConfigProvider = ({ children }) => {
const { pathname } = useLocation(); const { error: configError, isLoading, request, result: config } = useRequest(
const {
error: configError,
isLoading,
request,
result: config,
setValue: setConfig,
} = useRequest(
useCallback(async () => { useCallback(async () => {
const [ const [
{ data }, { data },
@@ -48,10 +40,8 @@ export const ConfigProvider = ({ children }) => {
const { error, dismissError } = useDismissableError(configError); const { error, dismissError } = useDismissableError(configError);
useEffect(() => { useEffect(() => {
if (pathname !== '/login') { request();
request(); }, [request]);
}
}, [request, pathname]);
useEffect(() => { useEffect(() => {
if (error?.response?.status === 401) { if (error?.response?.status === 401) {
@@ -59,10 +49,10 @@ export const ConfigProvider = ({ children }) => {
} }
}, [error]); }, [error]);
const value = useMemo(() => ({ ...config, isLoading, setConfig }), [ const value = useMemo(() => ({ ...config, request, isLoading }), [
config, config,
request,
isLoading, isLoading,
setConfig,
]); ]);
return ( return (

View File

@@ -12,13 +12,12 @@ import {
WizardContextConsumer, WizardContextConsumer,
WizardFooter, WizardFooter,
} from '@patternfly/react-core'; } from '@patternfly/react-core';
import { ConfigAPI, SettingsAPI, MeAPI, RootAPI } from '../../../../api'; import { ConfigAPI, SettingsAPI, RootAPI } from '../../../../api';
import useRequest, { useDismissableError } from '../../../../util/useRequest'; import useRequest, { useDismissableError } from '../../../../util/useRequest';
import ContentLoading from '../../../../components/ContentLoading'; import ContentLoading from '../../../../components/ContentLoading';
import ContentError from '../../../../components/ContentError'; import ContentError from '../../../../components/ContentError';
import { FormSubmitError } from '../../../../components/FormField'; import { FormSubmitError } from '../../../../components/FormField';
import { useConfig } from '../../../../contexts/Config'; import { useConfig } from '../../../../contexts/Config';
import issuePendoIdentity from './pendoUtils';
import SubscriptionStep from './SubscriptionStep'; import SubscriptionStep from './SubscriptionStep';
import AnalyticsStep from './AnalyticsStep'; import AnalyticsStep from './AnalyticsStep';
import EulaStep from './EulaStep'; import EulaStep from './EulaStep';
@@ -92,7 +91,7 @@ const CustomFooter = ({ isSubmitLoading }) => {
function SubscriptionEdit() { function SubscriptionEdit() {
const history = useHistory(); const history = useHistory();
const { license_info, setConfig } = useConfig(); const { request: updateConfig, license_info } = useConfig();
const hasValidKey = Boolean(license_info?.valid_key); const hasValidKey = Boolean(license_info?.valid_key);
const subscriptionMgmtRoute = useRouteMatch({ const subscriptionMgmtRoute = useRouteMatch({
path: '/subscription_management', path: '/subscription_management',
@@ -102,20 +101,18 @@ function SubscriptionEdit() {
isLoading: isContentLoading, isLoading: isContentLoading,
error: contentError, error: contentError,
request: fetchContent, request: fetchContent,
result: { brandName, pendoApiKey }, result: { brandName },
} = useRequest( } = useRequest(
useCallback(async () => { useCallback(async () => {
const { const {
data: { BRAND_NAME, PENDO_API_KEY }, data: { BRAND_NAME },
} = await RootAPI.readAssetVariables(); } = await RootAPI.readAssetVariables();
return { return {
brandName: BRAND_NAME, brandName: BRAND_NAME,
pendoApiKey: PENDO_API_KEY,
}; };
}, []), }, []),
{ {
brandName: null, brandName: null,
pendoApiKey: null,
} }
); );
@@ -145,23 +142,11 @@ function SubscriptionEdit() {
}); });
} }
const [
{ data },
{
data: {
results: [me],
},
},
] = await Promise.all([ConfigAPI.read(), MeAPI.read()]);
const newConfig = { ...data, me };
setConfig(newConfig);
if (!hasValidKey) { if (!hasValidKey) {
if (form.pendo) { if (form.pendo) {
await SettingsAPI.updateCategory('ui', { await SettingsAPI.updateCategory('ui', {
PENDO_TRACKING_STATE: 'detailed', PENDO_TRACKING_STATE: 'detailed',
}); });
await issuePendoIdentity(newConfig, pendoApiKey);
} else { } else {
await SettingsAPI.updateCategory('ui', { await SettingsAPI.updateCategory('ui', {
PENDO_TRACKING_STATE: 'off', PENDO_TRACKING_STATE: 'off',
@@ -178,6 +163,9 @@ function SubscriptionEdit() {
}); });
} }
} }
await updateConfig();
return true; return true;
}, []) // eslint-disable-line react-hooks/exhaustive-deps }, []) // eslint-disable-line react-hooks/exhaustive-deps
); );

View File

@@ -14,7 +14,6 @@ import {
} from '../../../../api'; } from '../../../../api';
import SubscriptionEdit from './SubscriptionEdit'; import SubscriptionEdit from './SubscriptionEdit';
jest.mock('./bootstrapPendo');
jest.mock('../../../../api'); jest.mock('../../../../api');
const mockConfig = { const mockConfig = {
@@ -53,7 +52,7 @@ const emptyConfig = {
license_info: { license_info: {
valid_key: false, valid_key: false,
}, },
setConfig: jest.fn(), request: jest.fn(),
}; };
describe('<SubscriptionEdit />', () => { describe('<SubscriptionEdit />', () => {
@@ -269,7 +268,7 @@ describe('<SubscriptionEdit />', () => {
context: { context: {
config: { config: {
mockConfig, mockConfig,
setConfig: jest.fn(), request: jest.fn(),
}, },
me: { me: {
is_superuser: true, is_superuser: true,

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useCallback } from 'react'; import React, { useEffect, useCallback } from 'react';
import { Link } from 'react-router-dom'; import { Link, useHistory, useLocation } from 'react-router-dom';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { Button } from '@patternfly/react-core'; import { Button } from '@patternfly/react-core';
@@ -19,6 +19,12 @@ import { SettingDetail } from '../../shared';
function UIDetail() { function UIDetail() {
const { me } = useConfig(); const { me } = useConfig();
const { GET: options } = useSettings(); const { GET: options } = useSettings();
const history = useHistory();
const { hardReload } = useLocation();
if (hardReload) {
history.go();
}
const { isLoading, error, request, result: ui } = useRequest( const { isLoading, error, request, result: ui } = useRequest(
useCallback(async () => { useCallback(async () => {

View File

@@ -49,9 +49,18 @@ function UIEdit() {
useCallback( useCallback(
async values => { async values => {
await SettingsAPI.updateAll(values); await SettingsAPI.updateAll(values);
history.push('/settings/ui/details'); if (
values?.PENDO_TRACKING_STATE !== uiData?.PENDO_TRACKING_STATE?.value
) {
history.push({
pathname: '/settings/ui/details',
hardReload: true,
});
} else {
history.push('/settings/ui/details');
}
}, },
[history] [history, uiData]
), ),
null null
); );

View File

@@ -111,6 +111,21 @@ describe('<UIEdit />', () => {
wrapper.find('Form').invoke('onSubmit')(); wrapper.find('Form').invoke('onSubmit')();
}); });
expect(history.location.pathname).toEqual('/settings/ui/details'); expect(history.location.pathname).toEqual('/settings/ui/details');
expect(history.location.hardReload).toEqual(undefined);
});
test('should navigate to ui detail with reload param on successful submission where PENDO_TRACKING_STATE changes', async () => {
act(() => {
wrapper.find('select#PENDO_TRACKING_STATE').simulate('change', {
target: { value: 'off', name: 'CUSTOM_LOGIN_INFO' },
});
});
wrapper.update();
await act(async () => {
wrapper.find('Form').invoke('onSubmit')();
});
expect(history.location.pathname).toEqual('/settings/ui/details');
expect(history.location.hardReload).toEqual(true);
}); });
test('should navigate to ui detail when cancel is clicked', async () => { test('should navigate to ui detail when cancel is clicked', async () => {

View File

@@ -1,30 +1,26 @@
import { UsersAPI } from '../../../../api'; import { RootAPI, UsersAPI } from '../api';
import bootstrapPendo from './bootstrapPendo'; import bootstrapPendo from './bootstrapPendo';
function buildPendoOptions(config, pendoApiKey) { function buildPendoOptions(config, pendoApiKey) {
const tower_version = config.version.split('-')[0]; const towerVersion = config.version.split('-')[0];
const trial = config.trial ? config.trial : false; const trial = config.trial ? config.trial : false;
const options = {
return {
apiKey: pendoApiKey, apiKey: pendoApiKey,
visitor: { visitor: {
id: null, id: 0,
role: null, role: null,
}, },
account: { account: {
id: null, id: 'tower.ansible.com',
planLevel: config.license_type, planLevel: config.license_type,
planPrice: config.instance_count, planPrice: config.instance_count,
creationDate: config.license_date, creationDate: config.license_date,
trial, trial,
tower_version, tower_version: towerVersion,
ansible_version: config.ansible_version, ansible_version: config.ansible_version,
}, },
}; };
options.visitor.id = 0;
options.account.id = 'tower.ansible.com';
return options;
} }
async function buildPendoOptionsRole(options, config) { async function buildPendoOptionsRole(options, config) {
@@ -45,14 +41,20 @@ async function buildPendoOptionsRole(options, config) {
} }
} }
async function issuePendoIdentity(config, pendoApiKey) { async function issuePendoIdentity(config) {
if (!('license_info' in config)) {
config.license_info = {};
}
config.license_info.analytics_status = config.analytics_status; config.license_info.analytics_status = config.analytics_status;
config.license_info.version = config.version; config.license_info.version = config.version;
config.license_info.ansible_version = config.ansible_version; config.license_info.ansible_version = config.ansible_version;
if (config.analytics_status !== 'off') { if (config.analytics_status !== 'off') {
bootstrapPendo(pendoApiKey); const {
const pendoOptions = buildPendoOptions(config, pendoApiKey); data: { PENDO_API_KEY },
} = await RootAPI.readAssetVariables();
bootstrapPendo(PENDO_API_KEY);
const pendoOptions = buildPendoOptions(config, PENDO_API_KEY);
const pendoOptionsWithRole = await buildPendoOptionsRole( const pendoOptionsWithRole = await buildPendoOptionsRole(
pendoOptions, pendoOptions,
config config