forked from community/device-mgt-core
parent
5cbe25c0d7
commit
153b7f1385
@ -0,0 +1,61 @@
|
||||
/*
|
||||
* Copyright (c) 2019, Entgra (pvt) Ltd. (http://entgra.io) All Rights Reserved.
|
||||
*
|
||||
* Entgra (pvt) Ltd. licenses this file to you under the Apache License,
|
||||
* Version 2.0 (the "License"); you may not use this file except
|
||||
* in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import React from "react";
|
||||
import {Modal, Tabs} from "antd";
|
||||
import DeviceUninstall from "./DeviceUninstall";
|
||||
import UserUninstall from "./UserUninstall";
|
||||
import RoleUninstall from "./RoleUninstall";
|
||||
import GroupUninstall from "./GroupUninstall";
|
||||
|
||||
const { TabPane } = Tabs;
|
||||
|
||||
class AppUninstallModal extends React.Component{
|
||||
state={
|
||||
data:[]
|
||||
};
|
||||
render() {
|
||||
const {deviceType} = this.props;
|
||||
return (
|
||||
<div>
|
||||
<Modal
|
||||
title="Uninstall App"
|
||||
visible={this.props.visible}
|
||||
onCancel={this.props.onClose}
|
||||
footer={null}
|
||||
>
|
||||
<Tabs defaultActiveKey="device">
|
||||
<TabPane tab="Device" key="device">
|
||||
<DeviceUninstall deviceType={deviceType} onUninstall={this.props.onUninstall} uuid={this.props.uuid}/>
|
||||
</TabPane>
|
||||
<TabPane tab="User" key="user">
|
||||
<UserUninstall onUninstall={this.props.onUninstall} uuid={this.props.uuid}/>
|
||||
</TabPane>
|
||||
<TabPane tab="Role" key="role">
|
||||
<RoleUninstall onUninstall={this.props.onUninstall} uuid={this.props.uuid}/>
|
||||
</TabPane>
|
||||
<TabPane tab="Group" key="group">
|
||||
<GroupUninstall onUninstall={this.props.onUninstall} uuid={this.props.uuid}/>
|
||||
</TabPane>
|
||||
</Tabs>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default AppUninstallModal;
|
@ -0,0 +1,232 @@
|
||||
/*
|
||||
* Copyright (c) 2019, Entgra (pvt) Ltd. (http://entgra.io) All Rights Reserved.
|
||||
*
|
||||
* Entgra (pvt) Ltd. licenses this file to you under the Apache License,
|
||||
* Version 2.0 (the "License"); you may not use this file except
|
||||
* in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import axios from "axios";
|
||||
import {Button,Table, Typography} from "antd";
|
||||
import TimeAgo from 'javascript-time-ago'
|
||||
|
||||
// Load locale-specific relative date/time formatting rules.
|
||||
import en from 'javascript-time-ago/locale/en'
|
||||
import {withConfigContext} from "../../../../context/ConfigContext";
|
||||
import {handleApiError} from "../../../../js/Utils";
|
||||
|
||||
const {Text} = Typography;
|
||||
const columns = [
|
||||
{
|
||||
title: 'Device',
|
||||
dataIndex: 'name',
|
||||
fixed: 'left',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: 'Modal',
|
||||
dataIndex: 'deviceInfo',
|
||||
key: 'modal',
|
||||
render: deviceInfo => `${deviceInfo.vendor} ${deviceInfo.deviceModel}`
|
||||
// todo add filtering options
|
||||
},
|
||||
{
|
||||
title: 'Owner',
|
||||
dataIndex: 'enrolmentInfo',
|
||||
key: 'owner',
|
||||
render: enrolmentInfo => enrolmentInfo.owner
|
||||
// todo add filtering options
|
||||
},
|
||||
{
|
||||
title: 'Last Updated',
|
||||
dataIndex: 'enrolmentInfo',
|
||||
key: 'dateOfLastUpdate',
|
||||
render: (data) => {
|
||||
return (getTimeAgo(data.dateOfLastUpdate));
|
||||
}
|
||||
// todo add filtering options
|
||||
},
|
||||
{
|
||||
title: 'Status',
|
||||
dataIndex: 'enrolmentInfo',
|
||||
key: 'status',
|
||||
render: enrolmentInfo => enrolmentInfo.status
|
||||
// todo add filtering options
|
||||
},
|
||||
{
|
||||
title: 'Ownership',
|
||||
dataIndex: 'enrolmentInfo',
|
||||
key: 'ownership',
|
||||
render: enrolmentInfo => enrolmentInfo.ownership
|
||||
// todo add filtering options
|
||||
},
|
||||
{
|
||||
title: 'OS Version',
|
||||
dataIndex: 'deviceInfo',
|
||||
key: 'osVersion',
|
||||
render: deviceInfo => deviceInfo.osVersion
|
||||
// todo add filtering options
|
||||
},
|
||||
{
|
||||
title: 'IMEI',
|
||||
dataIndex: 'properties',
|
||||
key: 'imei',
|
||||
render: properties => {
|
||||
let imei = "not-found";
|
||||
for (let i = 0; i < properties.length; i++) {
|
||||
if (properties[i].name === "IMEI") {
|
||||
imei = properties[i].value;
|
||||
}
|
||||
}
|
||||
return imei;
|
||||
}
|
||||
// todo add filtering options
|
||||
},
|
||||
];
|
||||
|
||||
const getTimeAgo = (time) => {
|
||||
const timeAgo = new TimeAgo('en-US');
|
||||
return timeAgo.format(time);
|
||||
};
|
||||
|
||||
class DeviceUninstall extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
TimeAgo.addLocale(en);
|
||||
this.state = {
|
||||
data: [],
|
||||
pagination: {},
|
||||
loading: false,
|
||||
selectedRows: []
|
||||
};
|
||||
}
|
||||
|
||||
rowSelection = {
|
||||
onChange: (selectedRowKeys, selectedRows) => {
|
||||
this.setState({
|
||||
selectedRows: selectedRows
|
||||
})
|
||||
},
|
||||
getCheckboxProps: record => ({
|
||||
disabled: record.name === 'Disabled User', // Column configuration not to be checked
|
||||
name: record.name,
|
||||
}),
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
this.fetch();
|
||||
}
|
||||
|
||||
//fetch data from api
|
||||
fetch = (params = {}) => {
|
||||
const config = this.props.context;
|
||||
this.setState({loading: true});
|
||||
const {deviceType} = this.props;
|
||||
// get current page
|
||||
const currentPage = (params.hasOwnProperty("page")) ? params.page : 1;
|
||||
|
||||
const extraParams = {
|
||||
offset: 10 * (currentPage - 1), //calculate the offset
|
||||
limit: 10,
|
||||
status: "ACTIVE",
|
||||
};
|
||||
|
||||
if (deviceType !== 'ANY') {
|
||||
extraParams.type = deviceType;
|
||||
}
|
||||
|
||||
// note: encode with '%26' not '&'
|
||||
const encodedExtraParams = Object.keys(extraParams).map(key => key + '=' + extraParams[key]).join('&');
|
||||
|
||||
const uuid = this.props.uuid;
|
||||
axios.get(
|
||||
window.location.origin + config.serverConfig.invoker.uri + config.serverConfig.invoker.store + "/subscription/" + uuid + "/"+
|
||||
"/devices?" + encodedExtraParams,
|
||||
).then(res => {
|
||||
if (res.status === 200) {
|
||||
const pagination = {...this.state.pagination};
|
||||
this.setState({
|
||||
loading: false,
|
||||
data: res.data.data.devices,
|
||||
pagination,
|
||||
});
|
||||
}
|
||||
}).catch((error) => {
|
||||
handleApiError(error,"Error occurred while trying to load devices.");
|
||||
this.setState({loading: false});
|
||||
});
|
||||
};
|
||||
|
||||
handleTableChange = (pagination, filters, sorter) => {
|
||||
const pager = {...this.state.pagination};
|
||||
pager.current = pagination.current;
|
||||
this.setState({
|
||||
pagination: pager,
|
||||
});
|
||||
this.fetch({
|
||||
results: pagination.pageSize,
|
||||
page: pagination.current,
|
||||
sortField: sorter.field,
|
||||
sortOrder: sorter.order,
|
||||
...filters,
|
||||
});
|
||||
};
|
||||
|
||||
uninstall = () => {
|
||||
const {selectedRows} = this.state;
|
||||
const payload = [];
|
||||
selectedRows.map(device => {
|
||||
payload.push({
|
||||
id: device.deviceIdentifier,
|
||||
type: device.type
|
||||
});
|
||||
});
|
||||
this.props.onUninstall("devices", payload);
|
||||
};
|
||||
|
||||
render() {
|
||||
const {data, pagination, loading, selectedRows} = this.state;
|
||||
return (
|
||||
<div>
|
||||
<Text>
|
||||
Start uninstalling the application for devices by selecting the corresponding devices.
|
||||
Select uninstall to automatically start uninstalling the application for the respective devices.
|
||||
</Text>
|
||||
<Table
|
||||
style={{paddingTop: 20}}
|
||||
columns={columns}
|
||||
rowKey={record => record.deviceIdentifier}
|
||||
dataSource={data}
|
||||
pagination={{
|
||||
...pagination,
|
||||
size: "small",
|
||||
showTotal: (total, range) => `showing ${range[0]}-${range[1]} of ${total} devices`
|
||||
}}
|
||||
loading={loading}
|
||||
onChange={this.handleTableChange}
|
||||
rowSelection={this.rowSelection}
|
||||
scroll={{x: 1000}}
|
||||
/>
|
||||
<div style={{paddingTop: 10, textAlign: "right"}}>
|
||||
<Button disabled={selectedRows.length === 0} htmlType="button" type="primary"
|
||||
onClick={this.uninstall}>
|
||||
Uninstall
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default withConfigContext(DeviceUninstall);
|
@ -0,0 +1,125 @@
|
||||
/*
|
||||
* Copyright (c) 2019, Entgra (pvt) Ltd. (http://entgra.io) All Rights Reserved.
|
||||
*
|
||||
* Entgra (pvt) Ltd. licenses this file to you under the Apache License,
|
||||
* Version 2.0 (the "License"); you may not use this file except
|
||||
* in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import {Typography, Select, Spin, message, notification, Button} from "antd";
|
||||
import debounce from 'lodash.debounce';
|
||||
import axios from "axios";
|
||||
import {withConfigContext} from "../../../../context/ConfigContext";
|
||||
import {handleApiError} from "../../../../js/Utils";
|
||||
|
||||
const {Text} = Typography;
|
||||
const {Option} = Select;
|
||||
|
||||
class GroupUninstall extends React.Component {
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.lastFetchId = 0;
|
||||
this.fetchUser = debounce(this.fetchUser, 800);
|
||||
}
|
||||
|
||||
state = {
|
||||
data: [],
|
||||
value: [],
|
||||
fetching: false,
|
||||
};
|
||||
|
||||
fetchUser = value => {
|
||||
this.lastFetchId += 1;
|
||||
const fetchId = this.lastFetchId;
|
||||
const config = this.props.context;
|
||||
this.setState({data: [], fetching: true});
|
||||
|
||||
const uuid = this.props.uuid;
|
||||
|
||||
axios.get(
|
||||
window.location.origin+ config.serverConfig.invoker.uri + config.serverConfig.invoker.store+ "/subscription/" + uuid + "/"+
|
||||
"/GROUP?",
|
||||
|
||||
).then(res => {
|
||||
if (res.status === 200) {
|
||||
if (fetchId !== this.lastFetchId) {
|
||||
// for fetch callback order
|
||||
return;
|
||||
}
|
||||
|
||||
const data = res.data.data.deviceGroups.map(group => ({
|
||||
text: group,
|
||||
value: group,
|
||||
}));
|
||||
|
||||
this.setState({data, fetching: false});
|
||||
}
|
||||
|
||||
}).catch((error) => {
|
||||
handleApiError(error,"Error occurred while trying to load groups.");
|
||||
this.setState({fetching: false});
|
||||
});
|
||||
};
|
||||
|
||||
handleChange = value => {
|
||||
this.setState({
|
||||
value,
|
||||
data: [],
|
||||
fetching: false,
|
||||
});
|
||||
};
|
||||
|
||||
install = () =>{
|
||||
const {value} = this.state;
|
||||
const data = [];
|
||||
value.map(val=>{
|
||||
data.push(val.key);
|
||||
});
|
||||
this.props.onInstall("group",data);
|
||||
};
|
||||
|
||||
render() {
|
||||
|
||||
const {fetching, data, value} = this.state;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Text>Start uninstalling the application for one or more groups by entering the corresponding group name. Select uninstall to automatically start uninstalling the application for the respective device group/ groups.</Text>
|
||||
<br/>
|
||||
<br/>
|
||||
<Select
|
||||
mode="multiple"
|
||||
labelInValue
|
||||
value={value}
|
||||
placeholder="Search groups"
|
||||
notFoundContent={fetching ? <Spin size="small"/> : null}
|
||||
filterOption={false}
|
||||
onSearch={this.fetchUser}
|
||||
onChange={this.handleChange}
|
||||
style={{width: '100%'}}
|
||||
>
|
||||
{data.map(d => (
|
||||
<Option key={d.value}>{d.text}</Option>
|
||||
))}
|
||||
</Select>
|
||||
<div style={{paddingTop:10, textAlign:"right"}}>
|
||||
<Button disabled={value.length===0} htmlType="button" type="primary" onClick={this.install}>Install</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default withConfigContext(GroupUninstall);
|
@ -0,0 +1,124 @@
|
||||
/*
|
||||
* Copyright (c) 2019, Entgra (pvt) Ltd. (http://entgra.io) All Rights Reserved.
|
||||
*
|
||||
* Entgra (pvt) Ltd. licenses this file to you under the Apache License,
|
||||
* Version 2.0 (the "License"); you may not use this file except
|
||||
* in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import {Typography, Select, Spin, message, notification, Button} from "antd";
|
||||
import debounce from 'lodash.debounce';
|
||||
import axios from "axios";
|
||||
import {withConfigContext} from "../../../../context/ConfigContext";
|
||||
import {handleApiError} from "../../../../js/Utils";
|
||||
|
||||
const {Text} = Typography;
|
||||
const {Option} = Select;
|
||||
|
||||
class RoleUninstall extends React.Component {
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.lastFetchId = 0;
|
||||
this.fetchUser = debounce(this.fetchUser, 800);
|
||||
}
|
||||
|
||||
state = {
|
||||
data: [],
|
||||
value: [],
|
||||
fetching: false,
|
||||
};
|
||||
|
||||
fetchUser = value => {
|
||||
const config = this.props.context;
|
||||
this.lastFetchId += 1;
|
||||
const fetchId = this.lastFetchId;
|
||||
this.setState({data: [], fetching: true});
|
||||
|
||||
const uuid = this.props.uuid;
|
||||
|
||||
axios.get(
|
||||
window.location.origin+ config.serverConfig.invoker.uri + config.serverConfig.invoker.store+ "/subscription/" + uuid + "/"+
|
||||
"/ROLE?",
|
||||
).then(res => {
|
||||
if (res.status === 200) {
|
||||
if (fetchId !== this.lastFetchId) {
|
||||
// for fetch callback order
|
||||
return;
|
||||
}
|
||||
|
||||
const data = res.data.data.roles.map(role => ({
|
||||
text: role,
|
||||
value: role,
|
||||
}));
|
||||
|
||||
this.setState({data, fetching: false});
|
||||
}
|
||||
|
||||
}).catch((error) => {
|
||||
handleApiError(error,"Error occurred while trying to load roles.");
|
||||
this.setState({fetching: false});
|
||||
});
|
||||
};
|
||||
|
||||
handleChange = value => {
|
||||
this.setState({
|
||||
value,
|
||||
data: [],
|
||||
fetching: false,
|
||||
});
|
||||
};
|
||||
|
||||
install = () =>{
|
||||
const {value} = this.state;
|
||||
const data = [];
|
||||
value.map(val=>{
|
||||
data.push(val.key);
|
||||
});
|
||||
this.props.onInstall("role",data);
|
||||
};
|
||||
|
||||
render() {
|
||||
|
||||
const {fetching, data, value} = this.state;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Text>Start uninstalling the application for one or more roles by entering the corresponding role name. Select uninstall to automatically start uninstalling the application for the respective user role/roles.</Text>
|
||||
<br/>
|
||||
<br/>
|
||||
<Select
|
||||
mode="multiple"
|
||||
labelInValue
|
||||
value={value}
|
||||
placeholder="Search roles"
|
||||
notFoundContent={fetching ? <Spin size="small"/> : null}
|
||||
filterOption={false}
|
||||
onSearch={this.fetchUser}
|
||||
onChange={this.handleChange}
|
||||
style={{width: '100%'}}
|
||||
>
|
||||
{data.map(d => (
|
||||
<Option key={d.value}>{d.text}</Option>
|
||||
))}
|
||||
</Select>
|
||||
<div style={{paddingTop:10, textAlign:"right"}}>
|
||||
<Button disabled={value.length===0} htmlType="button" type="primary" onClick={this.install}>Install</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default withConfigContext(RoleUninstall);
|
@ -0,0 +1,122 @@
|
||||
/*
|
||||
* Copyright (c) 2019, Entgra (pvt) Ltd. (http://entgra.io) All Rights Reserved.
|
||||
*
|
||||
* Entgra (pvt) Ltd. licenses this file to you under the Apache License,
|
||||
* Version 2.0 (the "License"); you may not use this file except
|
||||
* in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import {Typography, Select, Spin, message, notification, Button} from "antd";
|
||||
import debounce from 'lodash.debounce';
|
||||
import axios from "axios";
|
||||
import {withConfigContext} from "../../../../context/ConfigContext";
|
||||
import {handleApiError} from "../../../../js/Utils";
|
||||
|
||||
const {Text} = Typography;
|
||||
const {Option} = Select;
|
||||
|
||||
class UserUninstall extends React.Component {
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.lastFetchId = 0;
|
||||
this.fetchUser = debounce(this.fetchUser, 800);
|
||||
}
|
||||
|
||||
state = {
|
||||
data: [],
|
||||
value: [],
|
||||
fetching: false,
|
||||
};
|
||||
|
||||
fetchUser = (value) => {
|
||||
const config = this.props.context;
|
||||
this.lastFetchId += 1;
|
||||
const fetchId = this.lastFetchId;
|
||||
this.setState({data: [], fetching: true});
|
||||
|
||||
const uuid = this.props.uuid;
|
||||
|
||||
axios.get(
|
||||
window.location.origin+ config.serverConfig.invoker.uri + config.serverConfig.invoker.store+ "/subscription/" + uuid + "/"+
|
||||
"/USER?",
|
||||
|
||||
).then(res => {
|
||||
if (res.status === 200) {
|
||||
if (fetchId !== this.lastFetchId) {
|
||||
// for fetch callback order
|
||||
return;
|
||||
}
|
||||
const data = res.data.data.users.map(user => ({
|
||||
text: user,
|
||||
value: user,
|
||||
}));
|
||||
|
||||
this.setState({data, fetching: false});
|
||||
}
|
||||
|
||||
}).catch((error) => {
|
||||
handleApiError(error,"Error occurred while trying to load users.");
|
||||
this.setState({fetching: false});
|
||||
});
|
||||
};
|
||||
|
||||
handleChange = value => {
|
||||
this.setState({
|
||||
value,
|
||||
data: [],
|
||||
fetching: false,
|
||||
});
|
||||
};
|
||||
|
||||
uninstall = () => {
|
||||
const {value} = this.state;
|
||||
const data = [];
|
||||
value.map(val => {
|
||||
data.push(val.key);
|
||||
});
|
||||
this.props.onUninstall("user", data);
|
||||
};
|
||||
|
||||
render() {
|
||||
const {fetching, data, value} = this.state;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Text>Start uninstalling the application for one or more users by entering the corresponding user name. Select uninstall to automatically start uninstalling the application for the respective user/users. </Text>
|
||||
<p>Select users</p>
|
||||
<Select
|
||||
mode="multiple"
|
||||
labelInValue
|
||||
value={value}
|
||||
placeholder="Enter the username"
|
||||
notFoundContent={fetching ? <Spin size="small"/> : null}
|
||||
filterOption={false}
|
||||
onSearch={this.fetchUser}
|
||||
onChange={this.handleChange}
|
||||
style={{width: '100%'}}
|
||||
>
|
||||
{data.map(d => (
|
||||
<Option key={d.value}>{d.text}</Option>
|
||||
))}
|
||||
</Select>
|
||||
<div style={{paddingTop: 10, textAlign: "right"}}>
|
||||
<Button disabled={value.length===0} htmlType="button" type="primary" onClick={this.uninstall}>Uninstall</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default withConfigContext(UserUninstall);
|
Loading…
Reference in new issue