mirror of
https://github.com/responsively-org/responsively-app
synced 2024-11-10 06:44:13 +00:00
Added device previewer and zoom
This commit is contained in:
parent
2e6c1dedd4
commit
c081be2c6d
26 changed files with 477 additions and 22 deletions
|
@ -2,6 +2,7 @@
|
|||
import type {GetState, Dispatch} from '../reducers/types';
|
||||
|
||||
export const NEW_ADDRESS = 'NEW_ADDRESS';
|
||||
export const NEW_ZOOM_LEVEL = 'NEW_ZOOM_LEVEL';
|
||||
|
||||
export function newAddress(address) {
|
||||
return {
|
||||
|
@ -10,8 +11,14 @@ export function newAddress(address) {
|
|||
};
|
||||
}
|
||||
|
||||
export function newZoomLevel(zoomLevel) {
|
||||
return {
|
||||
type: NEW_ZOOM_LEVEL,
|
||||
zoomLevel,
|
||||
};
|
||||
}
|
||||
|
||||
export function onAddressChange(newURL) {
|
||||
console.log('onAddressChange', newURL);
|
||||
return (dispatch: Dispatch, getState: GetState) => {
|
||||
const {address} = getState();
|
||||
|
||||
|
@ -24,3 +31,15 @@ export function onAddressChange(newURL) {
|
|||
dispatch(newAddress(newURL));
|
||||
};
|
||||
}
|
||||
|
||||
export function onZoomChange(newLevel) {
|
||||
return (dispatch: Dispatch, getState: GetState) => {
|
||||
const {zoomLevel} = getState();
|
||||
|
||||
if (newLevel === zoomLevel) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(newZoomLevel(newLevel));
|
||||
};
|
||||
}
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Hello Electron React!</title>
|
||||
<meta charset="utf-8" />
|
||||
<title>Responsively</title>
|
||||
<script>
|
||||
(function() {
|
||||
if (!process.env.HOT) {
|
||||
|
@ -12,7 +12,7 @@
|
|||
// HACK: Writing the script path should be done with webpack
|
||||
document.getElementsByTagName('head')[0].appendChild(link);
|
||||
}
|
||||
}());
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
|
@ -30,7 +30,7 @@
|
|||
// Dynamically insert the bundled app script in the renderer process
|
||||
const port = process.env.PORT || 1212;
|
||||
scripts.push(
|
||||
(process.env.HOT)
|
||||
process.env.HOT
|
||||
? 'http://localhost:' + port + '/dist/renderer.dev.js'
|
||||
: './dist/renderer.prod.js'
|
||||
);
|
||||
|
|
|
@ -36,7 +36,7 @@ class AddressBar extends React.Component<Props> {
|
|||
onChange={e => this.setState({userTypedAddress: e.target.value})}
|
||||
/>
|
||||
<button className={styles.goButton} onClick={this._onChange}>
|
||||
<GoArrowIcon height={30} color="white" />
|
||||
<GoArrowIcon height={20} color="white" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
|
@ -6,7 +6,6 @@
|
|||
font-size: 16px;
|
||||
height: 20px;
|
||||
width: 200px;
|
||||
margin: 0 20px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 20px;
|
||||
background: unset;
|
||||
|
@ -21,4 +20,3 @@
|
|||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
|
29
desktop-app/app/components/DevicesPreviewer/index.js
Normal file
29
desktop-app/app/components/DevicesPreviewer/index.js
Normal file
|
@ -0,0 +1,29 @@
|
|||
// @flow
|
||||
import React, {Component} from 'react';
|
||||
import Renderer from '../Renderer';
|
||||
import cx from 'classnames';
|
||||
|
||||
import styles from './style.module.css';
|
||||
|
||||
class DevicesPreviewer extends Component {
|
||||
render() {
|
||||
console.log('DevicesPreviewer props', this.props);
|
||||
const {
|
||||
browser: {devices, address, zoomLevel},
|
||||
} = this.props;
|
||||
return (
|
||||
<div className={cx(styles.container)}>
|
||||
{devices.map(device => (
|
||||
<Renderer
|
||||
key={device.id}
|
||||
device={device}
|
||||
src={address}
|
||||
zoomLevel={zoomLevel}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default DevicesPreviewer;
|
48
desktop-app/app/components/DevicesPreviewer/index.test.js
Normal file
48
desktop-app/app/components/DevicesPreviewer/index.test.js
Normal file
|
@ -0,0 +1,48 @@
|
|||
// @flow
|
||||
import React from 'react';
|
||||
import {shallow} from 'enzyme';
|
||||
import {expect} from 'chai';
|
||||
|
||||
import DevicesPreviewer from './';
|
||||
|
||||
const testSrc = 'https://testUrl.com';
|
||||
const testDevice1 = {
|
||||
id: 1,
|
||||
name: 'testDevice1',
|
||||
width: 100,
|
||||
height: 100,
|
||||
};
|
||||
const testDevice2 = {
|
||||
id: 2,
|
||||
name: 'testDevice2',
|
||||
width: 200,
|
||||
height: 200,
|
||||
};
|
||||
const testDevices = [testDevice1, testDevice2];
|
||||
|
||||
describe('<DevicesPreviewer />', () => {
|
||||
it('Renders the Renderer for all passed array of devices', () => {
|
||||
const wrapper = shallow(
|
||||
<DevicesPreviewer devices={testDevices} url={testSrc} />
|
||||
);
|
||||
expect(wrapper.find('Renderer')).to.have.lengthOf(testDevices.length);
|
||||
});
|
||||
|
||||
it('Renders the Renderer for all devices passed with the given url', () => {
|
||||
const wrapper = shallow(
|
||||
<DevicesPreviewer devices={testDevices} url={testSrc} />
|
||||
);
|
||||
wrapper.find('Renderer').forEach(renderer => {
|
||||
expect(renderer.prop('src')).to.equal(testSrc);
|
||||
});
|
||||
});
|
||||
|
||||
it('Renders the Renderer for all devices in the given order', () => {
|
||||
const wrapper = shallow(
|
||||
<DevicesPreviewer devices={testDevices} url={testSrc} />
|
||||
);
|
||||
wrapper.find('Renderer').forEach((renderer, idx) => {
|
||||
expect(renderer.prop('device')).to.equal(testDevices[idx]);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,6 @@
|
|||
.container {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.device {
|
||||
}
|
23
desktop-app/app/components/Header/index.js
Normal file
23
desktop-app/app/components/Header/index.js
Normal file
|
@ -0,0 +1,23 @@
|
|||
// @flow
|
||||
import React, {Component} from 'react';
|
||||
import ZoomContainer from '../../containers/ZoomContainer';
|
||||
import AddressBar from '../../containers/AddressBar';
|
||||
import Grid from '@material-ui/core/Grid';
|
||||
import cx from 'classnames';
|
||||
|
||||
import styles from './style.module.css';
|
||||
|
||||
const Header = function(props) {
|
||||
return (
|
||||
<Grid container direction="row" justify="space-evenly" alignItems="center">
|
||||
<Grid item>
|
||||
<AddressBar />
|
||||
</Grid>
|
||||
<Grid item>
|
||||
<ZoomContainer />
|
||||
</Grid>
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
14
desktop-app/app/components/Header/index.test.js
Normal file
14
desktop-app/app/components/Header/index.test.js
Normal file
|
@ -0,0 +1,14 @@
|
|||
import React from 'react';
|
||||
import {shallow} from 'enzyme';
|
||||
import {expect} from 'chai';
|
||||
|
||||
import Header from './';
|
||||
|
||||
|
||||
describe('<Header />', () => {
|
||||
it('renders a h1 and BrowserZoom components', () => {
|
||||
const wrapper = shallow(<Header />);
|
||||
expect(wrapper.find('h1')).to.have.lengthOf(1);
|
||||
expect(wrapper.find('ZoomInput')).to.have.lengthOf(1);
|
||||
})
|
||||
});
|
5
desktop-app/app/components/Header/style.module.css
Normal file
5
desktop-app/app/components/Header/style.module.css
Normal file
|
@ -0,0 +1,5 @@
|
|||
.container {
|
||||
display: flex;
|
||||
padding: 2rem;
|
||||
justify-content: space-around;
|
||||
}
|
38
desktop-app/app/components/Renderer/index.js
Normal file
38
desktop-app/app/components/Renderer/index.js
Normal file
|
@ -0,0 +1,38 @@
|
|||
// @flow
|
||||
import React, {Component} from 'react';
|
||||
import cx from 'classnames';
|
||||
|
||||
import styles from './style.module.css';
|
||||
|
||||
class Renderer extends Component {
|
||||
render() {
|
||||
console.log('Renderer this.props', this.props);
|
||||
return (
|
||||
<div className={cx(styles.container)}>
|
||||
<h2>{this.props.device.name}</h2>
|
||||
<div
|
||||
className={cx(styles.deviceWrapper)}
|
||||
style={{
|
||||
width: this.props.device.width * this.props.zoomLevel,
|
||||
heigth: this.props.device.height * this.props.zoomLevel,
|
||||
}}
|
||||
>
|
||||
<webview
|
||||
className={cx(styles.device)}
|
||||
title={this.props.device.name}
|
||||
src={this.props.src}
|
||||
width={this.props.device.width}
|
||||
height={this.props.device.height}
|
||||
style={{
|
||||
width: this.props.device.width,
|
||||
height: this.props.device.height,
|
||||
transform: `scale(${this.props.zoomLevel})`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Renderer;
|
46
desktop-app/app/components/Renderer/index.test.js
Normal file
46
desktop-app/app/components/Renderer/index.test.js
Normal file
|
@ -0,0 +1,46 @@
|
|||
// @flow
|
||||
import React from 'react';
|
||||
import {shallow} from 'enzyme';
|
||||
import {expect} from 'chai';
|
||||
|
||||
import Renderer from './';
|
||||
|
||||
const testSrc = 'https://testUrl.com';
|
||||
const testDevice1 = {
|
||||
name: 'testDevice1',
|
||||
width: 100,
|
||||
height: 100,
|
||||
};
|
||||
|
||||
describe('<Renderer />', () => {
|
||||
it('Renders the header and the iframe', () => {
|
||||
const wrapper = shallow(<Renderer src={testSrc} device={testDevice1} />);
|
||||
expect(wrapper.find('iframe')).to.have.lengthOf(1);
|
||||
expect(wrapper.find('h2')).to.have.lengthOf(1);
|
||||
});
|
||||
|
||||
it('Renders the header with the device name', () => {
|
||||
const wrapper = shallow(<Renderer src={testSrc} device={testDevice1} />);
|
||||
expect(wrapper.find('h2').text()).to.equal(testDevice1.name);
|
||||
});
|
||||
|
||||
it('Renders the iframe with the given device dimensions', () => {
|
||||
const wrapper = shallow(<Renderer src={testSrc} device={testDevice1} />);
|
||||
expect(wrapper.find('iframe').prop('width')).to.equal(testDevice1.width);
|
||||
expect(wrapper.find('iframe').prop('height')).to.equal(testDevice1.height);
|
||||
});
|
||||
|
||||
it('Renders the iframe with the given url', () => {
|
||||
const wrapper = shallow(<Renderer src={testSrc} device={testDevice1} />);
|
||||
expect(wrapper.find('iframe').prop('src')).to.equal(testSrc);
|
||||
});
|
||||
|
||||
/*it('Calls the callback with a number value', () => {
|
||||
const onChange = sinon.spy();
|
||||
const wrapper = mount(<BrowserZoom onChange={onChange} />);
|
||||
wrapper.find('.MuiSlider-thumb').simulate('mousedown');
|
||||
wrapper.find('.MuiSlider-thumb').simulate('mouseup');
|
||||
console.log('spy.args', onChange.args);
|
||||
assert(onChange.calledWith(100));
|
||||
});*/
|
||||
});
|
10
desktop-app/app/components/Renderer/style.module.css
Normal file
10
desktop-app/app/components/Renderer/style.module.css
Normal file
|
@ -0,0 +1,10 @@
|
|||
.container {
|
||||
margin: 10px;
|
||||
}
|
||||
|
||||
.deviceWrapper {
|
||||
}
|
||||
|
||||
.device {
|
||||
transform-origin: top left;
|
||||
}
|
58
desktop-app/app/components/ZoomInput/index.js
Normal file
58
desktop-app/app/components/ZoomInput/index.js
Normal file
|
@ -0,0 +1,58 @@
|
|||
// @flow
|
||||
import React, {Component} from 'react';
|
||||
import Slider from '@material-ui/core/Slider';
|
||||
import ZoomInIcon from '@material-ui/icons/ZoomIn';
|
||||
import ZoomOutIcon from '@material-ui/icons/ZoomOut';
|
||||
import Grid from '@material-ui/core/Grid';
|
||||
|
||||
import styles from './styles.module.css';
|
||||
import './otherStyles.css';
|
||||
|
||||
const marks = [
|
||||
{
|
||||
value: 25,
|
||||
label: '25%',
|
||||
},
|
||||
{
|
||||
value: 50,
|
||||
label: '50%',
|
||||
},
|
||||
{
|
||||
value: 100,
|
||||
label: '100%',
|
||||
},
|
||||
{
|
||||
value: 200,
|
||||
label: '200%',
|
||||
},
|
||||
];
|
||||
|
||||
class BrowserZoom extends Component {
|
||||
render() {
|
||||
return (
|
||||
<div className={styles.zoomSlider}>
|
||||
<Grid container spacing={1}>
|
||||
<Grid item>
|
||||
<ZoomOutIcon />
|
||||
</Grid>
|
||||
<Grid item xs>
|
||||
<Slider
|
||||
defaultValue={this.props.value}
|
||||
valueLabelDisplay="auto"
|
||||
min={10}
|
||||
max={100}
|
||||
onChange={(_, value) =>
|
||||
this.props.onChange && this.props.onChange(value)
|
||||
}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item>
|
||||
<ZoomInIcon />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default BrowserZoom;
|
32
desktop-app/app/components/ZoomInput/index.test.js
Normal file
32
desktop-app/app/components/ZoomInput/index.test.js
Normal file
|
@ -0,0 +1,32 @@
|
|||
// @flow
|
||||
import React from 'react';
|
||||
import {shallow, mount} from 'enzyme';
|
||||
import {expect, assert} from 'chai';
|
||||
import sinon from 'sinon';
|
||||
|
||||
import BrowserZoom from './';
|
||||
import Slider from '@material-ui/core/Slider';
|
||||
|
||||
describe('<BrowserZoom />', () => {
|
||||
it('Renders label and the slider component ', () => {
|
||||
const wrapper = shallow(<BrowserZoom />);
|
||||
expect(wrapper.find(Slider)).to.have.lengthOf(1);
|
||||
});
|
||||
|
||||
it('Calls the callback on slider change', () => {
|
||||
const onChange = sinon.spy();
|
||||
const wrapper = mount(<BrowserZoom onChange={onChange} />);
|
||||
wrapper.find('.MuiSlider-thumb').simulate('mousedown');
|
||||
wrapper.find('.MuiSlider-thumb').simulate('mouseup');
|
||||
expect(onChange).to.have.property('callCount', 1);
|
||||
});
|
||||
|
||||
/*it('Calls the callback with a number value', () => {
|
||||
const onChange = sinon.spy();
|
||||
const wrapper = mount(<BrowserZoom onChange={onChange} />);
|
||||
wrapper.find('.MuiSlider-thumb').simulate('mousedown');
|
||||
wrapper.find('.MuiSlider-thumb').simulate('mouseup');
|
||||
console.log('spy.args', onChange.args);
|
||||
assert(onChange.calledWith(100));
|
||||
});*/
|
||||
});
|
4
desktop-app/app/components/ZoomInput/otherStyles.css
Normal file
4
desktop-app/app/components/ZoomInput/otherStyles.css
Normal file
|
@ -0,0 +1,4 @@
|
|||
.MuiSlider-markLabelActive,
|
||||
.MuiSlider-markLabel {
|
||||
color: white !important;
|
||||
}
|
8
desktop-app/app/components/ZoomInput/styles.module.css
Normal file
8
desktop-app/app/components/ZoomInput/styles.module.css
Normal file
|
@ -0,0 +1,8 @@
|
|||
.zoomSlider {
|
||||
width: 150px;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 1rem;
|
||||
margin-right: 1rem;
|
||||
}
|
15
desktop-app/app/constants/devices.js
Normal file
15
desktop-app/app/constants/devices.js
Normal file
|
@ -0,0 +1,15 @@
|
|||
// @flow
|
||||
export type Device = {
|
||||
id: number,
|
||||
width: number,
|
||||
height: number,
|
||||
name: string,
|
||||
};
|
||||
|
||||
export default [
|
||||
{id: 1, width: 320, height: 568, name: 'iPhone SE'},
|
||||
{id: 2, width: 375, height: 812, name: 'iPhone X'},
|
||||
{id: 3, width: 768, height: 1024, name: 'iPad'},
|
||||
{id: 4, width: 1024, height: 1366, name: 'iPad Pro'},
|
||||
{id: 5, width: 1440, height: 900, name: 'Laptop'},
|
||||
];
|
4
desktop-app/app/constants/routes.js
Normal file
4
desktop-app/app/constants/routes.js
Normal file
|
@ -0,0 +1,4 @@
|
|||
export default {
|
||||
HOME: '/',
|
||||
COUNTER: '/counter',
|
||||
};
|
|
@ -1,4 +0,0 @@
|
|||
{
|
||||
"HOME": "/",
|
||||
"COUNTER": "/counter"
|
||||
}
|
31
desktop-app/app/containers/AddressBar/index.js
Normal file
31
desktop-app/app/containers/AddressBar/index.js
Normal file
|
@ -0,0 +1,31 @@
|
|||
// @flow
|
||||
import React from 'react';
|
||||
import {connect} from 'react-redux';
|
||||
import {bindActionCreators} from 'redux';
|
||||
|
||||
import AddressInput from '../../components/AddressInput';
|
||||
import * as BrowserActions from '../../actions/browser';
|
||||
|
||||
const AddressBar = function(props) {
|
||||
return (
|
||||
<AddressInput
|
||||
address={props.browser.address}
|
||||
onChange={props.onAddressChange}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
function mapStateToProps(state) {
|
||||
return {
|
||||
browser: state.browser,
|
||||
};
|
||||
}
|
||||
|
||||
function mapDispatchToProps(dispatch) {
|
||||
return bindActionCreators(BrowserActions, dispatch);
|
||||
}
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(AddressBar);
|
|
@ -1,8 +1,9 @@
|
|||
// @flow
|
||||
import React from 'react';
|
||||
import React, {Fragment} from 'react';
|
||||
import {bindActionCreators} from 'redux';
|
||||
import {connect} from 'react-redux';
|
||||
import AddressBar from '../../components/AddressBar';
|
||||
import Header from '../../components/Header';
|
||||
import DevicePreviewerContainer from '../DevicePreviewerContainer';
|
||||
import * as BrowserActions from '../../actions/browser';
|
||||
|
||||
type Props = {};
|
||||
|
@ -13,9 +14,14 @@ class Browser extends React.Component<Props> {
|
|||
render() {
|
||||
console.log('Props', this.props);
|
||||
return (
|
||||
<div>
|
||||
<AddressBar onChange={this.props.onAddressChange} />
|
||||
</div>
|
||||
<Fragment>
|
||||
<div>
|
||||
<Header />
|
||||
</div>
|
||||
<div>
|
||||
<DevicePreviewerContainer />
|
||||
</div>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
21
desktop-app/app/containers/DevicePreviewerContainer/index.js
Normal file
21
desktop-app/app/containers/DevicePreviewerContainer/index.js
Normal file
|
@ -0,0 +1,21 @@
|
|||
// @flow
|
||||
import React from 'react';
|
||||
import {bindActionCreators} from 'redux';
|
||||
import {connect} from 'react-redux';
|
||||
import DevicesPreviewer from '../../components/DevicesPreviewer';
|
||||
import * as BrowserActions from '../../actions/browser';
|
||||
|
||||
function mapStateToProps(state) {
|
||||
return {
|
||||
browser: state.browser,
|
||||
};
|
||||
}
|
||||
|
||||
function mapDispatchToProps(dispatch) {
|
||||
return bindActionCreators(BrowserActions, dispatch);
|
||||
}
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(DevicesPreviewer);
|
31
desktop-app/app/containers/ZoomContainer/index.js
Normal file
31
desktop-app/app/containers/ZoomContainer/index.js
Normal file
|
@ -0,0 +1,31 @@
|
|||
// @flow
|
||||
import React from 'react';
|
||||
import {connect} from 'react-redux';
|
||||
import {bindActionCreators} from 'redux';
|
||||
|
||||
import ZoomInput from '../../components/ZoomInput';
|
||||
import * as BrowserActions from '../../actions/browser';
|
||||
|
||||
const ZoomController = props => {
|
||||
return (
|
||||
<ZoomInput
|
||||
value={props.browser.zoomLevel * 100}
|
||||
onChange={val => props.onZoomChange(val / 100)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
function mapStateToProps(state) {
|
||||
return {
|
||||
browser: state.browser,
|
||||
};
|
||||
}
|
||||
|
||||
function mapDispatchToProps(dispatch) {
|
||||
return bindActionCreators(BrowserActions, dispatch);
|
||||
}
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(ZoomController);
|
|
@ -1,11 +1,24 @@
|
|||
// @flow
|
||||
import {NEW_ADDRESS} from '../actions/browser';
|
||||
import {NEW_ADDRESS, NEW_ZOOM_LEVEL} from '../actions/browser';
|
||||
import type {Action} from './types';
|
||||
import devices from '../constants/devices';
|
||||
import type {Device} from '../constants/devices';
|
||||
|
||||
export default function counter(state: Object = {}, action: Action) {
|
||||
export type BrowserStateType = {
|
||||
devices: Array<Device>,
|
||||
address: string,
|
||||
zoomLevel: number,
|
||||
};
|
||||
|
||||
export default function counter(
|
||||
state: BrowserStateType = {devices, zoomLevel: 0.75},
|
||||
action: Action
|
||||
) {
|
||||
switch (action.type) {
|
||||
case NEW_ADDRESS:
|
||||
return {...state, address: action.address};
|
||||
case NEW_ZOOM_LEVEL:
|
||||
return {...state, zoomLevel: action.zoomLevel};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
import type { Dispatch as ReduxDispatch, Store as ReduxStore } from 'redux';
|
||||
import type {Dispatch as ReduxDispatch, Store as ReduxStore} from 'redux';
|
||||
|
||||
export type counterStateType = {
|
||||
+counter: number
|
||||
+counter: number,
|
||||
};
|
||||
|
||||
export type Action = {
|
||||
+type: string
|
||||
+type: string,
|
||||
};
|
||||
|
||||
export type GetState = () => counterStateType;
|
||||
|
|
Loading…
Reference in a new issue