Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add Popup component
- Loading branch information
Showing
5 changed files
with
333 additions
and
2 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,21 @@ | ||
A simple Popup which provides handlers for a few events. | ||
|
||
### Simple use case | ||
|
||
``` | ||
<div> | ||
<button onClick={() => setState({ showPopup: true })}> | ||
Show popup | ||
</button> | ||
<p>Popup is {state.showPopup ? "open" : "closed"}</p> | ||
{ state.showPopup && | ||
<Popup | ||
initialUrl="http://example.org" | ||
tile="Example Popup" | ||
width="800" | ||
height="500" | ||
onClose={() => setState({showPopup: false})} | ||
/> | ||
} | ||
</div> | ||
``` |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,177 @@ | ||
import { PureComponent } from 'react' | ||
import PropTypes from 'prop-types' | ||
|
||
import { isMobileApp } from 'cozy-device-helper' | ||
|
||
/** | ||
* Customized function to get dimensions and position for a centered | ||
* popup window | ||
* @param {string|number} w | ||
* @param {string|number} h | ||
* @return {{w, h, top, left}} Popup window | ||
*/ | ||
// source https://stackoverflow.com/a/16861050 | ||
export function popupCenter(w, h) { | ||
/* global screen */ | ||
// Fixes dual-screen position | ||
// Most browsers Firefox | ||
const dualScreenLeft = window.screenLeft || screen.left | ||
const dualScreenTop = window.screenTop || screen.top | ||
|
||
const width = | ||
window.innerWidth || document.documentElement.clientWidth || screen.width | ||
const height = | ||
window.innerHeight || document.documentElement.clientHeight || screen.height | ||
|
||
const left = width / 2 - w / 2 + dualScreenLeft | ||
const top = height / 2 - h / 2 + dualScreenTop | ||
return { | ||
w, | ||
h, | ||
top, | ||
left | ||
} | ||
} | ||
|
||
/** | ||
* Renders a popup and listen to popup events | ||
*/ | ||
export class Popup extends PureComponent { | ||
constructor(props, context) { | ||
super(props, context) | ||
|
||
this.handleClose = this.handleClose.bind(this) | ||
this.handleMessage = this.handleMessage.bind(this) | ||
this.handleLoadStart = this.handleLoadStart.bind(this) | ||
} | ||
|
||
componentDidMount() { | ||
this.showPopup() | ||
} | ||
|
||
componentWillUnmount() { | ||
this.killPopup() | ||
} | ||
|
||
addListeners() { | ||
// Listen here for message FROM popup | ||
window.addEventListener('message', this.handleMessage) | ||
|
||
if (isMobileApp()) { | ||
this.popup.addEventListener('loadstart', this.handleLoadStart) | ||
this.popup.addEventListener('exit', this.handleClose) | ||
} | ||
} | ||
|
||
removeListeners() { | ||
window.removeEventListener('message', this.handleMessage) | ||
|
||
// rest of instructions only if popup is still opened | ||
if (this.popup.closed) return | ||
|
||
if (isMobileApp()) { | ||
this.popup.removeEventListener('loadstart', this.handleLoadStart) | ||
this.popup.removeEventListener('exit', this.handleClose) | ||
} | ||
} | ||
|
||
handleMessage(messageEvent) { | ||
const { onMessage } = this.props | ||
const isFromPopup = this.popup === messageEvent.source | ||
if (isFromPopup && typeof onMessage === 'function') onMessage(messageEvent) | ||
} | ||
|
||
handleClose() { | ||
this.killPopup() | ||
|
||
const { onClose } = this.props | ||
if (typeof onClose === 'function') onClose(this.popup) | ||
} | ||
|
||
showPopup() { | ||
const { initialUrl, height, width, title } = this.props | ||
const { w, h, top, left } = popupCenter(width, height) | ||
/** | ||
* ATM we also use window.open on Native App in order to open | ||
* InAppBrowser. But some provider (Google for instance) will | ||
* block us. We need to use a SafariViewController or Chrome Custom Tab. | ||
* So | ||
*/ | ||
this.popup = window.open( | ||
initialUrl, | ||
title, | ||
`scrollbars=yes, width=${w}, height=${h}, top=${top}, left=${left}` | ||
) | ||
// Puts focus on the newWindow | ||
if (this.popup.focus) { | ||
this.popup.focus() | ||
} | ||
|
||
this.addListeners() | ||
this.startMonitoringClosing() | ||
} | ||
|
||
killPopup() { | ||
this.removeListeners() | ||
this.stopMonitoringClosing() | ||
if (!this.popup.closed) this.popup.close() | ||
} | ||
|
||
monitorClosing() { | ||
if (this.popup.closed) { | ||
this.stopMonitoringClosing() | ||
return this.handleClose() | ||
} | ||
} | ||
|
||
/** | ||
* Check if window is closing every 500ms | ||
* @param {Window} window | ||
*/ | ||
startMonitoringClosing() { | ||
this.checkClosedInterval = setInterval(() => this.monitorClosing(), 500) | ||
} | ||
|
||
stopMonitoringClosing() { | ||
clearInterval(this.checkClosedInterval) | ||
} | ||
|
||
handleLoadStart(event) { | ||
const { url } = event | ||
const { onMobileUrlChange } = this.props | ||
if (typeof onMobileUrlChange === 'function') onMobileUrlChange(new URL(url)) | ||
} | ||
|
||
render() { | ||
return null | ||
} | ||
} | ||
|
||
Popup.propTypes = { | ||
// Dimensions | ||
height: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, | ||
width: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, | ||
title: PropTypes.string, | ||
initialUrl: PropTypes.string.isRequired, | ||
// Callbacks | ||
/** | ||
* Close handler. Called after popup closing. | ||
*/ | ||
onClose: PropTypes.func, | ||
/** | ||
* Handler called when a message is received from `postMessage` interface. | ||
* @param {MessageEvent} messageEvent Received MessageEvent object. | ||
*/ | ||
onMessage: PropTypes.func, | ||
/** | ||
* Handler used on mobile device to detect url changes | ||
* @param {URL} url URL object. | ||
*/ | ||
onMobileUrlChange: PropTypes.func | ||
} | ||
|
||
Popup.defaultProps = { | ||
title: '' | ||
} | ||
|
||
export default Popup |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,131 @@ | ||
import React from 'react' | ||
import { shallow } from 'enzyme' | ||
|
||
import { isMobileApp } from 'cozy-device-helper' | ||
|
||
import { Popup } from './' | ||
|
||
jest.mock('cozy-device-helper', () => ({ | ||
...require.requireActual('cozy-device-helper'), | ||
isMobileApp: jest.fn() | ||
})) | ||
|
||
const props = { | ||
initialUrl: 'http://example.org', | ||
title: 'Test title', | ||
width: '500', | ||
height: '200' | ||
} | ||
|
||
const popupMock = {} | ||
class MessageEventMock { | ||
constructor(options = {}) { | ||
this.source = options.source || popupMock | ||
} | ||
} | ||
|
||
describe('Popup', () => { | ||
beforeAll(() => { | ||
jest.useFakeTimers() | ||
|
||
jest.spyOn(global, 'open').mockReturnValue(popupMock) | ||
jest.spyOn(global, 'addEventListener') | ||
}) | ||
|
||
afterAll(() => { | ||
global.open.mockRestore() | ||
global.addEventListener.mockRestore() | ||
}) | ||
|
||
beforeEach(() => { | ||
isMobileApp.mockReturnValue(false) | ||
popupMock.addEventListener = jest.fn() | ||
popupMock.close = jest.fn() | ||
popupMock.focus = jest.fn() | ||
popupMock.closed = false | ||
props.onClose = jest.fn() | ||
props.onMessage = jest.fn() | ||
props.onMobileUrlChange = jest.fn() | ||
}) | ||
|
||
afterEach(() => { | ||
jest.clearAllMocks() | ||
isMobileApp.mockRestore() | ||
}) | ||
|
||
it('should open new window', () => { | ||
shallow(<Popup {...props} />) | ||
expect(global.open).toHaveBeenCalledWith( | ||
props.initialUrl, | ||
props.title, | ||
expect.anything() | ||
) | ||
expect(popupMock.focus).toHaveBeenCalled() | ||
}) | ||
|
||
it('should subscribe to message events', () => { | ||
const wrapper = shallow(<Popup {...props} />) | ||
expect(global.addEventListener).toHaveBeenCalledWith( | ||
'message', | ||
wrapper.instance().handleMessage | ||
) | ||
}) | ||
|
||
it('should subcribe to mobile events', () => { | ||
isMobileApp.mockReturnValue(true) | ||
const wrapper = shallow(<Popup {...props} />) | ||
expect(popupMock.addEventListener).toHaveBeenCalledWith( | ||
'loadstart', | ||
wrapper.instance().handleLoadStart | ||
) | ||
expect(popupMock.addEventListener).toHaveBeenCalledWith( | ||
'exit', | ||
wrapper.instance().handleClose | ||
) | ||
}) | ||
|
||
describe('monitorClosing', () => { | ||
it('should detect closing', () => { | ||
const wrapper = shallow(<Popup {...props} />) | ||
jest.spyOn(wrapper.instance(), 'handleClose') | ||
popupMock.closed = true | ||
jest.runAllTimers() | ||
expect(wrapper.instance().handleClose).toHaveBeenCalled() | ||
}) | ||
}) | ||
|
||
describe('handleClose', () => { | ||
it('should call onClose', () => { | ||
const wrapper = shallow(<Popup {...props} />) | ||
wrapper.instance().handleClose() | ||
expect(props.onClose).toHaveBeenCalled() | ||
}) | ||
}) | ||
|
||
describe('handleMessage', () => { | ||
it('should call onMessage', () => { | ||
const wrapper = shallow(<Popup {...props} />) | ||
const messageEvent = new MessageEventMock() | ||
wrapper.instance().handleMessage(messageEvent) | ||
expect(props.onMessage).toHaveBeenCalledWith(messageEvent) | ||
}) | ||
|
||
it('should ignore messageEvent from another window ', () => { | ||
const wrapper = shallow(<Popup {...props} />) | ||
const messageEvent = new MessageEventMock({ source: {} }) | ||
wrapper.instance().handleMessage(messageEvent) | ||
expect(props.onMessage).not.toHaveBeenCalled() | ||
}) | ||
}) | ||
|
||
describe('handleLoadStart', () => { | ||
it('should call onMobileUrlChange', () => { | ||
const wrapper = shallow(<Popup {...props} />) | ||
const url = 'https://cozy.io' | ||
const urlEvent = { url } | ||
wrapper.instance().handleLoadStart(urlEvent) | ||
expect(props.onMobileUrlChange).toHaveBeenCalledWith(expect.any(URL)) | ||
expect(props.onMobileUrlChange).toHaveBeenCalledWith(new URL(url)) | ||
}) | ||
}) | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters