Skip to content

Commit

Permalink
Add Popup component (#1014)
Browse files Browse the repository at this point in the history
Add Popup component
  • Loading branch information
gregorylegarec committed Jul 25, 2019
2 parents 7ffd782 + 59489a5 commit 546c04b
Show file tree
Hide file tree
Showing 5 changed files with 333 additions and 2 deletions.
1 change: 1 addition & 0 deletions docs/styleguide.config.js
Expand Up @@ -92,6 +92,7 @@ module.exports = {
components: () => [
'../react/Overlay/index.jsx',
'../react/Alerter/index.jsx',
'../react/Popup/index.jsx',
'../react/PopupOpener/index.jsx',
'../react/PushClientButton/index.jsx',
'../react/PushClientBanner/index.jsx',
Expand Down
21 changes: 21 additions & 0 deletions react/Popup/Readme.md
@@ -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>
```
177 changes: 177 additions & 0 deletions react/Popup/index.jsx
@@ -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
131 changes: 131 additions & 0 deletions react/Popup/index.spec.jsx
@@ -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))
})
})
})
5 changes: 3 additions & 2 deletions react/index.js
Expand Up @@ -68,13 +68,14 @@ export { default as Circle } from './Circle'
export { default as Counter } from './Counter'
export { default as Well } from './Well'
export { default as Infos } from './Infos'
export { default as InputGroup } from './InputGroup'
export { default as AppIcon } from './AppIcon'
export { default as AppTitle } from './AppTitle'
export { default as Filename } from './Filename'
export { default as Viewer } from './Viewer/ViewerExposer'
export { default as FileInput } from './FileInput'
export { default as Card } from './Card'
export { default as InlineCard } from './InlineCard'
export { default as PercentageLine } from './PercentageLine'
export { default as InputGroup } from './InputGroup'
export { PageFooter, PageContent, PageLayout } from './Page'
export { default as PercentageLine } from './PercentageLine'
export { default as Popup } from './Popup'

0 comments on commit 546c04b

Please sign in to comment.