Skip to content

Commit

Permalink
✨(xapi) track download activity
Browse files Browse the repository at this point in the history
When a user downloads a document or a video, a xapi statement is sent to
track this activity. We use a hack to combine the `<a href` and onclick
behaviour. If we send the xapi statement at the same time the download
is made, browsers stop the xapi fetch. The fetch is postponed to the
window blur event which happens once the download begins.
  • Loading branch information
lunika committed Oct 7, 2021
1 parent d3f5901 commit 06cc2af
Show file tree
Hide file tree
Showing 5 changed files with 145 additions and 26 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added

- Add video and document downloading xAPI events

### Removed

- IE11 is no longer supported
Expand Down
38 changes: 29 additions & 9 deletions src/frontend/components/DocumentPlayer/index.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { render } from '@testing-library/react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import fetchMock from 'fetch-mock';
import React from 'react';

import DocumentPlayer from '.';
import { XAPI_ENDPOINT } from '../../settings';
import { uploadState } from '../../types/tracks';
import { documentMockFactory } from '../../utils/tests/factories';

Expand All @@ -13,7 +15,11 @@ const mockDocument = documentMockFactory({
jest.mock('../../data/appData', () => ({
appData: {
document: mockDocument,
jwt: 'foo',
},
getDecodedJwt: jest.fn().mockImplementation(() => ({
session_id: 'abcd',
})),
}));

describe('<DocumentPlayer />', () => {
Expand All @@ -22,11 +28,9 @@ describe('<DocumentPlayer />', () => {
id: '42',
title: 'foo.pdf',
});
const { getByText, container } = render(
<DocumentPlayer document={document} />,
);
const { container } = render(<DocumentPlayer document={document} />);

getByText('foo.pdf');
screen.getByRole('link', { name: 'foo.pdf' });
expect(container.getElementsByClassName('icon-file-text2')).toHaveLength(1);
});

Expand All @@ -35,11 +39,27 @@ describe('<DocumentPlayer />', () => {
id: '43',
title: 'bar.pdf',
});
const { getByText, container } = render(
<DocumentPlayer document={document} />,
);
const { container } = render(<DocumentPlayer document={document} />);

getByText('bar.pdf');
screen.getByRole('link', { name: 'bar.pdf' });
expect(container.getElementsByClassName('icon-file-text2')).toHaveLength(1);
});

it('sends the xapi downloaded statement when clicking on the link', async () => {
fetchMock.mock(`${XAPI_ENDPOINT}/document/`, 204);
const document = documentMockFactory({
id: '42',
title: 'foo.pdf',
});
const { container } = render(<DocumentPlayer document={document} />);

const toDownload = screen.getByRole('link', { name: 'foo.pdf' });

fireEvent.click(toDownload);
fireEvent.blur(window);

await waitFor(() =>
expect(fetchMock.called(`${XAPI_ENDPOINT}/document/`)).toBe(true),
);
});
});
19 changes: 18 additions & 1 deletion src/frontend/components/DocumentPlayer/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ import { Box } from 'grommet';
import React from 'react';
import styled from 'styled-components';

import { appData, getDecodedJwt } from '../../data/appData';
import { useDocument } from '../../data/stores/useDocument';
import { Document } from '../../types/file';
import { DocumentXapiStatement } from '../../XAPI/DocumentXapiStatement';

const IconBox = styled.span`
font-size: 64px;
Expand All @@ -17,10 +19,25 @@ interface DocumentPlayerProps {
const DocumentPlayer = (props: DocumentPlayerProps) => {
const document = useDocument((state) => state.getDocument(props.document));

const onDownload = () => {
const callback = () => {
const documentXapiStatement = new DocumentXapiStatement(
appData.jwt!,
getDecodedJwt().session_id,
);
documentXapiStatement.downloaded();
window.removeEventListener('blur', callback);
};

window.addEventListener('blur', callback);
};

return (
<Box align="center" justify="center" direction="row">
<IconBox className="icon-file-text2" />
<a href={document.url}>{document.title}</a>
<a onClick={onDownload} href={document.url} download>
{document.title}
</a>
</Box>
);
};
Expand Down
83 changes: 69 additions & 14 deletions src/frontend/components/DownloadVideo/index.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,35 @@
import { render } from '@testing-library/react';
import * as React from 'react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import fetchMock from 'fetch-mock';
import React from 'react';

import { DownloadVideo } from '.';
import { XAPI_ENDPOINT } from '../../settings';
import { uploadState } from '../../types/tracks';
import { videoMockFactory } from '../../utils/tests/factories';
import { wrapInIntlProvider } from '../../utils/tests/intl';

jest.mock('../../data/appData', () => ({
appData: {
jwt: 'foo',
},
getDecodedJwt: jest.fn().mockImplementation(() => ({
session_id: 'abcd',
})),
}));

jest.mock('video.js', () => ({
__esModule: true,
default: {
getPlayers: () => [
{
duration: () => 600,
},
],
},
}));

describe('<DownloadVideo />', () => {
afterEach(() => fetchMock.reset());
it('renders all video links', () => {
const video = videoMockFactory({
description: 'Some description',
Expand All @@ -29,13 +52,11 @@ describe('<DownloadVideo />', () => {
},
});

const { getByText } = render(
wrapInIntlProvider(<DownloadVideo urls={video.urls!} />),
);
render(wrapInIntlProvider(<DownloadVideo urls={video.urls!} />));

getByText('1080p');
getByText('720p');
getByText('480p');
screen.getByRole('link', { name: '1080p' });
screen.getByRole('link', { name: '720p' });
screen.getByRole('link', { name: '480p' });
});

it('renders video links available from resolutions field', () => {
Expand All @@ -59,13 +80,11 @@ describe('<DownloadVideo />', () => {
},
});

const { getByText, queryByText } = render(
wrapInIntlProvider(<DownloadVideo urls={video.urls!} />),
);
render(wrapInIntlProvider(<DownloadVideo urls={video.urls!} />));

expect(queryByText(/1080p/i)).toEqual(null);
getByText('720p');
getByText('480p');
expect(screen.queryByText(/1080p/i)).toEqual(null);
screen.getByRole('link', { name: '720p' });
screen.getByRole('link', { name: '480p' });
});
it('returns nothing if there is no compatible resolutions', () => {
const video = videoMockFactory({
Expand Down Expand Up @@ -93,4 +112,40 @@ describe('<DownloadVideo />', () => {
);
expect(container.firstChild).toBeNull();
});
it('sends the xapi downloaded statement when a link is clicked', async () => {
fetchMock.mock(`${XAPI_ENDPOINT}/video/`, 204);
const video = videoMockFactory({
description: 'Some description',
id: 'video-id',
is_ready_to_show: true,
title: 'Some title',
upload_state: uploadState.READY,
urls: {
manifests: {
hls: 'https://example.com/hls.m3u8',
},
mp4: {
480: 'https://example.com/480p.mp4',
720: 'https://example.com/720p.mp4',
1080: 'https://example.com/1080p.mp4',
},
thumbnails: {
720: 'https://example.com/144p.jpg',
},
},
});

render(wrapInIntlProvider(<DownloadVideo urls={video.urls!} />));

screen.getByRole('link', { name: '1080p' });
screen.getByRole('link', { name: '720p' });
const toDownloadLink = screen.getByRole('link', { name: '480p' });

fireEvent.click(toDownloadLink);
fireEvent.blur(window);

await waitFor(() =>
expect(fetchMock.called(`${XAPI_ENDPOINT}/video/`)).toBe(true),
);
});
});
27 changes: 25 additions & 2 deletions src/frontend/components/DownloadVideo/index.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import { Box } from 'grommet';
import Tooltip from 'rc-tooltip';
import 'rc-tooltip/assets/bootstrap.css';
import React, { Fragment } from 'react';
import React, { Fragment, useEffect, useRef } from 'react';
import { defineMessages, FormattedMessage } from 'react-intl';
import styled from 'styled-components';
import videojs, { VideoJsPlayer } from 'video.js';

import { appData, getDecodedJwt } from '../../data/appData';
import { videoSize, VideoUrls } from '../../types/tracks';
import { VideoXAPIStatement } from '../../XAPI/VideoXAPIStatement';

const messages = defineMessages({
downloadVideo: {
Expand Down Expand Up @@ -40,12 +43,32 @@ export const DownloadVideo = ({ urls }: { urls: VideoUrls }) => {
const resolutions = Object.keys(urls.mp4).map(
(size) => Number(size) as videoSize,
);
const player = useRef<VideoJsPlayer>();
useEffect(() => {
player.current = Object.values(videojs.getPlayers())[0];
}, []);
const onDownload = (size: videoSize) => {
if (player.current) {
const callback = () => {
const videoXAPIStatement = new VideoXAPIStatement(
appData.jwt!,
getDecodedJwt().session_id,
);
videoXAPIStatement.setDuration(player.current!.duration());
videoXAPIStatement.downloaded(size);
window.removeEventListener('blur', callback);
};
window.addEventListener('blur', callback);
}
};
const elements: JSX.Element[] = ([1080, 720, 480] as downloadableSize[])
.filter((size) => resolutions.includes(size))
.reduce((acc: JSX.Element[], size: downloadableSize) => {
acc.push(
<Fragment key={`fragment-${size}`}>
<a href={urls.mp4[size]}>{size}p</a>
<a onClick={() => onDownload(size)} href={urls.mp4[size]} download>
{size}p
</a>
&nbsp;
<Tooltip
overlay={<FormattedMessage {...messages[size]} />}
Expand Down

0 comments on commit 06cc2af

Please sign in to comment.