MOBILE-3394 utils: Improve alerts and prompts
parent
27ceabde62
commit
f3e5c21b6e
|
@ -277,7 +277,11 @@ export class CoreLoginSitePage {
|
|||
}
|
||||
];
|
||||
|
||||
this.domUtils.showAlertWithButtons(this.translate.instant('core.cannotconnect'), message, buttons);
|
||||
this.domUtils.showAlertWithOptions({
|
||||
title: this.translate.instant('core.cannotconnect'),
|
||||
message,
|
||||
buttons,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -369,10 +373,11 @@ export class CoreLoginSitePage {
|
|||
*/
|
||||
showInstructionsAndScanQR(): void {
|
||||
// Show some instructions first.
|
||||
this.domUtils.showAlertWithButtons(
|
||||
this.translate.instant('core.login.faqwhereisqrcode'),
|
||||
this.translate.instant('core.login.faqwhereisqrcodeanswer', {$image: CoreLoginHelperProvider.FAQ_QRCODE_IMAGE_HTML}),
|
||||
[
|
||||
this.domUtils.showAlertWithOptions({
|
||||
title: this.translate.instant('core.login.faqwhereisqrcode'),
|
||||
message: this.translate.instant('core.login.faqwhereisqrcodeanswer',
|
||||
{$image: CoreLoginHelperProvider.FAQ_QRCODE_IMAGE_HTML}),
|
||||
buttons: [
|
||||
{
|
||||
text: this.translate.instant('core.cancel'),
|
||||
role: 'cancel'
|
||||
|
@ -383,8 +388,8 @@ export class CoreLoginSitePage {
|
|||
this.scanQR();
|
||||
}
|
||||
},
|
||||
]
|
||||
);
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -0,0 +1,14 @@
|
|||
<ion-header>
|
||||
<ion-navbar>
|
||||
<ion-title>{{ title }}</ion-title>
|
||||
</ion-navbar>
|
||||
</ion-header>
|
||||
<ion-content>
|
||||
<div class="prompt-message"><core-format-text [text]="message"></core-format-text></div>
|
||||
<ion-textarea rows="1" core-auto-rows name="feedback" [attr.aria-multiline]="true" [(ngModel)]="text" [placeholder]="placeholder"></ion-textarea>
|
||||
<div class="prompt-button-group">
|
||||
<button *ngFor="let button of buttons" ion-button="prompt-button" (click)="buttonClicked(button)" [ngClass]="button.cssClass">
|
||||
{{ button.text }}
|
||||
</button>
|
||||
</div>
|
||||
</ion-content>
|
|
@ -0,0 +1,36 @@
|
|||
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||
//
|
||||
// Licensed 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 { NgModule } from '@angular/core';
|
||||
import { IonicPageModule } from 'ionic-angular';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { CoreViewerTextAreaPage } from './textarea';
|
||||
import { CoreComponentsModule } from '@components/components.module';
|
||||
import { CoreDirectivesModule } from '@directives/directives.module';
|
||||
|
||||
/**
|
||||
* Module to lazy load the page.
|
||||
*/
|
||||
@NgModule({
|
||||
declarations: [
|
||||
CoreViewerTextAreaPage
|
||||
],
|
||||
imports: [
|
||||
CoreComponentsModule,
|
||||
CoreDirectivesModule,
|
||||
IonicPageModule.forChild(CoreViewerTextAreaPage),
|
||||
TranslateModule.forChild()
|
||||
]
|
||||
})
|
||||
export class CoreViewerTextAreaPageModule {}
|
|
@ -0,0 +1,187 @@
|
|||
$core-modal-promt-min-width: 320px;
|
||||
|
||||
ion-app.app-root ion-modal.core-modal-prompt {
|
||||
/* Some styles have been copied from ionic alert component. */
|
||||
@include position(0, 0, 0, 0);
|
||||
position: absolute;
|
||||
z-index: $z-index-overlay;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
contain: strict;
|
||||
|
||||
ion-backdrop {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.header {
|
||||
&::after {
|
||||
background: none;
|
||||
}
|
||||
.toolbar-background {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.prompt-button-group {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
.prompt-button {
|
||||
@include margin(0);
|
||||
|
||||
z-index: 0;
|
||||
display: block;
|
||||
|
||||
font-size: $alert-button-font-size;
|
||||
line-height: $alert-button-line-height;
|
||||
}
|
||||
}
|
||||
|
||||
ion-textarea {
|
||||
@include placeholder($alert-input-placeholder-color);
|
||||
@include padding($alert-md-message-padding-top, $alert-md-message-padding-end, $alert-md-message-padding-bottom, $alert-md-message-padding-start);
|
||||
border: 0;
|
||||
background: inherit;
|
||||
|
||||
textarea {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.prompt-message {
|
||||
@include deprecated-variable(padding, $alert-md-message-padding) {
|
||||
@include padding($alert-md-message-padding-top, $alert-md-message-padding-end, $alert-md-message-padding-bottom, $alert-md-message-padding-start);
|
||||
}
|
||||
}
|
||||
|
||||
.modal-wrapper {
|
||||
z-index: $z-index-overlay-wrapper;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: $core-modal-promt-min-width;
|
||||
max-height: $alert-max-height;
|
||||
opacity: 0;
|
||||
contain: content;
|
||||
height: auto;
|
||||
|
||||
page-core-viewer-textarea,
|
||||
ion-content,
|
||||
.fixed-content,
|
||||
.scroll-content {
|
||||
position: relative;
|
||||
background: $white;
|
||||
overflow: hidden;
|
||||
}
|
||||
.fixed-content {
|
||||
display: none;
|
||||
}
|
||||
.scroll-content {
|
||||
padding: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.content-md .prompt-button-group {
|
||||
flex-wrap: $alert-md-button-group-flex-wrap;
|
||||
justify-content: $alert-md-button-group-justify-content;
|
||||
|
||||
@include deprecated-variable(padding, $alert-md-button-group-padding) {
|
||||
@include padding($alert-md-button-group-padding-top, $alert-md-button-group-padding-end, $alert-md-button-group-padding-bottom, $alert-md-button-group-padding-start);
|
||||
}
|
||||
|
||||
.prompt-button {
|
||||
@include text-align($alert-md-button-text-align);
|
||||
@include border-radius($alert-md-button-border-radius);
|
||||
|
||||
// necessary for ripple to work properly
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
font-weight: $alert-md-button-font-weight;
|
||||
text-transform: $alert-md-button-text-transform;
|
||||
color: $alert-md-button-text-color;
|
||||
background-color: $alert-md-button-background-color;
|
||||
|
||||
@include deprecated-variable(margin, $alert-md-button-margin) {
|
||||
@include margin($alert-md-button-margin-top, $alert-md-button-margin-end, $alert-md-button-margin-bottom, $alert-md-button-margin-start);
|
||||
}
|
||||
|
||||
@include deprecated-variable(padding, $alert-md-button-padding) {
|
||||
@include padding($alert-md-button-padding-top, $alert-md-button-padding-end, $alert-md-button-padding-bottom, $alert-md-button-padding-start);
|
||||
}
|
||||
}
|
||||
|
||||
.prompt-button.activated {
|
||||
background-color: $alert-md-button-background-color-activated;
|
||||
}
|
||||
|
||||
.prompt-button .button-inner {
|
||||
justify-content: $alert-md-button-group-justify-content;
|
||||
}
|
||||
}
|
||||
|
||||
.content-ios .prompt-button-group {
|
||||
@include margin-horizontal(null, -$alert-ios-button-border-width);
|
||||
|
||||
flex-wrap: $alert-ios-button-group-flex-wrap;
|
||||
.prompt-button {
|
||||
@include margin($alert-ios-button-margin);
|
||||
@include border-radius($alert-ios-button-border-radius);
|
||||
|
||||
overflow: hidden;
|
||||
|
||||
flex: $alert-ios-button-flex;
|
||||
|
||||
min-width: $alert-ios-button-min-width;
|
||||
height: $alert-ios-button-min-height;
|
||||
|
||||
border-top: $alert-ios-button-border-width $alert-ios-button-border-style $alert-ios-button-border-color;
|
||||
border-right: $alert-ios-button-border-width $alert-ios-button-border-style $alert-ios-button-border-color;
|
||||
font-size: $alert-ios-button-font-size;
|
||||
color: $alert-ios-button-text-color;
|
||||
background-color: $alert-ios-button-background-color;
|
||||
}
|
||||
|
||||
.prompt-button:last-child {
|
||||
border-right: 0;
|
||||
font-weight: $alert-ios-button-main-font-weight;
|
||||
}
|
||||
|
||||
.prompt-button.activated {
|
||||
background-color: $alert-ios-button-background-color-activated;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ion-app.app-root-md ion-modal.core-modal-prompt {
|
||||
.modal-wrapper {
|
||||
@include border-radius($alert-md-border-radius);
|
||||
max-width: $alert-md-max-width;
|
||||
background-color: $alert-md-background-color;
|
||||
box-shadow: $alert-md-box-shadow;
|
||||
}
|
||||
|
||||
.toolbar-content .toolbar-title {
|
||||
color: $alert-md-message-text-color;
|
||||
white-space: normal;
|
||||
}
|
||||
}
|
||||
|
||||
ion-app.app-root-ios ion-modal.core-modal-prompt {
|
||||
.modal-wrapper {
|
||||
@include border-radius($alert-ios-border-radius);
|
||||
overflow: hidden;
|
||||
max-width: $alert-ios-max-width;
|
||||
background-color: $alert-ios-background;
|
||||
box-shadow: $alert-ios-box-shadow;
|
||||
}
|
||||
|
||||
.toolbar-content .toolbar-title {
|
||||
color: $alert-ios-message-text-color;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
ion-title {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,71 @@
|
|||
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||
//
|
||||
// Licensed 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 { Component } from '@angular/core';
|
||||
import { IonicPage, ViewController, NavParams, AlertButton } from 'ionic-angular';
|
||||
|
||||
/**
|
||||
* Page to render a textarea prompt.
|
||||
*/
|
||||
@IonicPage({ segment: 'core-viewer-textarea' })
|
||||
@Component({
|
||||
selector: 'page-core-viewer-textarea',
|
||||
templateUrl: 'textarea.html',
|
||||
})
|
||||
export class CoreViewerTextAreaPage {
|
||||
title: string;
|
||||
message: string;
|
||||
placeholder: string;
|
||||
buttons: AlertButton[];
|
||||
text = '';
|
||||
|
||||
constructor(
|
||||
protected viewCtrl: ViewController,
|
||||
params: NavParams,
|
||||
) {
|
||||
this.title = params.get('title');
|
||||
this.message = params.get('message');
|
||||
this.placeholder = params.get('placeholder') || '';
|
||||
|
||||
const buttons = params.get('buttons');
|
||||
|
||||
this.buttons = buttons.map((button) => {
|
||||
if (typeof button === 'string') {
|
||||
return { text: button };
|
||||
}
|
||||
|
||||
return button;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Button clicked.
|
||||
*
|
||||
* @param button: Clicked button.
|
||||
*/
|
||||
buttonClicked(button: AlertButton): void {
|
||||
let shouldDismiss = true;
|
||||
if (button.handler) {
|
||||
// A handler has been provided, execute it pass the handler the values from the inputs
|
||||
if (button.handler(this.text) === false) {
|
||||
// If the return value of the handler is false then do not dismiss
|
||||
shouldDismiss = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldDismiss) {
|
||||
this.viewCtrl.dismiss(button.role);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -15,7 +15,7 @@
|
|||
import { Injectable, SimpleChange, ElementRef } from '@angular/core';
|
||||
import {
|
||||
LoadingController, Loading, ToastController, Toast, AlertController, Alert, Platform, Content, PopoverController,
|
||||
ModalController, AlertButton
|
||||
ModalController, AlertButton, AlertOptions
|
||||
} from 'ionic-angular';
|
||||
import { DomSanitizer } from '@angular/platform-browser';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
|
@ -1139,41 +1139,36 @@ export class CoreDomUtilsProvider {
|
|||
* @return Promise resolved with the alert modal.
|
||||
*/
|
||||
async showAlert(title: string, message: string, buttonText?: string, autocloseTime?: number): Promise<CoreAlert> {
|
||||
const buttons = [buttonText || this.translate.instant('core.ok')];
|
||||
|
||||
return this.showAlertWithButtons(title, message, buttons, autocloseTime);
|
||||
return this.showAlertWithOptions({
|
||||
title: title,
|
||||
message,
|
||||
buttons: [buttonText || this.translate.instant('core.ok')]
|
||||
}, autocloseTime);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show an alert modal with some buttons.
|
||||
* General show an alert modal.
|
||||
*
|
||||
* @param title Title to show.
|
||||
* @param message Message to show.
|
||||
* @param buttons Buttons objects or texts.
|
||||
* @param options Alert options to pass to the alert.
|
||||
* @param autocloseTime Number of milliseconds to wait to close the modal. If not defined, modal won't be closed.
|
||||
* @return Promise resolved with the alert modal.
|
||||
*/
|
||||
async showAlertWithButtons(title: string, message: string, buttons: (string | AlertButton)[], autocloseTime?: number):
|
||||
Promise<CoreAlert> {
|
||||
const hasHTMLTags = this.textUtils.hasHTMLTags(message);
|
||||
async showAlertWithOptions(options: AlertOptions = {}, autocloseTime?: number): Promise<CoreAlert> {
|
||||
const hasHTMLTags = this.textUtils.hasHTMLTags(options.message || '');
|
||||
|
||||
if (hasHTMLTags) {
|
||||
// Format the text.
|
||||
message = await this.textUtils.formatText(message);
|
||||
options.message = await this.textUtils.formatText(options.message);
|
||||
}
|
||||
|
||||
const alertId = <string> Md5.hashAsciiStr((title || '') + '#' + (message || ''));
|
||||
const alertId = <string> Md5.hashAsciiStr((options.title || '') + '#' + (options.message || ''));
|
||||
|
||||
if (this.displayedAlerts[alertId]) {
|
||||
// There's already an alert with the same message and title. Return it.
|
||||
return this.displayedAlerts[alertId];
|
||||
}
|
||||
|
||||
const alert: CoreAlert = <any> this.alertCtrl.create({
|
||||
title: title,
|
||||
message: message,
|
||||
buttons: buttons,
|
||||
});
|
||||
const alert: CoreAlert = <any> this.alertCtrl.create(options);
|
||||
|
||||
alert.present().then(() => {
|
||||
if (hasHTMLTags) {
|
||||
|
@ -1202,8 +1197,18 @@ export class CoreDomUtilsProvider {
|
|||
});
|
||||
|
||||
if (autocloseTime > 0) {
|
||||
setTimeout(() => {
|
||||
alert.dismiss();
|
||||
setTimeout(async () => {
|
||||
await alert.dismiss();
|
||||
|
||||
if (options.buttons) {
|
||||
// Execute dismiss function if any.
|
||||
const cancelButton = <AlertButton> options.buttons.find((button) => {
|
||||
return typeof button != 'string' && typeof button.role != 'undefined' &&
|
||||
typeof button.handler != 'undefined' && button.role == 'cancel';
|
||||
});
|
||||
cancelButton && cancelButton.handler(null);
|
||||
}
|
||||
|
||||
}, autocloseTime);
|
||||
}
|
||||
|
||||
|
@ -1250,52 +1255,33 @@ export class CoreDomUtilsProvider {
|
|||
* @param options More options. See https://ionicframework.com/docs/v3/api/components/alert/AlertController/
|
||||
* @return Promise resolved if the user confirms and rejected with a canceled error if he cancels.
|
||||
*/
|
||||
showConfirm(message: string, title?: string, okText?: string, cancelText?: string, options?: any): Promise<any> {
|
||||
showConfirm(message: string, title?: string, okText?: string, cancelText?: string, options: AlertOptions = {}): Promise<any> {
|
||||
return new Promise<void>((resolve, reject): void => {
|
||||
const hasHTMLTags = this.textUtils.hasHTMLTags(message);
|
||||
let promise;
|
||||
|
||||
if (hasHTMLTags) {
|
||||
// Format the text.
|
||||
promise = this.textUtils.formatText(message);
|
||||
} else {
|
||||
promise = Promise.resolve(message);
|
||||
options.title = title;
|
||||
options.message = message;
|
||||
|
||||
options.buttons = [
|
||||
{
|
||||
text: cancelText || this.translate.instant('core.cancel'),
|
||||
role: 'cancel',
|
||||
handler: (): void => {
|
||||
reject(this.createCanceledError());
|
||||
}
|
||||
},
|
||||
{
|
||||
text: okText || this.translate.instant('core.ok'),
|
||||
handler: (data: any): void => {
|
||||
resolve(data);
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
if (!title) {
|
||||
options.cssClass = 'core-nohead';
|
||||
}
|
||||
|
||||
promise.then((message) => {
|
||||
options = options || {};
|
||||
|
||||
options.message = message;
|
||||
options.title = title;
|
||||
if (!title) {
|
||||
options.cssClass = 'core-nohead';
|
||||
}
|
||||
options.buttons = [
|
||||
{
|
||||
text: cancelText || this.translate.instant('core.cancel'),
|
||||
role: 'cancel',
|
||||
handler: (): void => {
|
||||
reject(this.createCanceledError());
|
||||
}
|
||||
},
|
||||
{
|
||||
text: okText || this.translate.instant('core.ok'),
|
||||
handler: (data: any): void => {
|
||||
resolve(data);
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const alert = this.alertCtrl.create(options);
|
||||
|
||||
alert.present().then(() => {
|
||||
if (hasHTMLTags) {
|
||||
// Treat all anchors so they don't override the app.
|
||||
const alertMessageEl: HTMLElement = alert.pageRef().nativeElement.querySelector('.alert-message');
|
||||
this.treatAnchors(alertMessageEl);
|
||||
}
|
||||
});
|
||||
});
|
||||
this.showAlertWithOptions(options, 0);
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -1414,59 +1400,67 @@ export class CoreDomUtilsProvider {
|
|||
* @param title Modal title.
|
||||
* @param placeholder Placeholder of the input element. By default, "Password".
|
||||
* @param type Type of the input element. By default, password.
|
||||
* @param options More options to pass to the alert.
|
||||
* @return Promise resolved with the input data if the user clicks OK, rejected if cancels.
|
||||
*/
|
||||
showPrompt(message: string, title?: string, placeholder?: string, type: string = 'password'): Promise<any> {
|
||||
return new Promise((resolve, reject): void => {
|
||||
const hasHTMLTags = this.textUtils.hasHTMLTags(message);
|
||||
let promise;
|
||||
return new Promise((resolve, reject): any => {
|
||||
placeholder = typeof placeholder == 'undefined' || placeholder == null ?
|
||||
this.translate.instant('core.login.password') : placeholder;
|
||||
|
||||
if (hasHTMLTags) {
|
||||
// Format the text.
|
||||
promise = this.textUtils.formatText(message);
|
||||
} else {
|
||||
promise = Promise.resolve(message);
|
||||
}
|
||||
|
||||
promise.then((message) => {
|
||||
const alert = this.alertCtrl.create({
|
||||
message: message,
|
||||
title: title,
|
||||
inputs: [
|
||||
{
|
||||
name: 'promptinput',
|
||||
placeholder: placeholder || this.translate.instant('core.login.password'),
|
||||
type: type
|
||||
}
|
||||
],
|
||||
buttons: [
|
||||
{
|
||||
text: this.translate.instant('core.cancel'),
|
||||
role: 'cancel',
|
||||
handler: (): void => {
|
||||
reject();
|
||||
}
|
||||
},
|
||||
{
|
||||
text: this.translate.instant('core.ok'),
|
||||
handler: (data): void => {
|
||||
resolve(data.promptinput);
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
alert.present().then(() => {
|
||||
if (hasHTMLTags) {
|
||||
// Treat all anchors so they don't override the app.
|
||||
const alertMessageEl: HTMLElement = alert.pageRef().nativeElement.querySelector('.alert-message');
|
||||
this.treatAnchors(alertMessageEl);
|
||||
const options: AlertOptions = {
|
||||
title,
|
||||
message,
|
||||
inputs: [
|
||||
{
|
||||
name: 'promptinput',
|
||||
placeholder: placeholder,
|
||||
type: type
|
||||
}
|
||||
});
|
||||
});
|
||||
],
|
||||
buttons: [
|
||||
{
|
||||
text: this.translate.instant('core.cancel'),
|
||||
role: 'cancel',
|
||||
handler: (): void => {
|
||||
reject();
|
||||
}
|
||||
},
|
||||
{
|
||||
text: this.translate.instant('core.ok'),
|
||||
handler: (data): void => {
|
||||
resolve(data.promptinput);
|
||||
}
|
||||
}
|
||||
],
|
||||
};
|
||||
|
||||
this.showAlertWithOptions(options);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a prompt modal to input a textarea.
|
||||
*
|
||||
* @param title Modal title.
|
||||
* @param message Modal message.
|
||||
* @param buttons Buttons to pass to the modal.
|
||||
* @param placeholder Placeholder of the input element if any.
|
||||
* @return Promise resolved when modal presented.
|
||||
*/
|
||||
showTextareaPrompt(title: string, message: string, buttons: (string | AlertButton)[], placeholder?: string): Promise<any> {
|
||||
const params = {
|
||||
title: title,
|
||||
message: message,
|
||||
placeholder: placeholder,
|
||||
buttons: buttons,
|
||||
};
|
||||
|
||||
const modal = this.modalCtrl.create('CoreViewerTextAreaPage', params, { cssClass: 'core-modal-prompt' });
|
||||
|
||||
return modal.present();
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays an autodimissable toast modal window.
|
||||
*
|
||||
|
|
Loading…
Reference in New Issue