mirror of
https://github.com/DioxusLabs/dioxus
synced 2024-11-23 04:33:06 +00:00
wip: updates to router
This commit is contained in:
parent
a5f05d73ac
commit
bab21a0aa1
8 changed files with 208 additions and 217 deletions
|
@ -3,8 +3,11 @@ This example is a simple iOS-style calculator. This particular example can run a
|
|||
This calculator version uses React-style state management. All state is held as individual use_states.
|
||||
*/
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use dioxus::events::*;
|
||||
use dioxus::prelude::*;
|
||||
use separator::Separatable;
|
||||
|
||||
fn main() {
|
||||
dioxus::desktop::launch(APP, |cfg| cfg);
|
||||
|
@ -15,48 +18,12 @@ const APP: FC<()> = |cx, _| {
|
|||
let operator = use_state(cx, || None as Option<&'static str>);
|
||||
let display_value = use_state(cx, || String::from(""));
|
||||
|
||||
let clear_display = display_value == "0";
|
||||
let clear_text = if clear_display { "C" } else { "AC" };
|
||||
|
||||
let toggle_percent = move |_| todo!();
|
||||
let input_digit = move |num: u8| display_value.modify().push_str(num.to_string().as_str());
|
||||
|
||||
let input_dot = move || display_value.modify().push_str(".");
|
||||
|
||||
let perform_operation = move || {
|
||||
if let Some(op) = operator.as_ref() {
|
||||
let rhs = display_value.parse::<f64>().unwrap();
|
||||
let new_val = match *op {
|
||||
"+" => *cur_val + rhs,
|
||||
"-" => *cur_val - rhs,
|
||||
"*" => *cur_val * rhs,
|
||||
"/" => *cur_val / rhs,
|
||||
_ => unreachable!(),
|
||||
};
|
||||
cur_val.set(new_val);
|
||||
display_value.set(new_val.to_string());
|
||||
operator.set(None);
|
||||
}
|
||||
};
|
||||
|
||||
let toggle_sign = move |_| {
|
||||
if display_value.starts_with("-") {
|
||||
display_value.set(display_value.trim_start_matches("-").to_string())
|
||||
} else {
|
||||
display_value.set(format!("-{}", *display_value))
|
||||
}
|
||||
};
|
||||
|
||||
let toggle_percent = move |_| todo!();
|
||||
|
||||
let clear_key = move |_| {
|
||||
display_value.set("0".to_string());
|
||||
if !clear_display {
|
||||
operator.set(None);
|
||||
cur_val.set(0.0);
|
||||
}
|
||||
};
|
||||
|
||||
let keydownhandler = move |evt: KeyboardEvent| match evt.key_code {
|
||||
rsx!(cx, div {
|
||||
class: "calculator",
|
||||
onkeydown: move |evt| match evt.key_code {
|
||||
KeyCode::Add => operator.set(Some("+")),
|
||||
KeyCode::Subtract => operator.set(Some("-")),
|
||||
KeyCode::Divide => operator.set(Some("/")),
|
||||
|
@ -77,23 +44,41 @@ const APP: FC<()> = |cx, _| {
|
|||
}
|
||||
}
|
||||
_ => {}
|
||||
};
|
||||
|
||||
use separator::Separatable;
|
||||
let formatted_display = cur_val.separated_string();
|
||||
|
||||
rsx!(cx, div {
|
||||
class: "calculator", onkeydown: {keydownhandler}
|
||||
div { class: "calculator-display", "{formatted_display}" }
|
||||
}
|
||||
div { class: "calculator-display", {[format_args!("{}", cur_val.separated_string())]} }
|
||||
div { class: "input-keys"
|
||||
div { class: "function-keys"
|
||||
CalculatorKey { name: "key-clear", onclick: {clear_key} "{clear_text}" }
|
||||
CalculatorKey { name: "key-sign", onclick: {toggle_sign}, "±"}
|
||||
CalculatorKey { name: "key-percent", onclick: {toggle_percent} "%"}
|
||||
CalculatorKey {
|
||||
{[if display_value == "0" { "C" } else { "AC" }]}
|
||||
name: "key-clear",
|
||||
onclick: move |_| {
|
||||
display_value.set("0".to_string());
|
||||
if display_value != "0" {
|
||||
operator.set(None);
|
||||
cur_val.set(0.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
CalculatorKey {
|
||||
"±"
|
||||
name: "key-sign",
|
||||
onclick: move |_| {
|
||||
if display_value.starts_with("-") {
|
||||
display_value.set(display_value.trim_start_matches("-").to_string())
|
||||
} else {
|
||||
display_value.set(format!("-{}", *display_value))
|
||||
}
|
||||
},
|
||||
}
|
||||
CalculatorKey {
|
||||
"%"
|
||||
onclick: {toggle_percent}
|
||||
name: "key-percent",
|
||||
}
|
||||
}
|
||||
div { class: "digit-keys"
|
||||
CalculatorKey { name: "key-0", onclick: move |_| input_digit(0), "0" }
|
||||
CalculatorKey { name: "key-dot", onclick: move |_| input_dot(), "●" }
|
||||
CalculatorKey { name: "key-dot", onclick: move |_| display_value.modify().push_str("."), "●" }
|
||||
|
||||
{(1..9).map(|k| rsx!{
|
||||
CalculatorKey { key: "{k}", name: "key-{k}", onclick: move |_| input_digit(k), "{k}" }
|
||||
|
@ -104,7 +89,25 @@ const APP: FC<()> = |cx, _| {
|
|||
CalculatorKey { name: "key-multiply", onclick: move |_| operator.set(Some("*")) "×" }
|
||||
CalculatorKey { name: "key-subtract", onclick: move |_| operator.set(Some("-")) "−" }
|
||||
CalculatorKey { name: "key-add", onclick: move |_| operator.set(Some("+")) "+" }
|
||||
CalculatorKey { name: "key-equals", onclick: move |_| perform_operation() "=" }
|
||||
CalculatorKey {
|
||||
"="
|
||||
name: "key-equals",
|
||||
onclick: move |_| {
|
||||
if let Some(op) = operator.as_ref() {
|
||||
let rhs = display_value.parse::<f64>().unwrap();
|
||||
let new_val = match *op {
|
||||
"+" => *cur_val + rhs,
|
||||
"-" => *cur_val - rhs,
|
||||
"*" => *cur_val * rhs,
|
||||
"/" => *cur_val / rhs,
|
||||
_ => unreachable!(),
|
||||
};
|
||||
cur_val.set(new_val);
|
||||
display_value.set(new_val.to_string());
|
||||
operator.set(None);
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
@ -113,7 +116,7 @@ const APP: FC<()> = |cx, _| {
|
|||
#[derive(Props)]
|
||||
struct CalculatorKeyProps<'a> {
|
||||
name: &'static str,
|
||||
onclick: &'a dyn Fn(MouseEvent),
|
||||
onclick: &'a dyn Fn(Arc<MouseEvent>),
|
||||
children: Element,
|
||||
}
|
||||
|
||||
|
|
|
@ -206,43 +206,43 @@ pub fn routable_derive_impl(input: Routable) -> TokenStream {
|
|||
// static #cache_thread_local_ident: ::std::cell::RefCell<::std::option::Option<#ident>> = ::std::cell::RefCell::new(::std::option::Option::None);
|
||||
// }
|
||||
|
||||
#[automatically_derived]
|
||||
impl ::dioxus::router::Routable for #ident {
|
||||
#from_path
|
||||
#to_path
|
||||
// #[automatically_derived]
|
||||
// impl ::dioxus::router::Routable for #ident {
|
||||
// #from_path
|
||||
// #to_path
|
||||
|
||||
fn routes() -> ::std::vec::Vec<&'static str> {
|
||||
::std::vec![#(#ats),*]
|
||||
}
|
||||
// // fn routes() -> ::std::vec::Vec<&'static str> {
|
||||
// // ::std::vec![#(#ats),*]
|
||||
// // }
|
||||
|
||||
fn not_found_route() -> ::std::option::Option<Self> {
|
||||
#not_found_route
|
||||
}
|
||||
|
||||
// fn current_route() -> ::std::option::Option<Self> {
|
||||
// #cache_thread_local_ident.with(|val| ::std::clone::Clone::clone(&*val.borrow()))
|
||||
// fn not_found_route() -> ::std::option::Option<Self> {
|
||||
// #not_found_route
|
||||
// }
|
||||
|
||||
fn recognize(pathname: &str) -> ::std::option::Option<Self> {
|
||||
todo!()
|
||||
// ::std::thread_local! {
|
||||
// static ROUTER: ::dioxus::router::__macro::Router = ::dioxus::router::__macro::build_router::<#ident>();
|
||||
// }
|
||||
// let route = ROUTER.with(|router| ::dioxus::router::__macro::recognize_with_router(router, pathname));
|
||||
// {
|
||||
// let route = ::std::clone::Clone::clone(&route);
|
||||
// #cache_thread_local_ident.with(move |val| {
|
||||
// *val.borrow_mut() = route;
|
||||
// });
|
||||
// }
|
||||
// route
|
||||
}
|
||||
// // fn current_route() -> ::std::option::Option<Self> {
|
||||
// // #cache_thread_local_ident.with(|val| ::std::clone::Clone::clone(&*val.borrow()))
|
||||
// // }
|
||||
|
||||
// fn cleanup() {
|
||||
// #cache_thread_local_ident.with(move |val| {
|
||||
// *val.borrow_mut() = ::std::option::Option::None;
|
||||
// });
|
||||
// fn recognize(pathname: &str) -> ::std::option::Option<Self> {
|
||||
// todo!()
|
||||
// // ::std::thread_local! {
|
||||
// // static ROUTER: ::dioxus::router::__macro::Router = ::dioxus::router::__macro::build_router::<#ident>();
|
||||
// // }
|
||||
// // let route = ROUTER.with(|router| ::dioxus::router::__macro::recognize_with_router(router, pathname));
|
||||
// // {
|
||||
// // let route = ::std::clone::Clone::clone(&route);
|
||||
// // #cache_thread_local_ident.with(move |val| {
|
||||
// // *val.borrow_mut() = route;
|
||||
// // });
|
||||
// // }
|
||||
// // route
|
||||
// }
|
||||
|
||||
// // fn cleanup() {
|
||||
// // #cache_thread_local_ident.with(move |val| {
|
||||
// // *val.borrow_mut() = ::std::option::Option::None;
|
||||
// // });
|
||||
// // }
|
||||
// }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -74,7 +74,6 @@ pub struct SelfReferentialItems<'a> {
|
|||
pub(crate) borrowed_props: Vec<&'a VComponent<'a>>,
|
||||
pub(crate) suspended_nodes: FxHashMap<u64, &'a VSuspended>,
|
||||
pub(crate) tasks: Vec<BumpBox<'a, dyn Future<Output = ()>>>,
|
||||
pub(crate) pending_effects: Vec<BumpBox<'a, dyn FnMut()>>,
|
||||
}
|
||||
|
||||
/// A component's unique identifier.
|
||||
|
@ -284,24 +283,6 @@ impl Scope {
|
|||
}
|
||||
}
|
||||
|
||||
/// Push an effect to be ran after the component has been successfully mounted to the dom
|
||||
/// Returns the effect's position in the stack
|
||||
pub fn push_effect<'src>(&'src self, effect: impl FnOnce() + 'src) -> usize {
|
||||
// this is some tricker to get around not being able to actually call fnonces
|
||||
let mut slot = Some(effect);
|
||||
let fut: &mut dyn FnMut() = self.bump().alloc(move || slot.take().unwrap()());
|
||||
|
||||
// wrap it in a type that will actually drop the contents
|
||||
let boxed_fut = unsafe { BumpBox::from_raw(fut) };
|
||||
|
||||
// erase the 'src lifetime for self-referential storage
|
||||
let self_ref_fut = unsafe { std::mem::transmute(boxed_fut) };
|
||||
|
||||
let mut items = self.items.borrow_mut();
|
||||
items.pending_effects.push(self_ref_fut);
|
||||
items.pending_effects.len() - 1
|
||||
}
|
||||
|
||||
/// Pushes the future onto the poll queue to be polled
|
||||
/// The future is forcibly dropped if the component is not ready by the next render
|
||||
pub fn push_task<'src, F: Future<Output = ()>>(
|
||||
|
@ -483,13 +464,6 @@ impl Scope {
|
|||
}
|
||||
}
|
||||
|
||||
// run the list of effects
|
||||
pub(crate) fn run_effects(&mut self) {
|
||||
for mut effect in self.items.get_mut().pending_effects.drain(..) {
|
||||
effect();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn root_node(&self) -> &VNode {
|
||||
let node = *self.wip_frame().nodes.borrow().get(0).unwrap();
|
||||
unsafe { std::mem::transmute(&*node) }
|
||||
|
|
|
@ -188,7 +188,6 @@ impl ScopeArena {
|
|||
borrowed_props: Default::default(),
|
||||
suspended_nodes: Default::default(),
|
||||
tasks: Default::default(),
|
||||
pending_effects: Default::default(),
|
||||
}),
|
||||
});
|
||||
|
||||
|
@ -226,14 +225,12 @@ impl ScopeArena {
|
|||
let SelfReferentialItems {
|
||||
borrowed_props,
|
||||
listeners,
|
||||
pending_effects,
|
||||
suspended_nodes,
|
||||
tasks,
|
||||
} = scope.items.get_mut();
|
||||
|
||||
borrowed_props.clear();
|
||||
listeners.clear();
|
||||
pending_effects.clear();
|
||||
suspended_nodes.clear();
|
||||
tasks.clear();
|
||||
|
||||
|
@ -328,14 +325,12 @@ impl ScopeArena {
|
|||
// just forget about our suspended nodes while we're at it
|
||||
items.suspended_nodes.clear();
|
||||
items.tasks.clear();
|
||||
items.pending_effects.clear();
|
||||
|
||||
// guarantee that we haven't screwed up - there should be no latent references anywhere
|
||||
debug_assert!(items.listeners.is_empty());
|
||||
debug_assert!(items.borrowed_props.is_empty());
|
||||
debug_assert!(items.suspended_nodes.is_empty());
|
||||
debug_assert!(items.tasks.is_empty());
|
||||
debug_assert!(items.pending_effects.is_empty());
|
||||
|
||||
// Todo: see if we can add stronger guarantees around internal bookkeeping and failed component renders.
|
||||
scope.wip_frame().nodes.borrow_mut().clear();
|
||||
|
|
|
@ -409,7 +409,7 @@ class Interpreter {
|
|||
}
|
||||
|
||||
function main() {
|
||||
let root = window.document.getElementById("_dioxusroot");
|
||||
let root = window.document.getElementById("main");
|
||||
window.interpreter = new Interpreter(root);
|
||||
console.log(window.interpreter);
|
||||
|
||||
|
|
|
@ -5,41 +5,35 @@ use dioxus_router::*;
|
|||
|
||||
fn main() {
|
||||
console_error_panic_hook::set_once();
|
||||
wasm_logger::init(wasm_logger::Config::new(log::Level::Debug));
|
||||
dioxus_web::launch(App, |c| c);
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
enum Route {
|
||||
static App: FC<()> = |cx, props| {
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
enum Route {
|
||||
Home,
|
||||
About,
|
||||
NotFound,
|
||||
}
|
||||
|
||||
static App: FC<()> = |cx, props| {
|
||||
let route = use_router(cx, Route::parse);
|
||||
|
||||
match route {
|
||||
Route::Home => rsx!(cx, div { "Home" }),
|
||||
Route::About => rsx!(cx, div { "About" }),
|
||||
Route::NotFound => rsx!(cx, div { "NotFound" }),
|
||||
}
|
||||
};
|
||||
|
||||
impl ToString for Route {
|
||||
fn to_string(&self) -> String {
|
||||
match self {
|
||||
Route::Home => "/".to_string(),
|
||||
Route::About => "/about".to_string(),
|
||||
Route::NotFound => "/404".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Route {
|
||||
fn parse(s: &str) -> Self {
|
||||
match s {
|
||||
let route = use_router(cx, |s| match s {
|
||||
"/" => Route::Home,
|
||||
"/about" => Route::About,
|
||||
_ => Route::NotFound,
|
||||
});
|
||||
|
||||
cx.render(rsx! {
|
||||
div {
|
||||
{match route {
|
||||
Route::Home => rsx!(h1 { "Home" }),
|
||||
Route::About => rsx!(h1 { "About" }),
|
||||
Route::NotFound => rsx!(h1 { "NotFound" }),
|
||||
}}
|
||||
nav {
|
||||
Link { to: Route::Home, href: |_| "/".to_string() }
|
||||
Link { to: Route::About, href: |_| "/about".to_string() }
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
};
|
||||
|
|
|
@ -6,23 +6,24 @@ use dioxus_core as dioxus;
|
|||
use dioxus_core::prelude::*;
|
||||
use dioxus_core_macro::{format_args_f, rsx, Props};
|
||||
use dioxus_html as dioxus_elements;
|
||||
use wasm_bindgen::JsValue;
|
||||
use web_sys::Event;
|
||||
use wasm_bindgen::{JsCast, JsValue};
|
||||
use web_sys::{window, Event};
|
||||
|
||||
use crate::utils::fetch_base_url;
|
||||
use crate::utils::strip_slash_suffix;
|
||||
|
||||
pub trait Routable: 'static + Send + Clone + ToString + PartialEq {}
|
||||
impl<T> Routable for T where T: 'static + Send + Clone + ToString + PartialEq {}
|
||||
pub trait Routable: 'static + Send + Clone + PartialEq {}
|
||||
impl<T> Routable for T where T: 'static + Send + Clone + PartialEq {}
|
||||
|
||||
pub struct RouterService<R: Routable> {
|
||||
historic_routes: RefCell<Vec<R>>,
|
||||
history_service: web_sys::History,
|
||||
historic_routes: Vec<R>,
|
||||
history_service: RefCell<web_sys::History>,
|
||||
base_ur: RefCell<Option<String>>,
|
||||
}
|
||||
|
||||
impl<R: Routable> RouterService<R> {
|
||||
fn push_route(&self, r: R) {
|
||||
self.historic_routes.borrow_mut().push(r);
|
||||
todo!()
|
||||
// self.historic_routes.borrow_mut().push(r);
|
||||
}
|
||||
|
||||
fn get_current_route(&self) -> &str {
|
||||
|
@ -65,41 +66,73 @@ impl<R: Routable> RouterService<R> {
|
|||
/// This hould only be used once per app
|
||||
///
|
||||
/// You can manually parse the route if you want, but the derived `parse` method on `Routable` will also work just fine
|
||||
pub fn use_router<R: Routable>(cx: Context, parse: impl FnMut(&str) -> R) -> &R {
|
||||
pub fn use_router<R: Routable>(cx: Context, mut parse: impl FnMut(&str) -> R + 'static) -> &R {
|
||||
// for the web, attach to the history api
|
||||
cx.use_hook(
|
||||
|f| {
|
||||
//
|
||||
use gloo::events::EventListener;
|
||||
|
||||
let base_url = fetch_base_url();
|
||||
let base = window()
|
||||
.unwrap()
|
||||
.document()
|
||||
.unwrap()
|
||||
.query_selector("base[href]")
|
||||
.ok()
|
||||
.flatten()
|
||||
.and_then(|base| {
|
||||
let base = JsCast::unchecked_into::<web_sys::HtmlBaseElement>(base).href();
|
||||
let url = web_sys::Url::new(&base).unwrap();
|
||||
|
||||
if url.pathname() != "/" {
|
||||
Some(strip_slash_suffix(&base).to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
let location = window().unwrap().location();
|
||||
let pathname = location.pathname().unwrap();
|
||||
let initial_route = parse(&pathname);
|
||||
|
||||
let service: RouterService<R> = RouterService {
|
||||
historic_routes: RefCell::new(vec![]),
|
||||
history_service: web_sys::window().unwrap().history().expect("no history"),
|
||||
base_ur: RefCell::new(base_url),
|
||||
historic_routes: vec![initial_route],
|
||||
history_service: RefCell::new(
|
||||
web_sys::window().unwrap().history().expect("no history"),
|
||||
),
|
||||
base_ur: RefCell::new(base),
|
||||
};
|
||||
|
||||
// let base = base_url();
|
||||
// let url = route.to_path();
|
||||
// pending_routes: RefCell::new(vec![]),
|
||||
// service.history_service.push_state(data, title);
|
||||
|
||||
cx.provide_state(service);
|
||||
// cx.provide_state(service);
|
||||
|
||||
let regenerate = cx.schedule_update();
|
||||
|
||||
// when "back" is called by the user, we want to to re-render the component
|
||||
// // when "back" is called by the user, we want to to re-render the component
|
||||
let listener = EventListener::new(&web_sys::window().unwrap(), "popstate", move |_| {
|
||||
//
|
||||
regenerate();
|
||||
});
|
||||
|
||||
service
|
||||
},
|
||||
|f| {
|
||||
|state| {
|
||||
let base = state.base_ur.borrow();
|
||||
if let Some(base) = base.as_ref() {
|
||||
//
|
||||
},
|
||||
);
|
||||
let path = format!("{}{}", base, state.get_current_route());
|
||||
}
|
||||
let history = state.history_service.borrow();
|
||||
|
||||
todo!()
|
||||
// let router = use_router_service::<R>(cx)?;
|
||||
// Some(cfg(router.get_current_route()))
|
||||
// history.state.historic_routes.last().unwrap()
|
||||
//
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub fn use_router_service<R: Routable>(cx: Context) -> Option<&Rc<RouterService<R>>> {
|
||||
|
@ -109,6 +142,23 @@ pub fn use_router_service<R: Routable>(cx: Context) -> Option<&Rc<RouterService<
|
|||
#[derive(Props)]
|
||||
pub struct LinkProps<R: Routable> {
|
||||
to: R,
|
||||
|
||||
/// The url that gets pushed to the history stack
|
||||
///
|
||||
/// You can either put it your own inline method or just autoderive the route using `derive(Routable)`
|
||||
///
|
||||
/// ```rust
|
||||
///
|
||||
/// Link { to: Route::Home, href: |_| "home".to_string() }
|
||||
///
|
||||
/// // or
|
||||
///
|
||||
/// Link { to: Route::Home, href: Route::as_url }
|
||||
///
|
||||
/// ```
|
||||
href: fn(&R) -> String,
|
||||
|
||||
#[builder(default)]
|
||||
children: Element,
|
||||
}
|
||||
|
||||
|
@ -116,7 +166,7 @@ pub fn Link<R: Routable>(cx: Context, props: &LinkProps<R>) -> Element {
|
|||
let service = use_router_service::<R>(cx)?;
|
||||
cx.render(rsx! {
|
||||
a {
|
||||
href: format_args!("{}", props.to.to_string()),
|
||||
href: format_args!("{}", (props.href)(&props.to)),
|
||||
onclick: move |_| service.push_route(props.to.clone()),
|
||||
{&props.children},
|
||||
}
|
||||
|
|
|
@ -1,31 +1,6 @@
|
|||
use wasm_bindgen::JsCast;
|
||||
use web_sys::window;
|
||||
|
||||
pub fn fetch_base_url() -> Option<String> {
|
||||
match window()
|
||||
.unwrap()
|
||||
.document()
|
||||
.unwrap()
|
||||
.query_selector("base[href]")
|
||||
{
|
||||
Ok(Some(base)) => {
|
||||
let base = JsCast::unchecked_into::<web_sys::HtmlBaseElement>(base).href();
|
||||
|
||||
let url = web_sys::Url::new(&base).unwrap();
|
||||
let base = url.pathname();
|
||||
|
||||
let base = if base != "/" {
|
||||
strip_slash_suffix(&base)
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
|
||||
Some(base.to_string())
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn strip_slash_suffix(path: &str) -> &str {
|
||||
path.strip_suffix('/').unwrap_or(path)
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue