Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[MPDX-8393] Make the Helpjuice beacon dismissable #1150

Merged
merged 6 commits into from
Oct 24, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pages/_app.page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ const App = ({
<TaskModalProvider>
<Layout>
<SnackbarUtilsConfigurator />
<Helpjuice />
<Box
sx={(theme) => ({
fontFamily: theme.typography.fontFamily,
Expand Down Expand Up @@ -202,7 +203,6 @@ const App = ({
</StyledEngineProvider>
</I18nextProvider>
<DataDog />
<Helpjuice />
</SessionProvider>
{process.env.ALERT_MESSAGE ? (
<AlertBanner
Expand Down
65 changes: 43 additions & 22 deletions pages/helpjuice.css
Original file line number Diff line number Diff line change
@@ -1,46 +1,67 @@
#helpjuice-widget {
--right-position: 80px;
}

/* Hide the life preserver SVG path when the widget is open and show our injected close path */
#helpjuice-widget:has(#helpjuice-widget-expanded.hj-shown) svg .st0 {
display: none;
}

/* Hide our injected close path when the widget is closed */
#helpjuice-widget:has(#helpjuice-widget-expanded:not(.hj-shown)) svg .close {
display: none;
}

#helpjuice-widget .article .footer {
/* Hide the estimated reading time and last updated time in search results */
display: none !important;
}

/*
* The default height of #helpjuice-widget-expanded is 620px and #helpjuice-widget-content is 500px. It fits well when
* the screen is 800px. Therefore, the widget content height should be 300px less than the screen height, while keeping
* its height between 300px and 500px. And the widget itself should be 120px taller than the content.
* The default height of #helpjuice-widget-expanded is 620px and #helpjuice-widget-content is 500px.
* It fits well when the screen is 800px. However, on smaller screens, the widget content height
* should be be 300px less than the screen height. And the widget itself should be 120px taller than
* the content.
*/
#helpjuice-widget #helpjuice-widget-expanded {
--content-height: clamp(100vh - 300px, 300px, 500px);
--content-height: min(100vh - 300px, 500px);
height: calc(var(--content-height) + 120px) !important;
}

#helpjuice-widget #helpjuice-widget-expanded #helpjuice-widget-content {
height: var(--content-height) !important;
}

#helpjuice-widget.bottomRight {
right: 130px !important;
#dismiss-beacon {
cursor: pointer;
}

/* Move the beacon higher from the bottom when it is not expanded */
#helpjuice-widget.bottomRight:has(#helpjuice-widget-expanded:not(.hj-shown)) {
bottom: 170px !important;
#helpjuice-widget.bottomRight {
right: 10px !important;
transition: right 0.3s ease;
}

/* If the browser doesn't support :has and :not selectors, always move the beacon higher. */
@supports not selector(:has(*):not(*)) {
#helpjuice-widget.bottomRight {
bottom: 170px !important;
}
#helpjuice-widget.bottomRight.visible,
#helpjuice-widget.bottomRight:hover {
right: 80px !important;
}

@media (max-width: 900px) {
#helpjuice-widget.bottomRight {
right: 100px !important;
}
/*
* Expand the hover hitbox to be 20px above and below the button, extend to the right edge of the
* screen and be 20px past the left edge of the button.
*/
#helpjuice-widget.bottomRight::before {
content: '';
position: absolute;
height: calc(var(--right-position) + 40px);
top: -20px;
right: calc(var(--right-position) * -1);
left: -20px;
z-index: -1;
}

@media (max-width: 600px) {
#helpjuice-widget.bottomRight {
right: 90px !important;
}
/* Hide the popup when the beacon is dismissed and not being hovered. */
#helpjuice-widget.bottomRight:not(.visible):not(:hover)
#helpjuice-widget-expanded.hj-shown {
display: none !important;
}
48 changes: 48 additions & 0 deletions src/components/Helpjuice/DismissableBeacon.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { render } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { DismissableBeacon } from './DismissableBeacon';
import { widgetHTML } from './widget.mock';

const setDismissed = jest.fn();

describe('DismissableBeacon', () => {
beforeEach(() => {
document.body.innerHTML = widgetHTML;
});

it('toggles visible class to widget', () => {
const { rerender } = render(
<DismissableBeacon dismissed={false} setDismissed={setDismissed} />,
);
expect(document.getElementById('helpjuice-widget')).toHaveClass('visible');

rerender(
<DismissableBeacon dismissed={true} setDismissed={setDismissed} />,
);
expect(document.getElementById('helpjuice-widget')).not.toHaveClass(
'visible',
);
});

it('creates Hide Beacon link that hides the popup and removes the visible class from the widget', () => {
render(<DismissableBeacon dismissed={false} setDismissed={setDismissed} />);

expect(document.getElementById('helpjuice-widget')).toHaveClass('visible');

// Simulating expanding the widget
const expandedWidget = document.getElementById('helpjuice-widget-expanded');
expandedWidget?.classList.add('hj-shown');

userEvent.click(document.getElementById('dismiss-beacon')!);

expect(setDismissed).toHaveBeenCalledWith(true);
expect(expandedWidget).not.toHaveClass('hj-shown');
});

it('undismisses the beacon when the trigger is clicked', () => {
render(<DismissableBeacon dismissed={true} setDismissed={setDismissed} />);

userEvent.click(document.getElementById('helpjuice-widget-trigger')!);
expect(setDismissed).toHaveBeenCalledWith(false);
});
});
73 changes: 73 additions & 0 deletions src/components/Helpjuice/DismissableBeacon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { useEffect } from 'react';

interface DismissableBeaconProps {
dismissed: boolean;
setDismissed: (dismissed: boolean) => void;
}

/**
* Like <Helpjuice>, this component doesn't render anything, but it enhances the existing Helpjuice
* beacon in the DOM by making it dismissable.
*/
export const DismissableBeacon: React.FC<DismissableBeaconProps> = ({
dismissed,
setDismissed,
}) => {
// Sync the #helpjuice-widget .visible classname with the dismissed state
useEffect(() => {
const widget = document.getElementById('helpjuice-widget');
if (dismissed) {
widget?.classList.remove('visible');
} else {
widget?.classList.add('visible');
}
}, [dismissed]);

// Add a Hide Beacon link to the bottom of the popup
useEffect(() => {
const dismissLink = document.createElement('a');
dismissLink.id = 'dismiss-beacon';
dismissLink.textContent = 'Hide Beacon';
dismissLink.tabIndex = 0;
document
.getElementById('helpjuice-widget-contact')
?.appendChild(dismissLink);

return () => {
dismissLink?.remove();
};
}, []);

useEffect(() => {
const abortController = new AbortController();
// Dismiss the beacon when the Hide Beacon link is clicked
document.getElementById('dismiss-beacon')?.addEventListener(
'click',
() => {
// Hide the popup
document
.getElementById('helpjuice-widget-expanded')
?.classList.remove('hj-shown');

setDismissed(true);
},
{ signal: abortController.signal },
);

// Undismiss the beacon when it is clicked
document.getElementById('helpjuice-widget-trigger')?.addEventListener(
'click',
() => {
setDismissed(false);
},
{ signal: abortController.signal },
);

// Remove all event listeners on unmount
return () => {
abortController.abort();
};
}, [setDismissed]);

return null;
};
37 changes: 34 additions & 3 deletions src/components/Helpjuice/Helpjuice.test.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,26 @@
import { act, render } from '@testing-library/react';
import { act, render, waitFor } from '@testing-library/react';
import { useSession } from 'next-auth/react';
import { session } from '__tests__/fixtures/session';
import { GqlMockedProvider } from '__tests__/util/graphqlMocking';
import { UserOptionQuery } from 'src/hooks/UserPreference.generated';
import { Helpjuice } from './Helpjuice';
import { widgetHTML } from './widget.mock';

describe('Helpjuice', () => {
beforeEach(() => {
process.env.HELPJUICE_ORIGIN = 'https://domain.helpjuice.com';
process.env.HELPJUICE_KNOWLEDGE_BASE_URL =
'https://domain.helpjuice.com/kb';
document.body.innerHTML =
'<a id="helpjuice-contact-link">Contact Us</a><a class="knowledge-base-link">Visit Knowledge Base</a>';
document.body.innerHTML = widgetHTML;
location.href = 'https://example.com/';
});

it('adds close icon svg path', () => {
render(<Helpjuice />);

expect(document.querySelector('path.close')).toBeInTheDocument();
});

it('does nothing if the element is missing', () => {
document.body.innerHTML = '';

Expand Down Expand Up @@ -97,4 +105,27 @@ describe('Helpjuice', () => {
'https://domain.helpjuice.com/kb',
);
});

it('uses the Apollo client when available', async () => {
const mutationSpy = jest.fn();
render(
<GqlMockedProvider<{ UserOption: UserOptionQuery }>
mocks={{
UserOption: {
userOption: {
key: 'dismissed',
value: 'false',
},
},
}}
onCall={mutationSpy}
>
<Helpjuice />
</GqlMockedProvider>,
);

await waitFor(() =>
expect(mutationSpy).toHaveGraphqlOperation('UserOption'),
);
});
});
63 changes: 61 additions & 2 deletions src/components/Helpjuice/Helpjuice.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,41 @@
import { useEffect } from 'react';
import { useContext, useEffect, useState } from 'react';
import { getApolloContext } from '@apollo/client';
import { useSession } from 'next-auth/react';
import { useUserPreference } from 'src/hooks/useUserPreference';
import { DismissableBeacon } from './DismissableBeacon';
import { useLocation } from './useLocation';

/**
* This component doesn't render anything, but it finds the existing Helpjuice component in the DOM
* and tweaks some things about it.
*/
export const Helpjuice: React.FC = () => {
// Because of the way the Helpjuice script is written, it must be added in _document.page.tsx instead of a component.
// It adds content to the DOM in response to the DOMContentLoaded. If we add the Swifty script to this component, the
// DOMContentLoaded event has already fired, and Swifty will not add the beacon elements to the page.
const { data: session } = useSession();
const href = useLocation();

// Add a white x that is shown using CSS when the panel is open
useEffect(() => {
const closeImage = document.createElementNS(
'http://www.w3.org/2000/svg',
'path',
);
closeImage.classList.add('close');
closeImage.setAttributeNS(null, 'd', 'M20,20L88,88M88,20L20,88');
closeImage.setAttributeNS(null, 'stroke', 'white');
closeImage.setAttributeNS(null, 'stroke-linecap', 'round');
closeImage.setAttributeNS(null, 'stroke-width', '8');
closeImage.setAttributeNS(null, 'fill', 'none');

document
.querySelector('#helpjuice-widget #helpjuice-widget-trigger svg g')
?.appendChild(closeImage);

return () => closeImage.remove();
});

useEffect(() => {
if (!process.env.HELPJUICE_ORIGIN) {
return;
Expand Down Expand Up @@ -36,5 +63,37 @@ export const Helpjuice: React.FC = () => {
}
}, [session, href]);

return null;
// Use NoApolloBeacon on pages without an <ApolloProvider>
const hasApolloClient = Boolean(useContext(getApolloContext()).client);
return hasApolloClient ? <ApolloBeacon /> : <NoApolloBeacon />;
};

/**
* This variant of the dismissable beacon saves the dismissed state to a persistent user preference.
* It can only be used on pages with an <ApolloProvider>.
*/
const ApolloBeacon: React.FC = () => {
const [dismissed, setDismissed, { loading }] = useUserPreference({
key: 'beacon_dismissed',
defaultValue: false,
});

return (
<DismissableBeacon
dismissed={dismissed || loading}
setDismissed={setDismissed}
/>
);
};

/**
* This variant of the dismissable beacon saves the dismissed state to ephemeral state that will be
* lost when the page is reloaded. It is designed to be used on pages without an <ApolloProvider>.
*/
const NoApolloBeacon: React.FC = () => {
const [dismissed, setDismissed] = useState(false);

return (
<DismissableBeacon dismissed={dismissed} setDismissed={setDismissed} />
);
};
13 changes: 13 additions & 0 deletions src/components/Helpjuice/widget.mock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
export const widgetHTML = `<div id="helpjuice-widget">
<a id="helpjuice-widget-trigger">
<svg>
<g />
</svg>
</a>
<div id="helpjuice-widget-expanded" />
<div id="helpjuice-widget-contact">
<a id="helpjuice-contact-link">Contact Us</a>
<a class="knowledge-base-link">Visit Knowledge Base</a>
</div>
</div>
</div>`;
Loading