Skip to content
Open
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
3 changes: 3 additions & 0 deletions src/sidebar/components/HypothesisApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import type { ToastMessengerService } from '../services/toast-messenger';
import { useSidebarStore } from '../store';
import AnnotationView from './AnnotationView';
import HelpPanel from './HelpPanel';
import InstructorSurveyPanel from './InstructorSurveyPanel';
import NotebookView from './NotebookView';
import ProfileView from './ProfileView';
import SharePanel from './SharePanel';
Expand Down Expand Up @@ -61,6 +62,7 @@ function HypothesisApp({
const isThemeClean = settings.theme === 'clean';

const isSidebar = route === 'sidebar';
const surveyPending = store.isInstructorSurveyPending();

useEffect(() => {
if (shouldAutoDisplayTutorial(isSidebar, profile, settings)) {
Expand Down Expand Up @@ -173,6 +175,7 @@ function HypothesisApp({
)}
<div className="container">
<ToastMessages />
{isSidebar && surveyPending && <InstructorSurveyPanel />}
<HelpPanel />
<SearchPanel />
<SharePanel shareTab={!isThirdParty} />
Expand Down
140 changes: 140 additions & 0 deletions src/sidebar/components/InstructorSurveyPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { Button, Card, CloseButton } from '@hypothesis/frontend-shared';
import classnames from 'classnames';
import { useCallback, useEffect, useId, useRef, useState } from 'preact/hooks';

import type { InstructorSurveyResponse } from '../../types/api';
import { withServices } from '../service-context';
import type { AnalyticsService } from '../services/analytics';
import type { SessionService } from '../services/session';
import { useSidebarStore } from '../store';

export type InstructorSurveyPanelProps = {
// Injected
analytics: AnalyticsService;
session: SessionService;
};

/**
* One-question survey asking whether the user teaches a course, shown at the
* top of the sidebar to users H says are being asked (see the
* `isInstructorSurveyPending` selector).
*
* All three answers -- yes, no, and the dismissal -- are recorded, and any of
* them stops the survey being shown again. The panel disappears because the
* answer lands in the profile, so there is no local "answered" state to keep.
*/
function InstructorSurveyPanel({
analytics,
session,
}: InstructorSurveyPanelProps) {
const store = useSidebarStore();
const headingId = useId();
const container = useRef<HTMLElement | null>(null);
const [submitting, setSubmitting] = useState(false);

// Both effects below wait for the sidebar to have been opened, because
// mounting is not the same as being seen. The sidebar iframe is created when
// the page loads and starts collapsed -- `visibility: hidden` -- so this
// panel mounts as soon as the profile arrives whether or not anyone will ever
// look at it. `hasSidebarOpened` is sticky, so each effect runs once, on the
// first open. SidebarView uses it the same way to hold off connecting the
// streamer.
const sidebarHasOpened = store.hasSidebarOpened();

// Record the impression when the panel is actually on screen. Tracking it at
// mount would count every page load of a user who never opens the sidebar,
// and the PRD wants to know who *saw* the survey.
useEffect(() => {
if (sidebarHasOpened) {
analytics.trackEvent('client.survey.instructor_role.shown');
}
}, [analytics, sidebarHasOpened]);

// Move focus to the panel, the way SidebarPanel does, so that keyboard and
// screen reader users land on the question rather than having to find it.
// Focusing at mount would be a no-op while the frame is hidden, and would
// take focus away from whatever the reader was doing if it weren't.
// Deliberately not a `role="dialog"`: that would imply the top bar is inert,
// which it isn't.
useEffect(() => {
if (sidebarHasOpened) {
container.current?.focus();
}
}, [sidebarHasOpened]);

const submit = useCallback(
async (response: InstructorSurveyResponse) => {
setSubmitting(true);
try {
await session.submitInstructorSurveyResponse(response);
} catch {
// The session service has already shown a toast. The panel stays up so
// the answer can be given a second time.
}
// Re-enable either way. On success the updated profile unmounts this
// panel, so this is normally a no-op -- but if a response ever came back
// still reporting the survey as pending, leaving `submitting` set would
// strand the user with a panel whose dismiss button is disabled too.
setSubmitting(false);
},
[session],
);

return (
<section
aria-labelledby={headingId}
// Same outer shape as the Help, Search and Share panels (SidebarPanel),
// so it lines up with whichever of them is open below it.
className="mb-4 focus-visible-ring rounded-lg"
data-testid="instructor-survey-panel"
ref={container}
tabIndex={-1}
>
<Card
classes={classnames(
'relative flex flex-col gap-3 p-3 text-color-text text-sm',
)}
data-testid="instructor-survey-card"
>
<CloseButton
classes={classnames(
'absolute top-1 right-1',
// Same size and colours as the close button in TabHeader, which the
// Help and Share panels use.
'text-[16px] text-grey-6 hover:text-grey-7 hover:bg-grey-3/50',
'touch:!min-h-0',
)}
data-testid="instructor-survey-dismiss"
disabled={submitting}
onClick={() => submit('dismissed')}
size="sm"
title="Dismiss survey"
variant="custom"
/>
<h2 className="m-0 text-base font-medium" id={headingId}>
Are you a course instructor?
</h2>
<div className="flex flex-row gap-2">
<Button
data-testid="instructor-survey-yes"
disabled={submitting}
onClick={() => submit('instructor')}
variant="primary"
>
Yes
</Button>
<Button
data-testid="instructor-survey-no"
disabled={submitting}
onClick={() => submit('not_instructor')}
variant="primary"
>
No
</Button>
</div>
</Card>
</section>
);
}

export default withServices(InstructorSurveyPanel, ['analytics', 'session']);
31 changes: 31 additions & 0 deletions src/sidebar/components/test/HypothesisApp-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ describe('HypothesisApp', () => {
},
}),
route: sinon.stub().returns('sidebar'),
isInstructorSurveyPending: sinon.stub().returns(false),

getLink: sinon.stub(),
};
Expand Down Expand Up @@ -188,6 +189,36 @@ describe('HypothesisApp', () => {
});
});

describe('instructor survey panel', () => {
it('renders InstructorSurveyPanel when the survey is pending', () => {
fakeStore.route.returns('sidebar');
fakeStore.isInstructorSurveyPending.returns(true);

const wrapper = createComponent();

assert.isTrue(wrapper.find('InstructorSurveyPanel').exists());
});

it('does not render InstructorSurveyPanel when the survey is not pending', () => {
fakeStore.isInstructorSurveyPending.returns(false);

const wrapper = createComponent();

assert.isFalse(wrapper.find('InstructorSurveyPanel').exists());
});

['annotation', 'notebook', 'profile', 'stream'].forEach(route => {
it(`does not render InstructorSurveyPanel outside the sidebar (${route})`, () => {
fakeStore.isInstructorSurveyPending.returns(true);
fakeStore.route.returns(route);

const wrapper = createComponent();

assert.isFalse(wrapper.find('InstructorSurveyPanel').exists());
});
});
});

// Add tests for common behaviors shared between "Log in" and "Sign up" actions.
function addCommonLoginTests(action) {
const clickButton = wrapper =>
Expand Down
178 changes: 178 additions & 0 deletions src/sidebar/components/test/InstructorSurveyPanel-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
import { checkAccessibility, mount } from '@hypothesis/frontend-testing';

import { ServiceContext } from '../../service-context';
import InstructorSurveyPanel, { $imports } from '../InstructorSurveyPanel';

describe('InstructorSurveyPanel', () => {
let fakeAnalyticsService;
let fakeSessionService;
let fakeStore;

function createComponent() {
const services = {
analytics: fakeAnalyticsService,
session: fakeSessionService,
};
const injector = { get: name => services[name] };
return mount(
<ServiceContext.Provider value={injector}>
<InstructorSurveyPanel />
</ServiceContext.Provider>,
{ connected: true },
);
}

beforeEach(() => {
fakeAnalyticsService = { trackEvent: sinon.stub() };
fakeSessionService = {
submitInstructorSurveyResponse: sinon.stub().resolves(),
};
// The panel only counts as seen once the sidebar has been opened; most
// tests want it already open.
fakeStore = { hasSidebarOpened: sinon.stub().returns(true) };

$imports.$mock({
'../store': { useSidebarStore: () => fakeStore },
});
});

afterEach(() => {
$imports.$restore();
});

it('asks the question', () => {
const wrapper = createComponent();
assert.include(wrapper.text(), 'Are you a course instructor?');
});

it('tracks an impression once the sidebar has been opened', () => {
const wrapper = createComponent();

assert.calledOnce(fakeAnalyticsService.trackEvent);
assert.calledWith(
fakeAnalyticsService.trackEvent,
'client.survey.instructor_role.shown',
);

wrapper.setProps({});
assert.calledOnce(fakeAnalyticsService.trackEvent);
});

it('moves focus to the panel', () => {
const wrapper = createComponent();
assert.equal(
document.activeElement,
wrapper
.find('section[data-testid="instructor-survey-panel"]')
.getDOMNode(),
);
});

it('does not track an impression while the sidebar is closed', () => {
// The sidebar iframe is created at page load and starts collapsed, so the
// panel mounts without anyone having seen it.
fakeStore.hasSidebarOpened.returns(false);

createComponent();

assert.notCalled(fakeAnalyticsService.trackEvent);
});

it('does not move focus while the sidebar is closed', () => {
fakeStore.hasSidebarOpened.returns(false);

const wrapper = createComponent();

assert.notEqual(
document.activeElement,
wrapper
.find('section[data-testid="instructor-survey-panel"]')
.getDOMNode(),
);
});

[
{
selector: 'Button[data-testid="instructor-survey-yes"]',
response: 'instructor',
},
{
selector: 'Button[data-testid="instructor-survey-no"]',
response: 'not_instructor',
},
// Dismissing is an answer like the other two, not an absence of one.
{
selector: 'CloseButton[data-testid="instructor-survey-dismiss"]',
response: 'dismissed',
},
].forEach(({ selector, response }) => {
it(`submits "${response}"`, () => {
const wrapper = createComponent();

wrapper.find(selector).props().onClick();

assert.calledOnce(fakeSessionService.submitInstructorSurveyResponse);
assert.calledWith(
fakeSessionService.submitInstructorSurveyResponse,
response,
);
});
});

it('disables every action while an answer is in flight', async () => {
let resolveSubmit;
fakeSessionService.submitInstructorSurveyResponse.returns(
new Promise(resolve => {
resolveSubmit = resolve;
}),
);
const wrapper = createComponent();

wrapper
.find('Button[data-testid="instructor-survey-yes"]')
.props()
.onClick();
wrapper.update();

for (const selector of [
'Button[data-testid="instructor-survey-yes"]',
'Button[data-testid="instructor-survey-no"]',
'CloseButton[data-testid="instructor-survey-dismiss"]',
]) {
assert.isTrue(
wrapper.find(selector).prop('disabled'),
`${selector} should be disabled`,
);
}

resolveSubmit();
});

it('re-enables the actions when submitting fails', async () => {
fakeSessionService.submitInstructorSurveyResponse.rejects(
new Error('API error'),
);
const wrapper = createComponent();

await wrapper
.find('Button[data-testid="instructor-survey-yes"]')
.props()
.onClick();
wrapper.update();

// The service has shown a toast; the panel stays up so the answer can be
// given again.
assert.isFalse(
wrapper
.find('Button[data-testid="instructor-survey-yes"]')
.prop('disabled'),
);
});

it(
'should pass a11y checks',
checkAccessibility({
content: () => createComponent(),
}),
);
});
Loading
Loading