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

Update assignable launch #2253

Merged
merged 11 commits into from
Jun 17, 2024
12 changes: 1 addition & 11 deletions public/rex/activity.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,9 @@
"activities":{
"reading":{
"icon":{"url":"/rex/icons/reading.svg"},
"launch":{"url":"/apps/rex/assigned/:id","navigation":true,"xapi":false},
"launch":{"url":"/apps/rex/assigned/:id","navigation":true,"xapi":false,"textSize":true},
"objectIRI":"https://openstax.org/orn/reading/:id",
"objectTypeIRI":"http://id.tincanapi.com/activitytype/school-assignment"
},
"youtube_video":{
"launch":{"url":"https://www.youtube.com/embed/:id","navigation":false,"xapi":false},
"objectIRI":"https://openstax.org/orn/video-youtube/:id",
"objectTypeIRI":"http://adlnet.gov/expapi/activities/media"
},
"pressbooks_interactive": {
"launch":{"url":"https://ecampusontario.pressbooks.pub/apinteractives/wp-admin/admin-ajax.php","navigation":false,"xapi":false},
"objectIRI":"https://openstax.org/orn/interactives-pressbooks/:id",
"objectTypeIRI":"http://adlnet.gov/expapi/activities/media"
}
}
}
109 changes: 109 additions & 0 deletions src/app/content/components/AssignedTopBar.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,17 @@ import { MiddlewareAPI, Store } from '../../types';
import { setTextSize } from '../actions';
import { LinkedArchiveTreeSection } from '../types';
import { AssignedTopBar } from './AssignedTopBar';
import { assertDocument, assertWindow } from '../../utils/browser-assertions';

jest.mock('react', () => {
const react = (jest as any).requireActual('react');
return { ...react, useEffect: react.useLayoutEffect };
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see that swapping this makes it synchronous, but I can't tell why at a glance. Is act not sufficient?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

its possible that newer versions of react or the testing library don't have this problem, but there are a few places in the rex code that do this. the react issue is here facebook/react#14050 (comment)

});

describe('AssignedTopBar', () => {
const windowBack = assertWindow();
const addEventListenerBackup = windowBack.addEventListener;
let addEventListener: jest.SpyInstance;
let store: Store;
let services: ReturnType<typeof createTestServices> & MiddlewareAPI;

Expand All @@ -24,9 +33,15 @@ describe('AssignedTopBar', () => {
getState: store.getState,
};

addEventListener = jest.spyOn(windowBack, 'addEventListener');

store.dispatch(setTextSize(0));
});

afterEach(() => {
windowBack.addEventListener = addEventListenerBackup;
});

it('renders', async() => {
const dispatch = jest.fn();
jest.spyOn(redux, 'useDispatch').mockImplementation(() => dispatch);
Expand Down Expand Up @@ -57,4 +72,98 @@ describe('AssignedTopBar', () => {

tree.unmount();
});

it('renders null with textSize integration', async() => {
services.launchToken = {tokenString: '', tokenData: {textSize: 2}};

const section = { title: '1.1 Section Title' } as LinkedArchiveTreeSection;
const intl = await createIntl('en');

const tree = renderer.create(
<Provider store={store}>
<RawIntlProvider value={intl}>
<Services.Provider value={services}>
<AssignedTopBar section={section} />
</Services.Provider>
</RawIntlProvider>
</Provider>
);

expect(tree.toJSON()).toMatchSnapshot();

tree.unmount();
});

it('handles font size from postmessage', async() => {
const dispatch = jest.fn();
Object.defineProperty(assertWindow(), 'parent', {value: {...assertWindow()}});
Object.defineProperty(assertDocument(), 'referrer', {value: assertWindow().location.toString()});
services.launchToken = {tokenString: '', tokenData: {textSize: 2}};
jest.spyOn(redux, 'useDispatch').mockImplementation(() => dispatch);

const section = { title: '1.1 Section Title' } as LinkedArchiveTreeSection;
const intl = await createIntl('en');

const tree = renderer.create(
<Provider store={store}>
<RawIntlProvider value={intl}>
<Services.Provider value={services}>
<AssignedTopBar section={section} />
</Services.Provider>
</RawIntlProvider>
</Provider>
);

renderer.act(() => {
addEventListener.mock.calls.forEach(([event, handler]) => {
if (event === 'message') {
handler({
data: {type: 'TextSizeUpdate', value: 2},
origin: assertWindow().location.origin,
});
}
});
});

expect(dispatch).toHaveBeenCalledTimes(1);
expect(dispatch).toHaveBeenCalledWith(setTextSize(2));

tree.unmount();
});

it('ignores postmessages from weird origins', async() => {
const dispatch = jest.fn();
Object.defineProperty(assertWindow(), 'parent', {value: {...assertWindow()}});
Object.defineProperty(assertDocument(), 'referrer', {value: assertWindow().location.toString()});
services.launchToken = {tokenString: '', tokenData: {textSize: 2}};
jest.spyOn(redux, 'useDispatch').mockImplementation(() => dispatch);

const section = { title: '1.1 Section Title' } as LinkedArchiveTreeSection;
const intl = await createIntl('en');

const tree = renderer.create(
<Provider store={store}>
<RawIntlProvider value={intl}>
<Services.Provider value={services}>
<AssignedTopBar section={section} />
</Services.Provider>
</RawIntlProvider>
</Provider>
);

renderer.act(() => {
addEventListener.mock.calls.forEach(([event, handler]) => {
if (event === 'message') {
handler({
data: {type: 'TextSizeUpdate', value: 2},
origin: 'https://google.com',
});
}
});
});

expect(dispatch).not.toHaveBeenCalled();

tree.unmount();
});
});
44 changes: 42 additions & 2 deletions src/app/content/components/AssignedTopBar.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
import React from 'react';
import { useDispatch, useSelector } from 'react-redux';
import styled, { css } from 'styled-components';
import { MessageEvent } from '@openstax/types/lib.dom';
import theme from '../../theme';
import { setTextSize } from '../actions';
import * as selectContent from '../selectors';
import { LinkedArchiveTreeSection } from '../types';
import { shadow, TopBarWrapper } from './Topbar/styled';
import { TextResizer } from './Topbar/TextResizer';
import { topbarDesktopHeight, topbarMobileHeight } from './constants';
import { TextResizerValue, textResizerValues } from '../constants';
import { useLaunchToken } from '../launchToken';

// tslint:disable-next-line:variable-name
const StyledTopBarWrapper = styled(TopBarWrapper)`
Expand Down Expand Up @@ -39,21 +42,58 @@ const StyledSectionTitle = styled.h2`
`)}
`;

const useTextResizeIntegration = (handleChange: (value: TextResizerValue) => void) => {
const launchToken = useLaunchToken();

React.useEffect(() => {
if (typeof window === 'undefined' || !window.parent || window.parent === window) {
return;
}

const win = window;
const referrer = new URL(win.document.referrer);

const handler = (event: MessageEvent<any>) => {
if (
event.data.type === 'TextSizeUpdate' &&
event.origin === referrer.origin &&
textResizerValues.includes(event.data.value as TextResizerValue)
) {
handleChange(event.data.value);
}
};

win.addEventListener('message', handler);
return () => win.removeEventListener('message', handler);
}, [handleChange]);

return typeof launchToken.textSize === 'number';
};

// tslint:disable-next-line:variable-name
export const AssignedTopBar = (props: {
section: LinkedArchiveTreeSection;
}) => {

const bookTheme = useSelector(selectContent.bookTheme);
const textSize = useSelector(selectContent.textSize);
const dispatch = useDispatch();

const handleValueChange = React.useCallback((value: TextResizerValue) => {
dispatch(setTextSize(value));
}, [dispatch]);

const hasIntegration = useTextResizeIntegration(handleValueChange);

if (hasIntegration) {
return null;
}

return (
<StyledTopBarWrapper>
<StyledSectionTitle dangerouslySetInnerHTML={{ __html: props.section?.title }} />
<TextResizer
bookTheme={bookTheme}
setTextSize={(value) => dispatch(setTextSize(value))}
setTextSize={handleValueChange}
textSize={textSize}
data-testid='text-resizer'
mobileToolbarOpen={false}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -174,3 +174,5 @@ exports[`AssignedTopBar renders 1`] = `
</div>
</div>
`;

exports[`AssignedTopBar renders null with textSize integration 1`] = `null`;
9 changes: 8 additions & 1 deletion src/app/content/hooks/storeTextSize.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,13 @@ describe('loadStoredTextSize', () => {
expect(storeDispatch).toHaveBeenCalledWith(setTextSize(2));
});

it('loads the value from launchToken', async() => {
helpers.launchToken = {tokenString: '', tokenData: {textSize: 2}};
await hook();
expect(assertWindow().localStorage.getItem).toHaveBeenCalled();
expect(storeDispatch).toHaveBeenCalledWith(setTextSize(2));
});

it('noops if textSize is already set', async() => {
mockLocalStorage(2);
store.dispatch(setTextSize(3));
Expand All @@ -97,7 +104,7 @@ describe('loadStoredTextSize', () => {
global.window = window;
});

Object.entries(invalidValues).map((entry) => {
Object.entries(invalidValues).forEach((entry) => {
it(`uses the default if loaded value is ${entry[0]}`, async() => {
mockLocalStorage(entry[1]);
await hook();
Expand Down
19 changes: 10 additions & 9 deletions src/app/content/hooks/storeTextSize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@ import {
textResizerDefaultValue,
textResizerStorageKey,
TextResizerValue,
textResizerValues
textResizerValues,
} from '../constants';
import { textSize } from '../selectors';

export const loadStoredTextSize = (services: MiddlewareAPI & AppServices) => async() => {
const { getState, dispatch } = services;
const { getState, dispatch, launchToken } = services;
const state = getState();
let storedTextSize;
let value: TextResizerValue = textResizerDefaultValue;
Expand All @@ -18,19 +18,20 @@ export const loadStoredTextSize = (services: MiddlewareAPI & AppServices) => asy
return;
}

if (typeof window !== 'undefined') {
if (typeof launchToken?.tokenData.textSize === 'number') {
storedTextSize = launchToken.tokenData.textSize;
}

if (storedTextSize === undefined && typeof window !== 'undefined') {
try {
storedTextSize = window.localStorage.getItem(textResizerStorageKey);
storedTextSize = parseInt(window.localStorage.getItem(textResizerStorageKey) ?? '', 10);
} catch {
// They have blocked access to localStorage; ignore it
}
}

if (storedTextSize) {
const parsedValue = parseInt(storedTextSize, 10) as TextResizerValue;
if (!isNaN(parsedValue) && textResizerValues.includes(parsedValue)) {
value = parsedValue;
}
if (storedTextSize && textResizerValues.includes(storedTextSize as TextResizerValue)) {
value = storedTextSize as TextResizerValue;
}

dispatch(setTextSize(value));
Expand Down
Loading
Loading