New notification system (#7339)

This PR consists of two primary changes:
1. I've replaced `react-hot-toast` with `react-toastify` library. Both serve the same purpose — sending popup notifications (so-called "toasts"). However, the latter comes with a richer feature set that matches our requirements much better.
2. I've exposed the relevant API surface to the Rust. Now Rust code can easily send notifications.

### Important Notes
At this point, no attempt at customizing style of notifications was made (other than selecting the "light" theme). 

Likely we should consider this soon after integration as a separate task.
This commit is contained in:
Michał Wawrzyniec Urbańczyk 2023-07-24 21:58:53 +02:00 committed by GitHub
parent 1d2371f986
commit 7211c8317d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
33 changed files with 752 additions and 164 deletions

18
Cargo.lock generated
View File

@ -3766,6 +3766,19 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "gloo-utils"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "037fcb07216cb3a30f7292bd0176b050b7b9a052ba830ef7d5d65f6dc64ba58e"
dependencies = [
"js-sys",
"serde",
"serde_json",
"wasm-bindgen",
"web-sys",
]
[[package]] [[package]]
name = "graphql-introspection-query" name = "graphql-introspection-query"
version = "0.2.0" version = "0.2.0"
@ -4267,6 +4280,7 @@ dependencies = [
"ensogl-hardcoded-theme", "ensogl-hardcoded-theme",
"ensogl-text", "ensogl-text",
"ensogl-text-msdf", "ensogl-text-msdf",
"gloo-utils",
"ide-view-component-browser", "ide-view-component-browser",
"ide-view-documentation", "ide-view-documentation",
"ide-view-execution-environment-selector", "ide-view-execution-environment-selector",
@ -4280,8 +4294,10 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"span-tree", "span-tree",
"strum",
"uuid 0.8.2", "uuid 0.8.2",
"wasm-bindgen", "wasm-bindgen",
"wasm-bindgen-test",
"web-sys", "web-sys",
"welcome-screen", "welcome-screen",
] ]
@ -7386,8 +7402,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "31f8dcbc21f30d9b8f2ea926ecb58f6b91192c17e9d33594b3df58b2007ca53b" checksum = "31f8dcbc21f30d9b8f2ea926ecb58f6b91192c17e9d33594b3df58b2007ca53b"
dependencies = [ dependencies = [
"cfg-if 1.0.0", "cfg-if 1.0.0",
"serde",
"serde_json",
"wasm-bindgen-macro", "wasm-bindgen-macro",
] ]

View File

@ -85,7 +85,7 @@ debug-assertions = true
console-subscriber = "0.1.8" console-subscriber = "0.1.8"
nix = "0.26.1" nix = "0.26.1"
octocrab = { git = "https://github.com/enso-org/octocrab", default-features = false, features = [ octocrab = { git = "https://github.com/enso-org/octocrab", default-features = false, features = [
"rustls" "rustls",
] } ] }
regex = { version = "1.6.0" } regex = { version = "1.6.0" }
serde_yaml = { version = "0.9.16" } serde_yaml = { version = "0.9.16" }
@ -93,7 +93,7 @@ serde-wasm-bindgen = { version = "0.4.5" }
tokio = { version = "1.23.0", features = ["full", "tracing"] } tokio = { version = "1.23.0", features = ["full", "tracing"] }
tokio-stream = { version = "0.1.12", features = ["fs"] } tokio-stream = { version = "0.1.12", features = ["fs"] }
tokio-util = { version = "0.7.4", features = ["full"] } tokio-util = { version = "0.7.4", features = ["full"] }
wasm-bindgen = { version = "0.2.84", features = ["serde-serialize"] } wasm-bindgen = { version = "0.2.84", features = [] }
wasm-bindgen-test = { version = "0.3.34" } wasm-bindgen-test = { version = "0.3.34" }
anyhow = { version = "1.0.66" } anyhow = { version = "1.0.66" }
failure = { version = "0.1.8" } failure = { version = "0.1.8" }
@ -135,7 +135,11 @@ syn = { version = "1.0", features = [
] } ] }
quote = { version = "1.0.23" } quote = { version = "1.0.23" }
semver = { version = "1.0.0", features = ["serde"] } semver = { version = "1.0.0", features = ["serde"] }
strum = { version = "0.24.0", features = ["derive"] }
thiserror = "1.0.40" thiserror = "1.0.40"
bytemuck = { version = "1.13.1", features = ["derive"] } bytemuck = { version = "1.13.1", features = ["derive"] }
bitflags = { version = "2.2.1" } bitflags = { version = "2.2.1" }
superslice = { version = "1.0.0" } superslice = { version = "1.0.0" }
# We don't use serde-wasm-bindgen in some cases, because it cannot deal properly with flattened fields, see:
# https://github.com/cloudflare/serde-wasm-bindgen/issues/9
gloo-utils = { version = "0.1.7" }

View File

@ -22,7 +22,7 @@ mockall = { version = "0.7.1", features = ["nightly"] }
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = { workspace = true } serde_json = { workspace = true }
sha3 = { version = "0.8.2" } sha3 = { version = "0.8.2" }
strum = "0.24.0" strum = { workspace = true }
strum_macros = "0.24.0" strum_macros = "0.24.0"
uuid = { version = "0.8", features = ["serde", "v4", "wasm-bindgen"] } uuid = { version = "0.8", features = ["serde", "v4", "wasm-bindgen"] }

View File

@ -28,14 +28,17 @@ ide-view-documentation = { path = "documentation" }
ide-view-graph-editor = { path = "graph-editor" } ide-view-graph-editor = { path = "graph-editor" }
ide-view-project-view-top-bar = { path = "project-view-top-bar" } ide-view-project-view-top-bar = { path = "project-view-top-bar" }
span-tree = { path = "../language/span-tree" } span-tree = { path = "../language/span-tree" }
gloo-utils = { workspace = true }
js-sys = { workspace = true } js-sys = { workspace = true }
multi-map = { workspace = true } multi-map = { workspace = true }
nalgebra = { workspace = true } nalgebra = { workspace = true }
ordered-float = { workspace = true } ordered-float = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
strum = { workspace = true }
uuid = { version = "0.8", features = ["serde", "v4", "wasm-bindgen"] } uuid = { version = "0.8", features = ["serde", "v4", "wasm-bindgen"] }
wasm-bindgen = { workspace = true } wasm-bindgen = { workspace = true }
wasm-bindgen-test = { workspace = true }
welcome-screen = { path = "welcome-screen" } welcome-screen = { path = "welcome-screen" }
[dependencies.web-sys] [dependencies.web-sys]

View File

@ -32,6 +32,7 @@
#[allow(clippy::option_map_unit_fn)] #[allow(clippy::option_map_unit_fn)]
pub mod code_editor; pub mod code_editor;
pub mod debug_mode_popup; pub mod debug_mode_popup;
pub mod notification;
pub mod popup; pub mod popup;
pub mod project; pub mod project;
pub mod project_list; pub mod project_list;

View File

@ -0,0 +1,391 @@
//! This is Rust wrapper for the [`react-toastify`](https://fkhadra.github.io/react-toastify) library.
//!
//! # Example
//!
//! ```no_run
//! use ide_view::notification;
//! # fn main() -> Result<(), wasm_bindgen::JsValue> {
//! use ide_view::notification::UpdateOptions;
//! let handle = notification::info(
//! "Undo triggered in UI.",
//! &Some(notification::Options {
//! theme: Some(notification::Theme::Dark),
//! auto_close: Some(notification::AutoClose::Never()),
//! draggable: Some(false),
//! close_on_click: Some(false),
//! ..Default::default()
//! }),
//! )?;
//! handle.update(&UpdateOptions::default().render_string("Undo done."))?;
//! # Ok(())
//! # }
//! ```
use crate::prelude::*;
use wasm_bindgen::prelude::*;
use gloo_utils::format::JsValueSerdeExt;
use serde::Deserialize;
use serde::Serialize;
// ==============
// === Export ===
// ==============
pub mod js;
pub use js::Id;
// ===================
// === Primary API ===
// ===================
/// Send any kind of notification.
pub fn send_any(message: &str, r#type: Type, options: &Option<Options>) -> Result<Id, JsValue> {
let options = match options {
Some(options) => options.try_into()?,
None => JsValue::UNDEFINED,
};
js::toast(message, r#type, &options)
}
/// Send an info notification.
pub fn info(message: &str, options: &Option<Options>) -> Result<Id, JsValue> {
send_any(message, Type::Info, options)
}
/// Send a warning notification.
pub fn warning(message: &str, options: &Option<Options>) -> Result<Id, JsValue> {
send_any(message, Type::Warning, options)
}
/// Send a error notification.
pub fn error(message: &str, options: &Option<Options>) -> Result<Id, JsValue> {
send_any(message, Type::Error, options)
}
/// Send a success notification.
pub fn success(message: &str, options: &Option<Options>) -> Result<Id, JsValue> {
send_any(message, Type::Success, options)
}
// =============================
// === JS-conversion helpers ===
// =============================
// === Rust->JS error ===
/// Convert arbitrary Rust error value into JsValue-based error.
pub fn to_js_error(error: impl std::error::Error) -> JsValue {
js_sys::Error::new(&error.to_string()).into()
}
// === Rust->JS conversion ===
/// Macro that implements TryFrom for given `type` to/from JsValue using
/// [`gloo-utils::format::JsValueSerdeExt`].
///
/// Implements:
/// - `TryFrom<&type> for JsValue`
/// - `TryFrom<JsValue> for type`
macro_rules! impl_try_from_jsvalue {
($type:ty) => {
impl TryFrom<&$type> for JsValue {
type Error = JsValue;
fn try_from(value: &$type) -> Result<Self, Self::Error> {
JsValue::from_serde(value).map_err($crate::notification::to_js_error)
}
}
impl TryFrom<JsValue> for $type {
type Error = JsValue;
fn try_from(js_value: JsValue) -> Result<Self, Self::Error> {
js_value.into_serde().map_err($crate::notification::to_js_error)
}
}
};
}
// === SerializableJsValue ===
/// A wrapper around a `JsValue` that implements the `Serialize` and `Deserialize` traits.
#[derive(Clone, Debug, Deref, DerefMut, AsRef, From)]
pub struct SerializableJsValue(pub JsValue);
impl Serialize for SerializableJsValue {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
let value = js_sys::JSON::stringify(self).map(String::from).map_err(|e| {
serde::ser::Error::custom(format!("Failed to stringify JsValue: {e:?}"))
})?;
let value = serde_json::from_str::<serde_json::Value>(&value)
.map_err(|e| serde::ser::Error::custom(format!("Failed to parse JSON: {e:?}")))?;
value.serialize(serializer)
}
}
impl<'de> Deserialize<'de> for SerializableJsValue {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let value = serde_json::Value::deserialize(deserializer)?;
JsValue::from_serde(&value).map(SerializableJsValue).map_err(serde::de::Error::custom)
}
}
// ===============
// === Options ===
// ===============
// === AutoClose ===
/// Helper structure for [`AutoClose`] so it serializes in a way compatible with the JS library.
///
/// Users of this module should not deal directly with this type. Instead, rely on
/// [`AutoClose::After`] or [`AutoClose::Never`]
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
#[serde(untagged)]
pub enum AutoCloseInner {
/// Auto close after a delay expressed in milliseconds.
Delay(u32),
/// Do not auto close. The boolean value must be `false`.
ShouldEver(bool),
}
/// Represents the auto-close delay of a notification.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, Deref)]
#[serde(transparent)]
pub struct AutoClose(AutoCloseInner);
impl AutoClose {
/// Auto close after a delay expressed in milliseconds.
#[allow(non_snake_case)]
pub fn After(delay_ms: u32) -> Self {
Self(AutoCloseInner::Delay(delay_ms))
}
/// Do not auto close.
#[allow(non_snake_case)]
pub fn Never() -> Self {
Self(AutoCloseInner::ShouldEver(false))
}
}
// === Type ===
/// Represents the type of a notification.
///
/// Affects styling and icon.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, strum::AsRefStr)]
#[serde(rename_all = "kebab-case")]
#[strum(serialize_all = "kebab-case")]
#[allow(missing_docs)]
pub enum Type {
Info,
Success,
Warning,
Error,
Default,
}
// === Position ===
/// Represents the position of a notification on the screen.
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
#[allow(missing_docs)]
pub enum Position {
TopRight,
TopCenter,
TopLeft,
BottomRight,
BottomCenter,
BottomLeft,
}
// === DraggableDirection ===
/// Direction that the notification can be dragged (swiped) to dismiss it.
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
#[allow(missing_docs)]
pub enum DraggableDirection {
X,
Y,
}
// === Theme ===
/// Themes supported by the library.
#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
#[allow(missing_docs)]
pub enum Theme {
Light,
Dark,
Colored,
}
// === Options ===
/// Customization options for the notification.
///
/// Note that it is not necessary to set any of these. All options marked as `None` will be
/// auto-filled with default values.
#[derive(Debug, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
#[allow(missing_docs)]
pub struct Options {
/// Unique notification identifier.
pub toast_id: Option<String>,
/// Type of the notification, affecting styling and icon. Default: `Type::Default`.
pub r#type: Option<Type>,
/// The position where the toast should appear. Default: `bottom-right`.
pub position: Option<Position>,
/// Time in milliseconds after the toast is removed. Default: `5000`.
pub auto_close: Option<AutoClose>,
/// Whether to show a close button. Default: `true`.
pub close_button: Option<bool>,
// pub transition: Option<ToastTransition>,
/// Hide or show the progress bar. `Default: false`
pub hide_progress_bar: Option<bool>,
/// Pause the timer when the mouse hover the toast. Default: `true`.
pub pause_on_hover: Option<bool>,
/// Pause the timer when the window loses focus. Default: `true`.
pub pause_on_focus_loss: Option<bool>,
/// Remove the toast when clicked. Default: `true`.
pub close_on_click: Option<bool>,
/// An optional css class to set.
pub class_name: Option<String>,
/// An optional css class to set for the toast content.
pub body_class_name: Option<String>,
/// An optional inline style to apply. This should be a JS object with CSS properties.
pub style: Option<SerializableJsValue>,
/// An optional inline style to apply for the toast content. This should be a JS object with
/// CSS properties.
pub body_style: Option<SerializableJsValue>,
/// An optional css class to set for the progress bar.
pub progress_class_name: Option<String>,
/// An optional inline style to apply for the progress bar. This should be a JS object with
/// CSS properties.
pub progress_style: Option<SerializableJsValue>,
/// Allow toast to be draggable. `Default: true`.
pub draggable: Option<bool>,
/// The percentage of the toast's width it takes for a drag to dismiss a toast. `Default: 80`.
pub draggable_percent: Option<f32>,
/// Specify in which direction should you swipe to dismiss the toast. `Default: "x"`.
pub draggable_direction: Option<DraggableDirection>,
/// Set id to handle multiple `ToastContainer` instances.
pub container_id: Option<String>,
/// Define the [ARIA role](https://www.w3.org/WAI/PF/aria/roles) for the notification. `Default: "alert"`.
pub role: Option<String>,
/// Add a delay in ms before the toast appear.
pub delay: Option<u32>,
/// Whether the ongoing action is still in progress.
///
/// The auto-close timer will not start if this is set to `true`.
pub is_loading: Option<bool>,
/// Support right to left display content. `Default: false`.
pub rtl: Option<bool>,
/// Theme to use.
pub theme: Option<Theme>,
/// Used to display a custom icon. Set it to `false` to prevent the icons from being displayed
pub icon: Option<bool>,
/// Any additional data to pass to the toast.
pub data: Option<SerializableJsValue>,
}
impl_try_from_jsvalue! {Options}
// === Update ===
/// Update options for a toast.
///
/// Just like [`Options`], but also includes a `render` field that allows to update the
/// notification message.
#[derive(Debug, Serialize, Deserialize, Default, Deref, DerefMut)]
#[serde(rename_all = "camelCase")]
pub struct UpdateOptions {
/// New state of the toast.
#[deref]
#[deref_mut]
#[serde(flatten)]
pub options: Options,
/// Used to update a toast. Pass any valid ReactNode(string, number, component).
pub render: Option<SerializableJsValue>,
}
impl_try_from_jsvalue! { UpdateOptions }
impl UpdateOptions {
/// Allows set a new message in the notification.
///
/// Useful only for update a toast.
pub fn render_string(mut self, render: impl AsRef<str>) -> Self {
let message = render.as_ref();
let value = JsValue::from_str(message);
self.render = Some(value.into());
self
}
}
// =============
// === Tests ===
// =============
#[cfg(test)]
mod tests {
use super::*;
use wasm_bindgen_test::wasm_bindgen_test;
use wasm_bindgen_test::wasm_bindgen_test_configure;
wasm_bindgen_test_configure!(run_in_browser);
#[test]
fn test_auto_close_delay() {
let auto_close = AutoClose::After(5000);
let serialized = serde_json::to_string(&auto_close).unwrap();
assert_eq!(serialized, "5000");
}
#[test]
fn test_auto_close_never() {
let auto_close = AutoClose::Never();
let serialized = serde_json::to_string(&auto_close).unwrap();
assert_eq!(serialized, "false");
}
#[wasm_bindgen_test]
fn test_options_marshalling() {
let options = Options { theme: Some(Theme::Dark), ..default() };
let js_value = JsValue::try_from(&options).unwrap();
// Make sure that `js_value` is a valid JS object.
assert!(js_value.is_object());
let js_object = js_value.dyn_into::<js_sys::Object>().unwrap();
// Make sure that `js_object` has a `theme` property.
assert!(js_object.has_own_property(&"theme".into()));
// Make sure that `js_object.theme` is a string.
let theme = js_sys::Reflect::get(&js_object, &"theme".into()).unwrap();
assert!(theme.is_string());
// Make sure that `js_object.theme` is equal to "dark".
let theme = theme.as_string().unwrap();
assert_eq!(theme, "dark");
}
}

View File

@ -0,0 +1,171 @@
//! This module provides relatively low-level wrappers for the
//! [`react-toastify`](https://fkhadra.github.io/react-toastify/introduction) library API.
use crate::prelude::*;
use wasm_bindgen::prelude::*;
use crate::notification::Type;
use crate::notification::UpdateOptions;
// ==================
// === Constants ===
// =================
/// Toastify's [`toast API`](https://fkhadra.github.io/react-toastify/api/toast) field name within
/// our application JS object.
const TOAST_FIELD_NAME: &str = "toast";
// ========================
// === Toast API Handle ===
// ========================
/// Get the global (set in JS app object) toast API handle.
pub fn get_toast() -> Result<ToastAPI, JsValue> {
// let window = &ensogl::system::web::window; // JSValue wrapper
let window = ensogl::system::web::binding::wasm::get_window();
let app_field_name = enso_config::CONFIG.window_app_scope_name;
// Hopefully, window contains a field with the name, so we can access it.
let app = js_sys::Reflect::get(window.as_ref(), &app_field_name.into())?;
let toastify = js_sys::Reflect::get(app.as_ref(), &TOAST_FIELD_NAME.into())?;
Ok(toastify.into())
}
// ===================
// === JS bindings ===
// ===================
// Wrappers for [`toast`](https://react-hot-toast.com/docs/toast) API.
#[wasm_bindgen(inline_js = r#"
export function sendToast(toast, message, method, options) {
const target = toast[method];
return target(message, options);
}
"#)]
extern "C" {
/// The wrapper for the toastify API.
#[derive(Clone, Debug)]
pub type ToastifyAPI;
/// The wrapper for the toast API.
#[derive(Clone, Debug)]
pub type ToastAPI;
/// The unique identifier of a toast.
#[derive(Clone, Debug)]
pub type Id;
/// The unique identifier of a toast.
#[derive(Clone, Debug)]
pub type ContainerId;
/// Generalized wrapper for calling any kind of toast.
#[wasm_bindgen(catch)]
#[allow(unsafe_code)]
pub fn sendToast(
this: &ToastAPI,
message: &str,
method: &str,
options: &JsValue,
) -> Result<Id, JsValue>;
/// Supply a promise or a function that return a promise and the notification will be
/// updated if it resolves or fails. When the promise is pending a spinner is displayed.
#[wasm_bindgen(catch, method, js_name = promise)]
#[allow(unsafe_code)]
pub fn promise(
this: &ToastAPI,
promise: &js_sys::Promise,
promise_params: &JsValue,
options: &JsValue,
) -> Result<Id, JsValue>;
/// Wrapper for dismissing a toast.
#[wasm_bindgen(catch, method)]
#[allow(unsafe_code)]
pub fn dismiss(this: &ToastAPI, id: &Id) -> Result<(), JsValue>;
/// Wrapper for dismissing a toast.
#[wasm_bindgen(catch, method, js_name = dismiss)]
#[allow(unsafe_code)]
pub fn dismiss_all(this: &ToastAPI) -> Result<(), JsValue>;
/// Check if a toast is displayed or not.
#[wasm_bindgen(catch, method, js_name = isActive)]
#[allow(unsafe_code)]
pub fn is_active(this: &ToastAPI, id: &Id) -> Result<bool, JsValue>;
/// Update a toast.
#[wasm_bindgen(catch, method)]
#[allow(unsafe_code)]
pub fn update(this: &ToastAPI, id: &Id, options: &JsValue) -> Result<(), JsValue>;
/// Clear waiting queue when working with limit in a specific container.
#[wasm_bindgen(catch, method, js_name = clearWaitingQueue)]
#[allow(unsafe_code)]
pub fn clear_waiting_queue_in(this: &ToastAPI, opts: &ContainerId) -> Result<(), JsValue>;
/// Clear waiting queue when working with limit in the default container.
#[wasm_bindgen(catch, method, js_name = clearWaitingQueue)]
#[allow(unsafe_code)]
pub fn clear_waiting_queue(this: &ToastAPI) -> Result<(), JsValue>;
/// Completes the controlled progress bar.
#[wasm_bindgen(catch, method)]
#[allow(unsafe_code)]
pub fn done(this: &ToastAPI, id: &Id) -> Result<(), JsValue>;
}
// ===========================
// === JS-types extensions ===
// ===========================
impl ToastAPI {
/// Send the toast notification.
pub fn send(&self, message: &str, method: &str, options: &JsValue) -> Result<Id, JsValue> {
sendToast(self, message, method, options)
}
}
impl Id {
/// Dismisses the toast.
pub fn dismiss(&self) -> Result<(), JsValue> {
get_toast()?.dismiss(self)
}
/// Completes the controlled progress bar.
pub fn done(&self) -> Result<(), JsValue> {
get_toast()?.done(self)
}
/// Check if a toast is displayed or not.
pub fn is_active(&self) -> Result<bool, JsValue> {
get_toast()?.is_active(self)
}
/// Update a toast.
pub fn update(&self, options: &UpdateOptions) -> Result<(), JsValue> {
get_toast()?.update(self, &options.try_into()?)
}
}
impl ContainerId {
/// Clear queue of notifications for this container (relevant if limit is set).
pub fn clear_waiting_queue(&self) -> Result<(), JsValue> {
get_toast()?.clear_waiting_queue_in(self)
}
}
/// Wrapper for sending arbitrary kind of toast.
pub fn toast(message: &str, r#type: Type, options: &JsValue) -> Result<Id, JsValue> {
let method: &str = r#type.as_ref();
get_toast()?.send(message, method, options)
}

View File

@ -23,7 +23,8 @@
}, },
"dependencies": { "dependencies": {
"@types/semver": "^7.3.9", "@types/semver": "^7.3.9",
"enso-content-config": "^1.0.0" "enso-content-config": "^1.0.0",
"react-toastify": "^9.1.3"
}, },
"devDependencies": { "devDependencies": {
"@esbuild-plugins/node-globals-polyfill": "^0.2.3", "@esbuild-plugins/node-globals-polyfill": "^0.2.3",

View File

@ -3,6 +3,7 @@
* allowing to choose a debug rendering test from. */ * allowing to choose a debug rendering test from. */
import * as semver from 'semver' import * as semver from 'semver'
import * as toastify from 'react-toastify'
import * as common from 'enso-common' import * as common from 'enso-common'
import * as contentConfig from 'enso-content-config' import * as contentConfig from 'enso-content-config'
@ -152,6 +153,7 @@ interface AuthenticationConfig {
/** Contains the entrypoint into the IDE. */ /** Contains the entrypoint into the IDE. */
class Main implements AppRunner { class Main implements AppRunner {
app: app.App | null = null app: app.App | null = null
toast = toastify.toast
/** Stop an app instance, if one is running. */ /** Stop an app instance, if one is running. */
stopApp() { stopApp() {

View File

@ -75,7 +75,7 @@ function esbuildPluginGenerateTailwind(): esbuild.Plugin {
tailwindConfigLastModified !== tailwindConfigNewLastModified tailwindConfigLastModified !== tailwindConfigNewLastModified
tailwindConfigLastModified = tailwindConfigNewLastModified tailwindConfigLastModified = tailwindConfigNewLastModified
}) })
build.onLoad({ filter: /\.css$/ }, async loadArgs => { build.onLoad({ filter: /tailwind\.css$/ }, async loadArgs => {
const lastModified = (await fs.stat(loadArgs.path)).mtimeMs const lastModified = (await fs.stat(loadArgs.path)).mtimeMs
let output = cachedOutput[loadArgs.path] let output = cachedOutput[loadArgs.path]
if (!output || output.lastModified !== lastModified || tailwindConfigWasModified) { if (!output || output.lastModified !== lastModified || tailwindConfigWasModified) {

View File

@ -31,6 +31,7 @@
"eslint": "^8.32.0", "eslint": "^8.32.0",
"eslint-plugin-jsdoc": "^39.6.8", "eslint-plugin-jsdoc": "^39.6.8",
"eslint-plugin-react": "^7.32.1", "eslint-plugin-react": "^7.32.1",
"react-toastify": "^9.1.3",
"tailwindcss": "^3.2.7", "tailwindcss": "^3.2.7",
"typescript": "^4.9.4" "typescript": "^4.9.4"
}, },

View File

@ -16,7 +16,7 @@
"@fortawesome/free-brands-svg-icons": "^6.3.0", "@fortawesome/free-brands-svg-icons": "^6.3.0",
"@fortawesome/react-fontawesome": "^0.2.0", "@fortawesome/react-fontawesome": "^0.2.0",
"enso-common": "^1.0.0", "enso-common": "^1.0.0",
"react-hot-toast": "^2.4.0", "react-toastify": "^9.1.3",
"react-router-dom": "^6.8.1", "react-router-dom": "^6.8.1",
"ts-results": "^3.3.0" "ts-results": "^3.3.0"
}, },

View File

@ -2,7 +2,7 @@
* email address. */ * email address. */
import * as React from 'react' import * as React from 'react'
import * as router from 'react-router-dom' import * as router from 'react-router-dom'
import toast from 'react-hot-toast' import * as toastify from 'react-toastify'
import * as app from '../../components/app' import * as app from '../../components/app'
import * as authModule from '../providers/auth' import * as authModule from '../providers/auth'
@ -41,7 +41,7 @@ function ConfirmRegistration() {
navigate(app.LOGIN_PATH + location.search.toString()) navigate(app.LOGIN_PATH + location.search.toString())
} catch (error) { } catch (error) {
logger.error('Error while confirming sign-up', error) logger.error('Error while confirming sign-up', error)
toast.error( toastify.toast.error(
'Something went wrong! Please try again or contact the administrators.' 'Something went wrong! Please try again or contact the administrators.'
) )
navigate(app.LOGIN_PATH) navigate(app.LOGIN_PATH)

View File

@ -1,7 +1,7 @@
/** @file Registration container responsible for rendering and interactions in sign up flow. */ /** @file Registration container responsible for rendering and interactions in sign up flow. */
import * as React from 'react' import * as React from 'react'
import * as router from 'react-router-dom' import * as router from 'react-router-dom'
import toast from 'react-hot-toast' import * as toastify from 'react-toastify'
import AtIcon from 'enso-assets/at.svg' import AtIcon from 'enso-assets/at.svg'
import CreateAccountIcon from 'enso-assets/create_account.svg' import CreateAccountIcon from 'enso-assets/create_account.svg'
@ -41,7 +41,7 @@ function Registration() {
const onSubmit = () => { const onSubmit = () => {
/** The password & confirm password fields must match. */ /** The password & confirm password fields must match. */
if (password !== confirmPassword) { if (password !== confirmPassword) {
toast.error('Passwords do not match.') toastify.toast.error('Passwords do not match.')
return Promise.resolve() return Promise.resolve()
} else { } else {
return auth.signUp(email, password, organizationId) return auth.signUp(email, password, organizationId)

View File

@ -2,7 +2,7 @@
* flow. */ * flow. */
import * as React from 'react' import * as React from 'react'
import * as router from 'react-router-dom' import * as router from 'react-router-dom'
import toast from 'react-hot-toast' import * as toastify from 'react-toastify'
import ArrowRightIcon from 'enso-assets/arrow_right.svg' import ArrowRightIcon from 'enso-assets/arrow_right.svg'
import AtIcon from 'enso-assets/at.svg' import AtIcon from 'enso-assets/at.svg'
@ -44,7 +44,7 @@ function ResetPassword() {
const onSubmit = () => { const onSubmit = () => {
if (newPassword !== newPasswordConfirm) { if (newPassword !== newPasswordConfirm) {
toast.error('Passwords do not match') toastify.toast.error('Passwords do not match')
return Promise.resolve() return Promise.resolve()
} else { } else {
return resetPassword(email, code, newPassword) return resetPassword(email, code, newPassword)

View File

@ -5,7 +5,7 @@
* hook also provides methods for registering a user, logging in, logging out, etc. */ * hook also provides methods for registering a user, logging in, logging out, etc. */
import * as React from 'react' import * as React from 'react'
import * as router from 'react-router-dom' import * as router from 'react-router-dom'
import toast from 'react-hot-toast' import * as toastify from 'react-toastify'
import * as app from '../../components/app' import * as app from '../../components/app'
import * as authServiceModule from '../service' import * as authServiceModule from '../service'
@ -211,7 +211,7 @@ export function AuthProvider(props: AuthProviderProps) {
const goOffline = React.useCallback( const goOffline = React.useCallback(
(shouldShowToast = true) => { (shouldShowToast = true) => {
if (shouldShowToast) { if (shouldShowToast) {
toast.error('You are offline, switching to offline mode.') toastify.toast.error('You are offline, switching to offline mode.')
} }
goOfflineInternal() goOfflineInternal()
navigate(app.DASHBOARD_PATH) navigate(app.DASHBOARD_PATH)
@ -311,7 +311,7 @@ export function AuthProvider(props: AuthProviderProps) {
fetchSession().catch(error => { fetchSession().catch(error => {
if (isUserFacingError(error)) { if (isUserFacingError(error)) {
toast.error(error.message) toastify.toast.error(error.message)
logger.error(error.message) logger.error(error.message)
} else { } else {
logger.error(error) logger.error(error)
@ -336,12 +336,12 @@ export function AuthProvider(props: AuthProviderProps) {
const withLoadingToast = const withLoadingToast =
<T extends unknown[], R>(action: (...args: T) => Promise<R>) => <T extends unknown[], R>(action: (...args: T) => Promise<R>) =>
async (...args: T) => { async (...args: T) => {
const loadingToast = toast.loading(MESSAGES.pleaseWait) const loadingToast = toastify.toast.loading(MESSAGES.pleaseWait)
let result let result
try { try {
result = await action(...args) result = await action(...args)
} finally { } finally {
toast.dismiss(loadingToast) toastify.toast.dismiss(loadingToast)
} }
return result return result
} }
@ -349,10 +349,10 @@ export function AuthProvider(props: AuthProviderProps) {
const signUp = async (username: string, password: string, organizationId: string | null) => { const signUp = async (username: string, password: string, organizationId: string | null) => {
const result = await cognito.signUp(username, password, organizationId) const result = await cognito.signUp(username, password, organizationId)
if (result.ok) { if (result.ok) {
toast.success(MESSAGES.signUpSuccess) toastify.toast.success(MESSAGES.signUpSuccess)
navigate(app.LOGIN_PATH) navigate(app.LOGIN_PATH)
} else { } else {
toast.error(result.val.message) toastify.toast.error(result.val.message)
} }
return result.ok return result.ok
} }
@ -368,7 +368,7 @@ export function AuthProvider(props: AuthProviderProps) {
} }
} }
toast.success(MESSAGES.confirmSignUpSuccess) toastify.toast.success(MESSAGES.confirmSignUpSuccess)
navigate(app.LOGIN_PATH) navigate(app.LOGIN_PATH)
return result.ok return result.ok
} }
@ -376,13 +376,13 @@ export function AuthProvider(props: AuthProviderProps) {
const signInWithPassword = async (email: string, password: string) => { const signInWithPassword = async (email: string, password: string) => {
const result = await cognito.signInWithPassword(email, password) const result = await cognito.signInWithPassword(email, password)
if (result.ok) { if (result.ok) {
toast.success(MESSAGES.signInWithPasswordSuccess) toastify.toast.success(MESSAGES.signInWithPasswordSuccess)
} else { } else {
if (result.val.kind === 'UserNotFound') { if (result.val.kind === 'UserNotFound') {
navigate(app.REGISTRATION_PATH) navigate(app.REGISTRATION_PATH)
} }
toast.error(result.val.message) toastify.toast.error(result.val.message)
} }
return result.ok return result.ok
} }
@ -393,14 +393,14 @@ export function AuthProvider(props: AuthProviderProps) {
email: string email: string
) => { ) => {
if (backend.type === backendModule.BackendType.local) { if (backend.type === backendModule.BackendType.local) {
toast.error('You cannot set your username on the local backend.') toastify.toast.error('You cannot set your username on the local backend.')
return false return false
} else { } else {
try { try {
const organizationId = await authService.cognito.organizationId() const organizationId = await authService.cognito.organizationId()
// This should not omit success and error toasts as it is not possible // This should not omit success and error toasts as it is not possible
// to render this optimistically. // to render this optimistically.
await toast.promise( await toastify.toast.promise(
backend.createUser({ backend.createUser({
userName: username, userName: username,
userEmail: backendModule.EmailAddress(email), userEmail: backendModule.EmailAddress(email),
@ -412,7 +412,7 @@ export function AuthProvider(props: AuthProviderProps) {
{ {
success: MESSAGES.setUsernameSuccess, success: MESSAGES.setUsernameSuccess,
error: MESSAGES.setUsernameFailure, error: MESSAGES.setUsernameFailure,
loading: MESSAGES.setUsernameLoading, pending: MESSAGES.setUsernameLoading,
} }
) )
navigate(app.DASHBOARD_PATH) navigate(app.DASHBOARD_PATH)
@ -426,10 +426,10 @@ export function AuthProvider(props: AuthProviderProps) {
const forgotPassword = async (email: string) => { const forgotPassword = async (email: string) => {
const result = await cognito.forgotPassword(email) const result = await cognito.forgotPassword(email)
if (result.ok) { if (result.ok) {
toast.success(MESSAGES.forgotPasswordSuccess) toastify.toast.success(MESSAGES.forgotPasswordSuccess)
navigate(app.RESET_PASSWORD_PATH) navigate(app.RESET_PASSWORD_PATH)
} else { } else {
toast.error(result.val.message) toastify.toast.error(result.val.message)
} }
return result.ok return result.ok
} }
@ -437,10 +437,10 @@ export function AuthProvider(props: AuthProviderProps) {
const resetPassword = async (email: string, code: string, password: string) => { const resetPassword = async (email: string, code: string, password: string) => {
const result = await cognito.forgotPasswordSubmit(email, code, password) const result = await cognito.forgotPasswordSubmit(email, code, password)
if (result.ok) { if (result.ok) {
toast.success(MESSAGES.resetPasswordSuccess) toastify.toast.success(MESSAGES.resetPasswordSuccess)
navigate(app.LOGIN_PATH) navigate(app.LOGIN_PATH)
} else { } else {
toast.error(result.val.message) toastify.toast.error(result.val.message)
} }
return result.ok return result.ok
} }
@ -448,9 +448,9 @@ export function AuthProvider(props: AuthProviderProps) {
const changePassword = async (oldPassword: string, newPassword: string) => { const changePassword = async (oldPassword: string, newPassword: string) => {
const result = await cognito.changePassword(oldPassword, newPassword) const result = await cognito.changePassword(oldPassword, newPassword)
if (result.ok) { if (result.ok) {
toast.success(MESSAGES.changePasswordSuccess) toastify.toast.success(MESSAGES.changePasswordSuccess)
} else { } else {
toast.error(result.val.message) toastify.toast.error(result.val.message)
} }
return result.ok return result.ok
} }
@ -461,10 +461,10 @@ export function AuthProvider(props: AuthProviderProps) {
setUserSession(null) setUserSession(null)
// This should not omit success and error toasts as it is not possible // This should not omit success and error toasts as it is not possible
// to render this optimistically. // to render this optimistically.
await toast.promise(cognito.signOut(), { await toastify.toast.promise(cognito.signOut(), {
success: MESSAGES.signOutSuccess, success: MESSAGES.signOutSuccess,
error: MESSAGES.signOutError, error: MESSAGES.signOutError,
loading: MESSAGES.signOutLoading, pending: MESSAGES.signOutLoading,
}) })
return true return true
} }

View File

@ -36,7 +36,7 @@
import * as React from 'react' import * as React from 'react'
import * as router from 'react-router-dom' import * as router from 'react-router-dom'
import * as toast from 'react-hot-toast' import * as toastify from 'react-toastify'
import * as detect from 'enso-common/src/detect' import * as detect from 'enso-common/src/detect'
@ -131,7 +131,7 @@ function App(props: AppProps) {
* will redirect the user between the login/register pages and the dashboard. */ * will redirect the user between the login/register pages and the dashboard. */
return ( return (
<> <>
<toast.Toaster toastOptions={{ style: { maxWidth: '100%' } }} position="top-center" /> <toastify.ToastContainer position="top-center" theme="light" closeOnClick={false} />
<Router basename={getMainPageUrl().pathname}> <Router basename={getMainPageUrl().pathname}>
<AppRouter {...props} /> <AppRouter {...props} />
</Router> </Router>

View File

@ -1,6 +1,6 @@
/** @file Managing the logic and displaying the UI for the password change function. */ /** @file Managing the logic and displaying the UI for the password change function. */
import * as React from 'react' import * as React from 'react'
import toast from 'react-hot-toast' import * as toastify from 'react-toastify'
import ArrowRightIcon from 'enso-assets/arrow_right.svg' import ArrowRightIcon from 'enso-assets/arrow_right.svg'
import LockIcon from 'enso-assets/lock.svg' import LockIcon from 'enso-assets/lock.svg'
@ -29,7 +29,7 @@ function ChangePasswordModal() {
const onSubmit = async () => { const onSubmit = async () => {
if (newPassword !== confirmNewPassword) { if (newPassword !== confirmNewPassword) {
toast.error('Passwords do not match.') toastify.toast.error('Passwords do not match.')
} else { } else {
const success = await changePassword(oldPassword, newPassword) const success = await changePassword(oldPassword, newPassword)
if (success) { if (success) {

View File

@ -1,7 +1,7 @@
/** @file A WebSocket-based chat directly to official support on the official Discord server. */ /** @file A WebSocket-based chat directly to official support on the official Discord server. */
import * as React from 'react' import * as React from 'react'
import * as reactDom from 'react-dom' import * as reactDom from 'react-dom'
import toast from 'react-hot-toast' import * as toastify from 'react-toastify'
import CloseLargeIcon from 'enso-assets/close_large.svg' import CloseLargeIcon from 'enso-assets/close_large.svg'
import DefaultUserIcon from 'enso-assets/default_user.svg' import DefaultUserIcon from 'enso-assets/default_user.svg'
@ -611,7 +611,7 @@ function Chat(props: ChatProps) {
const threadData = threads.find(thread => thread.id === newThreadId) const threadData = threads.find(thread => thread.id === newThreadId)
if (threadData == null) { if (threadData == null) {
const message = `Unknown thread id '${newThreadId}'.` const message = `Unknown thread id '${newThreadId}'.`
toast.error(message) toastify.toast.error(message)
logger.error(message) logger.error(message)
} else { } else {
sendMessage({ sendMessage({

View File

@ -1,6 +1,6 @@
/** @file Modal for confirming delete of any type of asset. */ /** @file Modal for confirming delete of any type of asset. */
import * as React from 'react' import * as React from 'react'
import toast from 'react-hot-toast' import * as toastify from 'react-toastify'
import CloseIcon from 'enso-assets/close.svg' import CloseIcon from 'enso-assets/close.svg'
@ -32,10 +32,8 @@ function ConfirmDeleteModal(props: ConfirmDeleteModalProps) {
try { try {
doDelete() doDelete()
} catch (error) { } catch (error) {
const message = `Could not delete ${description}: ${ const message = errorModule.getMessageOrToString(error)
errorModule.tryGetMessage(error) ?? 'unknown error.' toastify.toast.error(message)
}`
toast.error(message)
logger.error(message) logger.error(message)
} }
} }

View File

@ -1,6 +1,6 @@
/** @file Table displaying a list of directories. */ /** @file Table displaying a list of directories. */
import * as React from 'react' import * as React from 'react'
import toast from 'react-hot-toast' import * as toastify from 'react-toastify'
import DirectoryIcon from 'enso-assets/directory.svg' import DirectoryIcon from 'enso-assets/directory.svg'
import PlusIcon from 'enso-assets/plus.svg' import PlusIcon from 'enso-assets/plus.svg'
@ -111,7 +111,6 @@ function DirectoryName(props: InternalDirectoryNameProps) {
rowState, rowState,
setRowState, setRowState,
} = props } = props
const logger = loggerProvider.useLogger()
const { backend } = backendProvider.useBackend() const { backend } = backendProvider.useBackend()
const doRename = async (newName: string) => { const doRename = async (newName: string) => {
@ -120,11 +119,7 @@ function DirectoryName(props: InternalDirectoryNameProps) {
await backend.updateDirectory(item.id, { title: newName }, item.title) await backend.updateDirectory(item.id, { title: newName }, item.title)
return return
} catch (error) { } catch (error) {
const message = `Error renaming folder: ${ errorModule.toastAndLog('Error renaming folder', error)
errorModule.tryGetMessage(error) ?? 'unknown error'
}`
toast.error(message)
logger.error(message)
throw error throw error
} }
} }
@ -274,11 +269,7 @@ function DirectoryRow(
} catch (error) { } catch (error) {
setStatus(presence.Presence.present) setStatus(presence.Presence.present)
markItemAsVisible(key) markItemAsVisible(key)
const message = `Unable to delete directory: ${ errorModule.toastAndLog('Unable to delete directory', error)
errorModule.tryGetMessage(error) ?? 'unknown error.'
}`
toast.error(message)
logger.error(message)
} }
} }
} }
@ -289,7 +280,7 @@ function DirectoryRow(
if (key === event.placeholderId) { if (key === event.placeholderId) {
if (backend.type !== backendModule.BackendType.remote) { if (backend.type !== backendModule.BackendType.remote) {
const message = 'Folders cannot be created on the local backend.' const message = 'Folders cannot be created on the local backend.'
toast.error(message) toastify.toast.error(message)
logger.error(message) logger.error(message)
} else { } else {
setStatus(presence.Presence.inserting) setStatus(presence.Presence.inserting)
@ -309,11 +300,7 @@ function DirectoryRow(
type: directoryListEventModule.DirectoryListEventType.delete, type: directoryListEventModule.DirectoryListEventType.delete,
directoryId: key, directoryId: key,
}) })
const message = `Error creating new folder: ${ errorModule.toastAndLog('Error creating new folder', error)
errorModule.tryGetMessage(error) ?? 'unknown error.'
}`
toast.error(message)
logger.error(message)
} }
} }
} }

View File

@ -1,6 +1,6 @@
/** @file The directory header bar and directory item listing. */ /** @file The directory header bar and directory item listing. */
import * as React from 'react' import * as React from 'react'
import toast from 'react-hot-toast' import * as toastify from 'react-toastify'
import * as common from 'enso-common' import * as common from 'enso-common'
@ -228,7 +228,7 @@ function DirectoryView(props: DirectoryViewProps) {
setInitialized(true) setInitialized(true)
if (!newProjectAssets.some(asset => asset.title === initialProjectName)) { if (!newProjectAssets.some(asset => asset.title === initialProjectName)) {
const errorMessage = `No project named '${initialProjectName}' was found.` const errorMessage = `No project named '${initialProjectName}' was found.`
toast.error(errorMessage) toastify.toast.error(errorMessage)
logger.error(`Error opening project on startup: ${errorMessage}`) logger.error(`Error opening project on startup: ${errorMessage}`)
} }
} }

View File

@ -1,7 +1,7 @@
/** @file Header menubar for the directory listing, containing information about /** @file Header menubar for the directory listing, containing information about
* the current directory and some configuration options. */ * the current directory and some configuration options. */
import * as React from 'react' import * as React from 'react'
import toast from 'react-hot-toast' import * as toastify from 'react-toastify'
import ArrowRightSmallIcon from 'enso-assets/arrow_right_small.svg' import ArrowRightSmallIcon from 'enso-assets/arrow_right_small.svg'
import DownloadIcon from 'enso-assets/download.svg' import DownloadIcon from 'enso-assets/download.svg'
@ -52,18 +52,18 @@ function DriveBar(props: DriveBarProps) {
// TODO[sb]: Allow uploading `.enso-project`s // TODO[sb]: Allow uploading `.enso-project`s
// https://github.com/enso-org/cloud-v2/issues/510 // https://github.com/enso-org/cloud-v2/issues/510
const message = 'Files cannot be uploaded to the local backend.' const message = 'Files cannot be uploaded to the local backend.'
toast.error(message) toastify.toast.error(message)
logger.error(message) logger.error(message)
} else if ( } else if (
event.currentTarget.files == null || event.currentTarget.files == null ||
event.currentTarget.files.length === 0 event.currentTarget.files.length === 0
) { ) {
toast.success('No files selected to upload.') toastify.toast.success('No files selected to upload.')
} else if (directoryId == null) { } else if (directoryId == null) {
// This should never happen, however display a nice error message in case // This should never happen, however display a nice error message in case
// it somehow does. // it somehow does.
const message = 'Files cannot be uploaded while offline.' const message = 'Files cannot be uploaded while offline.'
toast.error(message) toastify.toast.error(message)
logger.error(message) logger.error(message)
} else { } else {
dispatchFileListEvent({ dispatchFileListEvent({

View File

@ -1,6 +1,6 @@
/** @file Table displaying a list of files. */ /** @file Table displaying a list of files. */
import * as React from 'react' import * as React from 'react'
import toast from 'react-hot-toast' import * as toastify from 'react-toastify'
import PlusIcon from 'enso-assets/plus.svg' import PlusIcon from 'enso-assets/plus.svg'
@ -76,18 +76,18 @@ function FileNameHeading(props: InternalFileNameHeadingProps) {
// TODO[sb]: Allow uploading `.enso-project`s // TODO[sb]: Allow uploading `.enso-project`s
// https://github.com/enso-org/cloud-v2/issues/510 // https://github.com/enso-org/cloud-v2/issues/510
const message = 'Files cannot be uploaded to the local backend.' const message = 'Files cannot be uploaded to the local backend.'
toast.error(message) toastify.toast.error(message)
logger.error(message) logger.error(message)
} else if ( } else if (
event.currentTarget.files == null || event.currentTarget.files == null ||
event.currentTarget.files.length === 0 event.currentTarget.files.length === 0
) { ) {
toast.success('No files selected to upload.') toastify.toast.success('No files selected to upload.')
} else if (directoryId == null) { } else if (directoryId == null) {
// This should never happen, however display a nice error message in case // This should never happen, however display a nice error message in case
// it somehow does. // it somehow does.
const message = 'Files cannot be uploaded while offline.' const message = 'Files cannot be uploaded while offline.'
toast.error(message) toastify.toast.error(message)
logger.error(message) logger.error(message)
} else { } else {
dispatchFileListEvent({ dispatchFileListEvent({
@ -284,11 +284,7 @@ function FileRow(
} catch (error) { } catch (error) {
setStatus(presence.Presence.present) setStatus(presence.Presence.present)
markItemAsVisible(key) markItemAsVisible(key)
const message = `Unable to delete file: ${ errorModule.toastAndLog('Unable to delete file', error)
errorModule.tryGetMessage(error) ?? 'unknown error.'
}`
toast.error(message)
logger.error(message)
} }
} }
} }
@ -300,7 +296,7 @@ function FileRow(
if (file != null) { if (file != null) {
if (backend.type !== backendModule.BackendType.remote) { if (backend.type !== backendModule.BackendType.remote) {
const message = 'Files cannot be uploaded on the local backend.' const message = 'Files cannot be uploaded on the local backend.'
toast.error(message) toastify.toast.error(message)
logger.error(message) logger.error(message)
} else { } else {
setStatus(presence.Presence.inserting) setStatus(presence.Presence.inserting)
@ -324,11 +320,7 @@ function FileRow(
type: fileListEventModule.FileListEventType.delete, type: fileListEventModule.FileListEventType.delete,
fileId: key, fileId: key,
}) })
const message = `Error creating new file: ${ errorModule.toastAndLog('Error creating new file', error)
errorModule.tryGetMessage(error) ?? 'unknown error.'
}`
toast.error(message)
logger.error(message)
} }
} }
} }

View File

@ -1,6 +1,6 @@
/** @file A modal with inputs for user email and permission level. */ /** @file A modal with inputs for user email and permission level. */
import * as React from 'react' import * as React from 'react'
import toast from 'react-hot-toast' import * as toastify from 'react-toastify'
import CloseIcon from 'enso-assets/close.svg' import CloseIcon from 'enso-assets/close.svg'
@ -234,7 +234,7 @@ export function ManagePermissionsModal(props: ManagePermissionsModalProps) {
userEmail: backendModule.EmailAddress(firstEmail), userEmail: backendModule.EmailAddress(firstEmail),
}) })
} catch (error) { } catch (error) {
toast.error(errorModule.tryGetMessage(error) ?? 'Unknown error.') toastify.toast.error(errorModule.tryGetMessage(error) ?? 'Unknown error.')
} }
} else if (finalUsers.length !== 0) { } else if (finalUsers.length !== 0) {
unsetModal() unsetModal()
@ -250,7 +250,9 @@ export function ManagePermissionsModal(props: ManagePermissionsModalProps) {
} catch { } catch {
onFailure?.(finalUsers, permissionsArray) onFailure?.(finalUsers, permissionsArray)
const finalUserEmails = finalUsers.map(finalUser => `'${finalUser.email}'`) const finalUserEmails = finalUsers.map(finalUser => `'${finalUser.email}'`)
toast.error(`Unable to set permissions of ${finalUserEmails.join(', ')}.`) toastify.toast.error(
`Unable to set permissions of ${finalUserEmails.join(', ')}.`
)
} }
} }
}, },

View File

@ -1,6 +1,6 @@
/** @file Table displaying a list of projects. */ /** @file Table displaying a list of projects. */
import * as React from 'react' import * as React from 'react'
import toast from 'react-hot-toast' import * as toastify from 'react-toastify'
import ArrowUpIcon from 'enso-assets/arrow_up.svg' import ArrowUpIcon from 'enso-assets/arrow_up.svg'
import PlayIcon from 'enso-assets/play.svg' import PlayIcon from 'enso-assets/play.svg'
@ -14,7 +14,6 @@ import * as dateTime from '../dateTime'
import * as errorModule from '../../error' import * as errorModule from '../../error'
import * as eventModule from '../event' import * as eventModule from '../event'
import * as hooks from '../../hooks' import * as hooks from '../../hooks'
import * as loggerProvider from '../../providers/logger'
import * as modalProvider from '../../providers/modal' import * as modalProvider from '../../providers/modal'
import * as permissions from '../permissions' import * as permissions from '../permissions'
import * as presence from '../presence' import * as presence from '../presence'
@ -157,14 +156,14 @@ function ProjectActionButton(props: ProjectActionButtonProps) {
((state: spinner.SpinnerState | null) => void) | null ((state: spinner.SpinnerState | null) => void) | null
>(null) >(null)
const [shouldOpenWhenReady, setShouldOpenWhenReady] = React.useState(false) const [shouldOpenWhenReady, setShouldOpenWhenReady] = React.useState(false)
const [toastId, setToastId] = React.useState<string | null>(null) const [toastId, setToastId] = React.useState<toastify.Id | null>(null)
const openProject = React.useCallback(async () => { const openProject = React.useCallback(async () => {
setState(backendModule.ProjectState.openInProgress) setState(backendModule.ProjectState.openInProgress)
try { try {
switch (backend.type) { switch (backend.type) {
case backendModule.BackendType.remote: case backendModule.BackendType.remote:
setToastId(toast.loading(LOADING_MESSAGE)) setToastId(toastify.toast.loading(LOADING_MESSAGE))
setRowState({ ...rowState, isRunning: true }) setRowState({ ...rowState, isRunning: true })
await backend.openProject(project.id, null, project.title) await backend.openProject(project.id, null, project.title)
setCheckState(CheckState.checkingStatus) setCheckState(CheckState.checkingStatus)
@ -183,7 +182,7 @@ function ProjectActionButton(props: ProjectActionButtonProps) {
} }
} catch (error) { } catch (error) {
setCheckState(CheckState.notChecking) setCheckState(CheckState.notChecking)
toast.error( toastify.toast.error(
`Error opening project '${project.title}': ${ `Error opening project '${project.title}': ${
errorModule.tryGetMessage(error) ?? 'unknown error' errorModule.tryGetMessage(error) ?? 'unknown error'
}.` }.`
@ -195,7 +194,7 @@ function ProjectActionButton(props: ProjectActionButtonProps) {
React.useEffect(() => { React.useEffect(() => {
if (toastId != null) { if (toastId != null) {
return () => { return () => {
toast.dismiss(toastId) toastify.toast.dismiss(toastId)
} }
} else { } else {
return return
@ -222,7 +221,7 @@ function ProjectActionButton(props: ProjectActionButtonProps) {
React.useEffect(() => { React.useEffect(() => {
if (toastId != null && state !== backendModule.ProjectState.openInProgress) { if (toastId != null && state !== backendModule.ProjectState.openInProgress) {
toast.dismiss(toastId) toastify.toast.dismiss(toastId)
} }
}, [state, toastId]) }, [state, toastId])
@ -501,7 +500,6 @@ function ProjectName(props: InternalProjectNameProps) {
doCloseIde, doCloseIde,
}, },
} = props } = props
const logger = loggerProvider.useLogger()
const { backend } = backendProvider.useBackend() const { backend } = backendProvider.useBackend()
const doRename = async (newName: string) => { const doRename = async (newName: string) => {
@ -517,11 +515,7 @@ function ProjectName(props: InternalProjectNameProps) {
) )
return return
} catch (error) { } catch (error) {
const message = `Unable to rename project: ${ errorModule.toastAndLog('Unable to rename project', error)
errorModule.tryGetMessage(error) ?? 'unknown error.'
}`
toast.error(message)
logger.error(message)
throw error throw error
} }
} }
@ -701,7 +695,6 @@ function ProjectRow(
markItemAsVisible, markItemAsVisible,
}, },
} = props } = props
const logger = loggerProvider.useLogger()
const { backend } = backendProvider.useBackend() const { backend } = backendProvider.useBackend()
const { setModal } = modalProvider.useSetModal() const { setModal } = modalProvider.useSetModal()
const [item, setItem] = React.useState(rawItem) const [item, setItem] = React.useState(rawItem)
@ -723,9 +716,7 @@ function ProjectRow(
} catch (error) { } catch (error) {
setStatus(presence.Presence.present) setStatus(presence.Presence.present)
markItemAsVisible(key) markItemAsVisible(key)
const message = errorModule.tryGetMessage(error) ?? 'Unable to delete project.' errorModule.toastAndLog('Unable to delete project', error)
toast.error(message)
logger.error(message)
} }
} }
@ -764,11 +755,7 @@ function ProjectRow(
type: projectListEventModule.ProjectListEventType.delete, type: projectListEventModule.ProjectListEventType.delete,
projectId: key, projectId: key,
}) })
const message = `Error creating new project: ${ errorModule.toastAndLog('Error creating new project', error)
errorModule.tryGetMessage(error) ?? 'unknown error.'
}`
toast.error(message)
logger.error(message)
} }
} }
break break

View File

@ -1,6 +1,6 @@
/** @file Table displaying a list of secrets. */ /** @file Table displaying a list of secrets. */
import * as React from 'react' import * as React from 'react'
import toast from 'react-hot-toast' import * as toastify from 'react-toastify'
import PlusIcon from 'enso-assets/plus.svg' import PlusIcon from 'enso-assets/plus.svg'
import SecretIcon from 'enso-assets/secret.svg' import SecretIcon from 'enso-assets/secret.svg'
@ -79,10 +79,10 @@ function SecretCreateForm(props: InternalSecretCreateFormProps) {
const onSubmit = (event: React.FormEvent) => { const onSubmit = (event: React.FormEvent) => {
event.preventDefault() event.preventDefault()
if (name == null) { if (name == null) {
toast.error('Please provide a secret name.') toastify.toast.error('Please provide a secret name.')
} else if (value == null) { } else if (value == null) {
// Secret value explicitly can be empty. // Secret value explicitly can be empty.
toast.error('Please provide a secret value.') toastify.toast.error('Please provide a secret value.')
} else { } else {
unsetModal() unsetModal()
dispatchSecretListEvent({ dispatchSecretListEvent({
@ -322,11 +322,7 @@ function SecretRow(
} catch (error) { } catch (error) {
setStatus(presence.Presence.present) setStatus(presence.Presence.present)
markItemAsVisible(key) markItemAsVisible(key)
const message = `Unable to delete secret: ${ errorModule.toastAndLog('Unable to delete secret', error)
errorModule.tryGetMessage(error) ?? 'unknown error.'
}`
toast.error(message)
logger.error(message)
} }
} }
} }
@ -337,7 +333,7 @@ function SecretRow(
if (key === event.placeholderId) { if (key === event.placeholderId) {
if (backend.type !== backendModule.BackendType.remote) { if (backend.type !== backendModule.BackendType.remote) {
const message = 'Secrets cannot be created on the local backend.' const message = 'Secrets cannot be created on the local backend.'
toast.error(message) toastify.toast.error(message)
logger.error(message) logger.error(message)
} else { } else {
setStatus(presence.Presence.inserting) setStatus(presence.Presence.inserting)
@ -358,11 +354,7 @@ function SecretRow(
type: secretListEventModule.SecretListEventType.delete, type: secretListEventModule.SecretListEventType.delete,
secretId: key, secretId: key,
}) })
const message = `Error creating new secret: ${ errorModule.toastAndLog('Error creating new secret', error)
errorModule.tryGetMessage(error) ?? 'unknown error.'
}`
toast.error(message)
logger.error(message)
} }
} }
} }
@ -492,7 +484,7 @@ function SecretsTable(props: SecretsTableProps) {
case secretListEventModule.SecretListEventType.create: { case secretListEventModule.SecretListEventType.create: {
if (backend.type !== backendModule.BackendType.remote) { if (backend.type !== backendModule.BackendType.remote) {
const message = 'Secrets cannot be created on the local backend.' const message = 'Secrets cannot be created on the local backend.'
toast.error(message) toastify.toast.error(message)
logger.error(message) logger.error(message)
} else { } else {
const placeholderItem: backendModule.SecretAsset = { const placeholderItem: backendModule.SecretAsset = {

View File

@ -4,6 +4,10 @@
// === tryGetMessage === // === tryGetMessage ===
// ===================== // =====================
import * as toastify from 'react-toastify'
import * as loggerProvider from './providers/logger'
/** Evaluates the given type only if it the exact same type as {@link Expected}. */ /** Evaluates the given type only if it the exact same type as {@link Expected}. */
type MustBe<T, Expected> = (<U>() => U extends T ? 1 : 2) extends <U>() => U extends Expected type MustBe<T, Expected> = (<U>() => U extends T ? 1 : 2) extends <U>() => U extends Expected
? 1 ? 1
@ -29,6 +33,37 @@ export function tryGetMessage(error: unknown): string | null {
: null : null
} }
/** Like {@link tryGetMessage} but return the string representation of the value if it is not an
* {@link Error} */
export function getMessageOrToString<T>(error: unknown) {
return tryGetMessage(error) ?? String(error)
}
/** Return a toastify option object that renders an error message. */
// eslint-disable-next-line no-restricted-syntax
export function render(f: (message: string) => string): toastify.UpdateOptions {
return { render: ({ data }) => f(getMessageOrToString(data)) }
}
/** Send a toast with rendered error message. Same message is logged as an error.
*
* @param messagePrefix - a prefix to add to the error message, should represent the immediate
* error context.
* @param error - the error to render, which will be appended to the message prefix.
* @param options - additional options to pass to the toast API.
* @returns - the toast ID. */
export function toastAndLog(
messagePrefix: string,
error?: unknown,
options?: toastify.ToastOptions
) {
const message =
error == null ? `${messagePrefix}.` : `${messagePrefix}: ${getMessageOrToString(error)}`
const id = toastify.toast.error(message, options)
loggerProvider.useLogger().error(message)
return id
}
// ============================ // ============================
// === UnreachableCaseError === // === UnreachableCaseError ===
// ============================ // ============================

View File

@ -1,4 +1,5 @@
@import url("https://fonts.googleapis.com/css2?family=M+PLUS+1:wght@500;600;700&display=swap"); @import url("https://fonts.googleapis.com/css2?family=M+PLUS+1:wght@500;600;700&display=swap");
@import "react-toastify/dist/ReactToastify.css";
body { body {
margin: 0; margin: 0;

View File

@ -387,7 +387,8 @@
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@types/semver": "^7.3.9", "@types/semver": "^7.3.9",
"enso-content-config": "^1.0.0" "enso-content-config": "^1.0.0",
"react-toastify": "^9.1.3"
}, },
"devDependencies": { "devDependencies": {
"@esbuild-plugins/node-globals-polyfill": "^0.2.3", "@esbuild-plugins/node-globals-polyfill": "^0.2.3",
@ -443,11 +444,12 @@
"@typescript-eslint/eslint-plugin": "^5.49.0", "@typescript-eslint/eslint-plugin": "^5.49.0",
"@typescript-eslint/parser": "^5.49.0", "@typescript-eslint/parser": "^5.49.0",
"enso-authentication": "^1.0.0", "enso-authentication": "^1.0.0",
"enso-chat": "git+ssh://git@github.com/enso-org/enso-bot.git#3b76888ec6bd3579016e70ef83ba282714aec47d", "enso-chat": "git://github.com/enso-org/enso-bot#wip/sb/initial-implementation",
"enso-content": "^1.0.0", "enso-content": "^1.0.0",
"eslint": "^8.32.0", "eslint": "^8.32.0",
"eslint-plugin-jsdoc": "^39.6.8", "eslint-plugin-jsdoc": "^39.6.8",
"eslint-plugin-react": "^7.32.1", "eslint-plugin-react": "^7.32.1",
"react-toastify": "^9.1.3",
"tailwindcss": "^3.2.7", "tailwindcss": "^3.2.7",
"typescript": "^4.9.4" "typescript": "^4.9.4"
}, },
@ -525,8 +527,8 @@
"@fortawesome/free-brands-svg-icons": "^6.3.0", "@fortawesome/free-brands-svg-icons": "^6.3.0",
"@fortawesome/react-fontawesome": "^0.2.0", "@fortawesome/react-fontawesome": "^0.2.0",
"enso-common": "^1.0.0", "enso-common": "^1.0.0",
"react-hot-toast": "^2.4.0",
"react-router-dom": "^6.8.1", "react-router-dom": "^6.8.1",
"react-toastify": "^9.1.3",
"ts-results": "^3.3.0" "ts-results": "^3.3.0"
}, },
"devDependencies": { "devDependencies": {
@ -6818,6 +6820,14 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/clsx": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz",
"integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==",
"engines": {
"node": ">=6"
}
},
"node_modules/collection-visit": { "node_modules/collection-visit": {
"version": "1.0.0", "version": "1.0.0",
"license": "MIT", "license": "MIT",
@ -9283,13 +9293,6 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/goober": {
"version": "2.1.12",
"license": "MIT",
"peerDependencies": {
"csstype": "^3.0.10"
}
},
"node_modules/gopd": { "node_modules/gopd": {
"version": "1.0.1", "version": "1.0.1",
"dev": true, "dev": true,
@ -13501,20 +13504,6 @@
"react": "^18.2.0" "react": "^18.2.0"
} }
}, },
"node_modules/react-hot-toast": {
"version": "2.4.0",
"license": "MIT",
"dependencies": {
"goober": "^2.1.10"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"react": ">=16",
"react-dom": ">=16"
}
},
"node_modules/react-is": { "node_modules/react-is": {
"version": "17.0.2", "version": "17.0.2",
"license": "MIT", "license": "MIT",
@ -13689,6 +13678,18 @@
"react": "^16.0.0 || ^17.0.0 || ^18.0.0" "react": "^16.0.0 || ^17.0.0 || ^18.0.0"
} }
}, },
"node_modules/react-toastify": {
"version": "9.1.3",
"resolved": "https://registry.npmjs.org/react-toastify/-/react-toastify-9.1.3.tgz",
"integrity": "sha512-fPfb8ghtn/XMxw3LkxQBk3IyagNpF/LIKjOBflbexr2AWxAH1MJgvnESwEwBn9liLFXgTKWgBSdZpw9m4OTHTg==",
"dependencies": {
"clsx": "^1.1.1"
},
"peerDependencies": {
"react": ">=16",
"react-dom": ">=16"
}
},
"node_modules/read-cache": { "node_modules/read-cache": {
"version": "1.0.0", "version": "1.0.0",
"dev": true, "dev": true,
@ -20625,6 +20626,11 @@
"mimic-response": "^1.0.0" "mimic-response": "^1.0.0"
} }
}, },
"clsx": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz",
"integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg=="
},
"collection-visit": { "collection-visit": {
"version": "1.0.0", "version": "1.0.0",
"peer": true, "peer": true,
@ -21455,8 +21461,8 @@
"@fortawesome/free-brands-svg-icons": "^6.3.0", "@fortawesome/free-brands-svg-icons": "^6.3.0",
"@fortawesome/react-fontawesome": "^0.2.0", "@fortawesome/react-fontawesome": "^0.2.0",
"enso-common": "^1.0.0", "enso-common": "^1.0.0",
"react-hot-toast": "^2.4.0",
"react-router-dom": "^6.8.1", "react-router-dom": "^6.8.1",
"react-toastify": "^9.1.3",
"ts-results": "^3.3.0", "ts-results": "^3.3.0",
"typescript": "^4.9.3" "typescript": "^4.9.3"
} }
@ -21503,6 +21509,7 @@
"eslint-plugin-jsdoc": "^40.0.2", "eslint-plugin-jsdoc": "^40.0.2",
"globals": "^13.20.0", "globals": "^13.20.0",
"portfinder": "^1.0.32", "portfinder": "^1.0.32",
"react-toastify": "^9.1.3",
"tsx": "^3.12.6", "tsx": "^3.12.6",
"typescript": "^4.9.3" "typescript": "^4.9.3"
} }
@ -21524,7 +21531,7 @@
"@typescript-eslint/eslint-plugin": "^5.49.0", "@typescript-eslint/eslint-plugin": "^5.49.0",
"@typescript-eslint/parser": "^5.49.0", "@typescript-eslint/parser": "^5.49.0",
"enso-authentication": "^1.0.0", "enso-authentication": "^1.0.0",
"enso-chat": "git+ssh://git@github.com/enso-org/enso-bot.git#3b76888ec6bd3579016e70ef83ba282714aec47d", "enso-chat": "git://github.com/enso-org/enso-bot#wip/sb/initial-implementation",
"enso-content": "^1.0.0", "enso-content": "^1.0.0",
"esbuild": "^0.17.15", "esbuild": "^0.17.15",
"esbuild-plugin-time": "^1.0.0", "esbuild-plugin-time": "^1.0.0",
@ -21534,6 +21541,7 @@
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-router-dom": "^6.7.0", "react-router-dom": "^6.7.0",
"react-toastify": "^9.1.3",
"tailwindcss": "^3.2.7", "tailwindcss": "^3.2.7",
"typescript": "^4.9.4" "typescript": "^4.9.4"
}, },
@ -22624,10 +22632,6 @@
} }
} }
}, },
"goober": {
"version": "2.1.12",
"requires": {}
},
"gopd": { "gopd": {
"version": "1.0.1", "version": "1.0.1",
"dev": true, "dev": true,
@ -25408,12 +25412,6 @@
"scheduler": "^0.23.0" "scheduler": "^0.23.0"
} }
}, },
"react-hot-toast": {
"version": "2.4.0",
"requires": {
"goober": "^2.1.10"
}
},
"react-is": { "react-is": {
"version": "17.0.2", "version": "17.0.2",
"peer": true "peer": true
@ -25540,6 +25538,14 @@
"react-is": "^16.12.0 || ^17.0.0 || ^18.0.0" "react-is": "^16.12.0 || ^17.0.0 || ^18.0.0"
} }
}, },
"react-toastify": {
"version": "9.1.3",
"resolved": "https://registry.npmjs.org/react-toastify/-/react-toastify-9.1.3.tgz",
"integrity": "sha512-fPfb8ghtn/XMxw3LkxQBk3IyagNpF/LIKjOBflbexr2AWxAH1MJgvnESwEwBn9liLFXgTKWgBSdZpw9m4OTHTg==",
"requires": {
"clsx": "^1.1.1"
}
},
"read-cache": { "read-cache": {
"version": "1.0.0", "version": "1.0.0",
"dev": true, "dev": true,

View File

@ -63,7 +63,7 @@ serde = { version = "1.0.130", features = ["derive"] }
serde_json = { workspace = true } serde_json = { workspace = true }
serde_yaml = { workspace = true } serde_yaml = { workspace = true }
scopeguard = "1.1.0" scopeguard = "1.1.0"
strum = { version = "0.24.0", features = ["derive"] } strum = { workspace = true }
sysinfo = "0.26.2" sysinfo = "0.26.2"
tar = "0.4.37" tar = "0.4.37"
tempfile = "3.2.0" tempfile = "3.2.0"

View File

@ -66,7 +66,7 @@ serde_json = { workspace = true }
serde_yaml = { workspace = true } serde_yaml = { workspace = true }
scopeguard = "1.1.0" scopeguard = "1.1.0"
sha2 = "0.10.2" sha2 = "0.10.2"
strum = { version = "0.24.0", features = ["derive"] } strum = { workspace = true }
symlink = "0.1.0" symlink = "0.1.0"
syn = { workspace = true } syn = { workspace = true }
sysinfo = "0.26.2" sysinfo = "0.26.2"

View File

@ -23,7 +23,7 @@ octocrab = { workspace = true }
serde = { version = "1.0.130", features = ["derive"] } serde = { version = "1.0.130", features = ["derive"] }
serde_json = { workspace = true } serde_json = { workspace = true }
serde_yaml = { workspace = true } serde_yaml = { workspace = true }
strum = { version = "0.24.0", features = ["derive"] } strum = { workspace = true }
tempfile = "3.2.0" tempfile = "3.2.0"
tokio = { workspace = true } tokio = { workspace = true }
toml = "0.5.9" toml = "0.5.9"