mirror of
https://gerrit.wikimedia.org/r/mediawiki/extensions/DiscussionTools
synced 2024-09-23 18:38:18 +00:00
Switching between VE & wikitext (plain)
Bug: T234403 Change-Id: I84a639ab8e31ec9f683a63b0ecf184f426df09dd
This commit is contained in:
parent
d18496f025
commit
56d8721fd7
|
@ -91,6 +91,8 @@
|
|||
"discussiontools-replywidget-anon-warning",
|
||||
"discussiontools-replywidget-cancel",
|
||||
"discussiontools-replywidget-feedback",
|
||||
"discussiontools-replywidget-mode-source",
|
||||
"discussiontools-replywidget-mode-visual",
|
||||
"discussiontools-replywidget-placeholder-reply",
|
||||
"discussiontools-replywidget-preview",
|
||||
"discussiontools-replywidget-reply",
|
||||
|
@ -98,6 +100,7 @@
|
|||
"discussiontools-replywidget-transcluded"
|
||||
],
|
||||
"dependencies": [
|
||||
"oojs-ui-widgets",
|
||||
"ext.discussionTools.init",
|
||||
"mediawiki.widgets.AbandonEditDialog"
|
||||
],
|
||||
|
@ -112,7 +115,6 @@
|
|||
],
|
||||
"dependencies": [
|
||||
"ext.discussionTools.ReplyWidget",
|
||||
"oojs-ui-core",
|
||||
"mediawiki.editfont.styles",
|
||||
"mediawiki.user",
|
||||
"mediawiki.jqueryMsg"
|
||||
|
|
|
@ -171,7 +171,18 @@ CommentController.prototype.setup = function () {
|
|||
$( commentController.newListItem ).text( mw.msg( 'discussiontools-replywidget-loading' ) );
|
||||
}
|
||||
|
||||
commentController.replyWidgetPromise.then( this.setupReplyWidget.bind( this ) );
|
||||
commentController.replyWidgetPromise.then( function ( replyWidget ) {
|
||||
if ( !commentController.newListItem ) {
|
||||
// On subsequent loads, there's no list item yet, so create one now
|
||||
commentController.newListItem = modifier.addListItem( commentController.comment );
|
||||
}
|
||||
$( commentController.newListItem ).empty().append( replyWidget.$element );
|
||||
|
||||
commentController.setupReplyWidget( replyWidget, true );
|
||||
|
||||
logger( { action: 'ready' } );
|
||||
logger( { action: 'loaded' } );
|
||||
} );
|
||||
};
|
||||
|
||||
CommentController.prototype.getReplyWidgetClass = function ( visual ) {
|
||||
|
@ -191,27 +202,24 @@ CommentController.prototype.createReplyWidget = function ( parsoidData, visual )
|
|||
var commentController = this;
|
||||
|
||||
return this.getReplyWidgetClass( visual ).then( function ( ReplyWidget ) {
|
||||
commentController.replyWidget = new ReplyWidget( commentController, parsoidData, {
|
||||
return new ReplyWidget( commentController, parsoidData, {
|
||||
input: {
|
||||
authors: parser.getAuthors( commentController.thread )
|
||||
}
|
||||
} );
|
||||
commentController.replyWidget.connect( commentController, { teardown: 'teardown' } );
|
||||
} );
|
||||
};
|
||||
|
||||
CommentController.prototype.setupReplyWidget = function () {
|
||||
if ( !this.newListItem ) {
|
||||
// On subsequent loads, there's no list item yet, so create one now
|
||||
this.newListItem = modifier.addListItem( this.comment );
|
||||
}
|
||||
$( this.newListItem ).empty().append( this.replyWidget.$element );
|
||||
this.replyWidget.setup();
|
||||
this.replyWidget.scrollElementIntoView( { padding: scrollPadding } );
|
||||
this.replyWidget.focus();
|
||||
CommentController.prototype.setupReplyWidget = function ( replyWidget, scrollIntoView ) {
|
||||
replyWidget.connect( this, { teardown: 'teardown' } );
|
||||
|
||||
logger( { action: 'ready' } );
|
||||
logger( { action: 'loaded' } );
|
||||
replyWidget.setup();
|
||||
if ( scrollIntoView ) {
|
||||
replyWidget.scrollElementIntoView( { padding: scrollPadding } );
|
||||
}
|
||||
replyWidget.focus();
|
||||
|
||||
this.replyWidget = replyWidget;
|
||||
};
|
||||
|
||||
CommentController.prototype.teardown = function () {
|
||||
|
@ -363,4 +371,82 @@ CommentController.prototype.save = function ( parsoidData ) {
|
|||
} );
|
||||
};
|
||||
|
||||
CommentController.prototype.switchToWikitext = function () {
|
||||
var wikitextPromise,
|
||||
oldWidget = this.replyWidget,
|
||||
pageData = oldWidget.parsoidData.pageData,
|
||||
target = oldWidget.replyBodyWidget.target,
|
||||
commentController = this;
|
||||
|
||||
wikitextPromise = target.getWikitextFragment(
|
||||
target.getSurface().getModel().getDocument(),
|
||||
{
|
||||
page: pageData.pageName,
|
||||
baserevid: pageData.oldId,
|
||||
etag: pageData.etag
|
||||
}
|
||||
);
|
||||
this.replyWidgetPromise = this.createReplyWidget( oldWidget.parsoidData, false );
|
||||
|
||||
return $.when( wikitextPromise, this.replyWidgetPromise ).then( function ( wikitext, replyWidget ) {
|
||||
// Swap out the DOM nodes
|
||||
oldWidget.$element.replaceWith( replyWidget.$element );
|
||||
|
||||
// Teardown the old widget
|
||||
oldWidget.disconnect( commentController );
|
||||
oldWidget.teardown();
|
||||
|
||||
replyWidget.setValue( controller.sanitizeWikitextLinebreaks( wikitext ) );
|
||||
commentController.setupReplyWidget( replyWidget );
|
||||
} );
|
||||
};
|
||||
|
||||
CommentController.prototype.switchToVisual = function () {
|
||||
var parsePromise,
|
||||
oldWidget = this.replyWidget,
|
||||
wikitext = oldWidget.getValue(),
|
||||
pageData = oldWidget.parsoidData.pageData,
|
||||
commentController = this;
|
||||
|
||||
wikitext = controller.sanitizeWikitextLinebreaks( wikitext ).trim();
|
||||
if ( wikitext ) {
|
||||
wikitext = wikitext.split( '\n' ).map( function ( line ) {
|
||||
return ':' + line;
|
||||
} ).join( '\n' );
|
||||
|
||||
// Based on ve.init.mw.Target#parseWikitextFragment
|
||||
parsePromise = ( new mw.Api() ).post( {
|
||||
action: 'visualeditor',
|
||||
paction: 'parsefragment',
|
||||
page: pageData.pageName,
|
||||
wikitext: wikitext
|
||||
} ).then( function ( response ) {
|
||||
return response && response.visualeditor.content;
|
||||
} );
|
||||
} else {
|
||||
parsePromise = $.Deferred().resolve( '' ).promise();
|
||||
}
|
||||
this.replyWidgetPromise = this.createReplyWidget( oldWidget.parsoidData, true );
|
||||
|
||||
return $.when( parsePromise, this.replyWidgetPromise ).then( function ( html, replyWidget ) {
|
||||
var doc;
|
||||
|
||||
// Swap out the DOM nodes
|
||||
oldWidget.$element.replaceWith( replyWidget.$element );
|
||||
|
||||
// Teardown the old widget
|
||||
oldWidget.disconnect( commentController );
|
||||
oldWidget.teardown();
|
||||
|
||||
if ( html ) {
|
||||
doc = replyWidget.replyBodyWidget.target.parseDocument( html );
|
||||
// Unindent list
|
||||
modifier.unwrapList( doc.body.children[ 0 ] );
|
||||
}
|
||||
|
||||
replyWidget.setValue( doc );
|
||||
commentController.setupReplyWidget( replyWidget );
|
||||
} );
|
||||
};
|
||||
|
||||
module.exports = CommentController;
|
||||
|
|
|
@ -47,6 +47,25 @@ function ReplyWidget( commentController, parsoidData, config ) {
|
|||
framed: false
|
||||
} );
|
||||
|
||||
this.modeTabSelect = new OO.ui.TabSelectWidget( {
|
||||
classes: [ 'dt-ui-replyWidget-modeTabs' ],
|
||||
items: [
|
||||
new OO.ui.TabOptionWidget( {
|
||||
label: mw.msg( 'discussiontools-replywidget-mode-visual' ),
|
||||
data: 'visual'
|
||||
} ),
|
||||
new OO.ui.TabOptionWidget( {
|
||||
label: mw.msg( 'discussiontools-replywidget-mode-source' ),
|
||||
data: 'source'
|
||||
} )
|
||||
],
|
||||
framed: false
|
||||
} );
|
||||
|
||||
this.modeTabSelect.connect( this, {
|
||||
choose: 'onModeTabSelectChoose'
|
||||
} );
|
||||
|
||||
this.$preview = $( '<div>' ).addClass( 'dt-ui-replyWidget-preview' ).attr( 'data-label', mw.msg( 'discussiontools-replywidget-preview' ) );
|
||||
this.$actionsWrapper = $( '<div>' ).addClass( 'dt-ui-replyWidget-actionsWrapper' );
|
||||
this.$actions = $( '<div>' ).addClass( 'dt-ui-replyWidget-actions' ).append(
|
||||
|
@ -87,6 +106,7 @@ function ReplyWidget( commentController, parsoidData, config ) {
|
|||
|
||||
// Initialization
|
||||
this.$element.addClass( 'dt-ui-replyWidget' ).append(
|
||||
this.modeTabSelect.$element,
|
||||
this.replyBodyWidget.$element,
|
||||
this.$preview,
|
||||
this.$actionsWrapper
|
||||
|
@ -124,9 +144,6 @@ function ReplyWidget( commentController, parsoidData, config ) {
|
|||
} );
|
||||
|
||||
this.initAutoSave();
|
||||
|
||||
// Init preview and button state
|
||||
this.onInputChange();
|
||||
}
|
||||
|
||||
/* Inheritance */
|
||||
|
@ -137,10 +154,27 @@ OO.inheritClass( ReplyWidget, OO.ui.Widget );
|
|||
|
||||
ReplyWidget.prototype.createReplyBodyWidget = null;
|
||||
|
||||
/**
|
||||
* Focus the widget
|
||||
*
|
||||
* @method
|
||||
* @chainable
|
||||
* @return {ReplyWidget}
|
||||
*/
|
||||
ReplyWidget.prototype.focus = null;
|
||||
|
||||
ReplyWidget.prototype.getValue = null;
|
||||
|
||||
/**
|
||||
* Set the reply widget's value
|
||||
*
|
||||
* @method
|
||||
* @chainable
|
||||
* @param {Mixed} value Value
|
||||
* @return {ReplyWidget}
|
||||
*/
|
||||
ReplyWidget.prototype.setValue = null;
|
||||
|
||||
ReplyWidget.prototype.isEmpty = null;
|
||||
|
||||
ReplyWidget.prototype.getMode = null;
|
||||
|
@ -164,10 +198,48 @@ ReplyWidget.prototype.setPending = function ( pending ) {
|
|||
}
|
||||
};
|
||||
|
||||
ReplyWidget.prototype.setup = function () {
|
||||
this.bindBeforeUnloadHandler();
|
||||
ReplyWidget.prototype.onModeTabSelectChoose = function ( option ) {
|
||||
var promise,
|
||||
widget = this;
|
||||
this.setPending( true );
|
||||
this.modeTabSelect.setDisabled( true );
|
||||
switch ( option.getData() ) {
|
||||
case 'source':
|
||||
promise = this.commentController.switchToWikitext();
|
||||
break;
|
||||
case 'visual':
|
||||
promise = this.commentController.switchToVisual();
|
||||
break;
|
||||
}
|
||||
promise.then( null, function () {
|
||||
// Switch failed, restore previous tab selection
|
||||
widget.modeTabSelect.selectItemByData( option.getData() === 'source' ? 'visual' : 'source' );
|
||||
} ).always( function () {
|
||||
widget.setPending( false );
|
||||
widget.modeTabSelect.setDisabled( false );
|
||||
} );
|
||||
};
|
||||
|
||||
/**
|
||||
* Setup the widget
|
||||
*
|
||||
* @chainable
|
||||
* @return {ReplyWidget}
|
||||
*/
|
||||
ReplyWidget.prototype.setup = function () {
|
||||
this.bindBeforeUnloadHandler();
|
||||
this.modeTabSelect.selectItemByData( this.getMode() );
|
||||
// Init preview and button state
|
||||
this.onInputChange();
|
||||
return this;
|
||||
};
|
||||
|
||||
/**
|
||||
* Try to teardown the widget, prompting the user if unsaved changes will be lost.
|
||||
*
|
||||
* @chainable
|
||||
* @return {ReplyWidget}
|
||||
*/
|
||||
ReplyWidget.prototype.tryTeardown = function () {
|
||||
var promise,
|
||||
widget = this;
|
||||
|
@ -196,13 +268,21 @@ ReplyWidget.prototype.tryTeardown = function () {
|
|||
promise.then( function () {
|
||||
widget.teardown();
|
||||
} );
|
||||
return this;
|
||||
};
|
||||
|
||||
/**
|
||||
* Teardown the widget
|
||||
*
|
||||
* @chainable
|
||||
* @return {ReplyWidget}
|
||||
*/
|
||||
ReplyWidget.prototype.teardown = function () {
|
||||
this.unbindBeforeUnloadHandler();
|
||||
this.clear();
|
||||
this.$preview.empty();
|
||||
this.emit( 'teardown' );
|
||||
return this;
|
||||
};
|
||||
|
||||
ReplyWidget.prototype.onKeyDown = function ( e ) {
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
.dt-ui-replyWidget {
|
||||
margin-bottom: 1em;
|
||||
position: relative;
|
||||
clear: both;
|
||||
|
||||
> .oo-ui-textInputWidget {
|
||||
max-width: none;
|
||||
|
@ -20,6 +22,17 @@
|
|||
}
|
||||
}
|
||||
|
||||
&-modeTabs {
|
||||
box-shadow: none;
|
||||
height: 3em;
|
||||
}
|
||||
|
||||
&-ve .dt-ui-replyWidget-modeTabs {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
&-actionsWrapper {
|
||||
display: flex;
|
||||
margin-top: 0.5em;
|
||||
|
|
|
@ -14,6 +14,8 @@ function ReplyWidgetPlain() {
|
|||
|
||||
// Events
|
||||
this.replyBodyWidget.connect( this, { change: this.onInputChangeThrottled } );
|
||||
|
||||
this.$element.addClass( 'dt-ui-replyWidget-plain' );
|
||||
}
|
||||
|
||||
/* Inheritance */
|
||||
|
@ -38,13 +40,15 @@ ReplyWidgetPlain.prototype.createReplyBodyWidget = function ( config ) {
|
|||
|
||||
ReplyWidgetPlain.prototype.focus = function () {
|
||||
this.replyBodyWidget.focus();
|
||||
|
||||
return this;
|
||||
};
|
||||
|
||||
ReplyWidgetPlain.prototype.clear = function () {
|
||||
// Parent method
|
||||
ReplyWidgetPlain.super.prototype.clear.apply( this, arguments );
|
||||
|
||||
this.replyBodyWidget.setValue( '' );
|
||||
this.setValue( '' );
|
||||
};
|
||||
|
||||
ReplyWidgetPlain.prototype.isEmpty = function () {
|
||||
|
@ -79,6 +83,15 @@ ReplyWidgetPlain.prototype.setup = function () {
|
|||
ReplyWidgetPlain.super.prototype.setup.call( this );
|
||||
|
||||
this.replyBodyWidget.once( 'change', this.onFirstTransaction.bind( this ) );
|
||||
|
||||
return this;
|
||||
};
|
||||
|
||||
ReplyWidgetPlain.prototype.teardown = function () {
|
||||
this.replyBodyWidget.off( 'change' );
|
||||
|
||||
// Parent method
|
||||
return ReplyWidgetPlain.super.prototype.teardown.call( this );
|
||||
};
|
||||
|
||||
ReplyWidgetPlain.prototype.onKeyDown = function ( e ) {
|
||||
|
@ -108,4 +121,9 @@ ReplyWidgetPlain.prototype.getValue = function () {
|
|||
return this.replyBodyWidget.getValue();
|
||||
};
|
||||
|
||||
ReplyWidgetPlain.prototype.setValue = function ( value ) {
|
||||
this.replyBodyWidget.setValue( value );
|
||||
return this;
|
||||
};
|
||||
|
||||
module.exports = ReplyWidgetPlain;
|
||||
|
|
|
@ -17,7 +17,17 @@ function ReplyWidgetVisual() {
|
|||
ReplyWidgetVisual.super.apply( this, arguments );
|
||||
|
||||
// TODO: Use user preference
|
||||
this.defaultMode = 'source';
|
||||
this.defaultMode = 'visual';
|
||||
this.initialValue = null;
|
||||
|
||||
// Events
|
||||
this.replyBodyWidget.connect( this, {
|
||||
change: 'onInputChangeThrottled',
|
||||
submit: 'onReplyClick'
|
||||
} );
|
||||
|
||||
// TODO: Rename this widget to VE, as it isn't just visual mode
|
||||
this.$element.addClass( 'dt-ui-replyWidget-ve' );
|
||||
}
|
||||
|
||||
/* Inheritance */
|
||||
|
@ -40,7 +50,16 @@ ReplyWidgetVisual.prototype.getValue = function () {
|
|||
}
|
||||
};
|
||||
|
||||
// TODO: Implement getMode to get current mode from surface
|
||||
ReplyWidgetVisual.prototype.setValue = function ( value ) {
|
||||
var target = this.replyBodyWidget.target;
|
||||
if ( target && target.getSurface() ) {
|
||||
target.setDocument( value );
|
||||
} else {
|
||||
// #setup hasn't been called yet, just save the value for when it is
|
||||
this.initialValue = value;
|
||||
}
|
||||
return this;
|
||||
};
|
||||
|
||||
ReplyWidgetVisual.prototype.clear = function () {
|
||||
// Parent method
|
||||
|
@ -65,20 +84,22 @@ ReplyWidgetVisual.prototype.initAutoSave = function () {
|
|||
};
|
||||
|
||||
ReplyWidgetVisual.prototype.setup = function () {
|
||||
var surface;
|
||||
this.replyBodyWidget.setDocument( this.initialValue || '<p></p>' );
|
||||
this.initialValue = null;
|
||||
|
||||
// Parent method
|
||||
ReplyWidgetVisual.super.prototype.setup.call( this );
|
||||
|
||||
this.replyBodyWidget.setDocument( '<p></p>' );
|
||||
this.replyBodyWidget.once( 'change', this.onFirstTransaction.bind( this ) );
|
||||
|
||||
surface = this.replyBodyWidget.target.getSurface();
|
||||
return this;
|
||||
};
|
||||
|
||||
// Events
|
||||
surface.getModel().getDocument()
|
||||
.connect( this, { transact: this.onInputChangeThrottled } )
|
||||
.once( 'transact', this.onFirstTransaction.bind( this ) );
|
||||
surface.connect( this, { submit: 'onReplyClick' } );
|
||||
ReplyWidgetVisual.prototype.teardown = function () {
|
||||
this.replyBodyWidget.off( 'change' );
|
||||
|
||||
// Parent method
|
||||
return ReplyWidgetVisual.super.prototype.teardown.call( this );
|
||||
};
|
||||
|
||||
ReplyWidgetVisual.prototype.focus = function () {
|
||||
|
@ -87,17 +108,18 @@ ReplyWidgetVisual.prototype.focus = function () {
|
|||
targetWidget.getSurface().getModel().selectLastContentOffset();
|
||||
targetWidget.focus();
|
||||
} );
|
||||
|
||||
return this;
|
||||
};
|
||||
|
||||
ReplyWidgetVisual.prototype.setPending = function ( pending ) {
|
||||
ReplyWidgetVisual.super.prototype.setPending.call( this, pending );
|
||||
|
||||
if ( pending ) {
|
||||
// TODO
|
||||
// this.replyBodyWidget.pushPending();
|
||||
this.replyBodyWidget.pushPending();
|
||||
this.replyBodyWidget.setReadOnly( true );
|
||||
} else {
|
||||
// this.replyBodyWidget.popPending();
|
||||
this.replyBodyWidget.popPending();
|
||||
this.replyBodyWidget.setReadOnly( false );
|
||||
}
|
||||
};
|
||||
|
|
|
@ -194,6 +194,47 @@ function removeListItem( node ) {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unwrap a top level list, converting list item text to paragraphs
|
||||
*
|
||||
* Assumes that the list is the only child of it's parent.
|
||||
*
|
||||
* @param {HTMLElement} list List element (dl/ol/ul)
|
||||
*/
|
||||
function unwrapList( list ) {
|
||||
var p,
|
||||
doc = list.ownerDocument,
|
||||
container = list.parentNode;
|
||||
|
||||
container.removeChild( list );
|
||||
while ( list.firstChild ) {
|
||||
if ( list.firstChild.nodeType === Node.ELEMENT_NODE ) {
|
||||
// Move <dd> contents to <p>
|
||||
p = doc.createElement( 'p' );
|
||||
while ( list.firstChild.firstChild ) {
|
||||
// If contents is a block element, place outside the paragraph
|
||||
// and start a new paragraph after
|
||||
if ( ve.isBlockElement( list.firstChild.firstChild ) ) {
|
||||
if ( p.firstChild ) {
|
||||
container.appendChild( p );
|
||||
}
|
||||
container.appendChild( list.firstChild.firstChild );
|
||||
p = doc.createElement( 'p' );
|
||||
} else {
|
||||
p.appendChild( list.firstChild.firstChild );
|
||||
}
|
||||
}
|
||||
if ( p.firstChild ) {
|
||||
container.appendChild( p );
|
||||
}
|
||||
list.removeChild( list.firstChild );
|
||||
} else {
|
||||
// Text node / comment node, probably empty
|
||||
container.appendChild( list.firstChild );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add another list item after the given one.
|
||||
*
|
||||
|
@ -221,5 +262,6 @@ module.exports = {
|
|||
addListItem: addListItem,
|
||||
removeListItem: removeListItem,
|
||||
addSiblingListItem: addSiblingListItem,
|
||||
unwrapList: unwrapList,
|
||||
createWikitextNode: createWikitextNode
|
||||
};
|
||||
|
|
|
@ -207,3 +207,53 @@ QUnit.test( '#addReplyLink', function ( assert ) {
|
|||
);
|
||||
}
|
||||
} );
|
||||
|
||||
QUnit.test( '#unwrapList', function ( assert ) {
|
||||
var cases;
|
||||
|
||||
cases = [
|
||||
{
|
||||
name: 'empty',
|
||||
html: '<dl><dd></dd></dl>',
|
||||
expected: ''
|
||||
},
|
||||
{
|
||||
name: 'single item',
|
||||
html: '<dl><dd>Foo</dd></dl>',
|
||||
expected: '<p>Foo</p>'
|
||||
},
|
||||
{
|
||||
name: 'single block item',
|
||||
html: '<dl><dd><pre>Foo</pre></dd></dl>',
|
||||
expected: '<pre>Foo</pre>'
|
||||
},
|
||||
{
|
||||
name: 'mixed inline and block',
|
||||
html: '<dl><dd>Foo <pre>Bar</pre> Baz</dd></dl>',
|
||||
expected: '<p>Foo </p><pre>Bar</pre><p> Baz</p>'
|
||||
},
|
||||
{
|
||||
name: 'multiple items',
|
||||
html: '<dl><dd>Foo</dd><dd>Bar</dd></dl>',
|
||||
expected: '<p>Foo</p><p>Bar</p>'
|
||||
},
|
||||
{
|
||||
name: 'nested list',
|
||||
html: '<dl><dd>Foo<dl><dd>Bar</dd></dl></dd></dl>',
|
||||
expected: '<p>Foo</p><dl><dd>Bar</dd></dl>'
|
||||
}
|
||||
];
|
||||
|
||||
cases.forEach( function ( caseItem ) {
|
||||
var container = document.createElement( 'div' );
|
||||
|
||||
container.innerHTML = caseItem.html;
|
||||
modifier.unwrapList( container.firstChild );
|
||||
|
||||
assert.strictEqual(
|
||||
container.innerHTML.trim(),
|
||||
caseItem.expected,
|
||||
caseItem.name
|
||||
);
|
||||
} );
|
||||
} );
|
||||
|
|
Loading…
Reference in a new issue